Raypx

Input Validation

Validating procedure inputs with Zod schemas.

All RPC procedure inputs are validated with Zod schemas. oRPC integrates Zod seamlessly -- the input types are inferred from your schema and flow through to the client with full type safety.

The .input() Pattern

Attach a Zod schema to any procedure using .input():

import { z } from "zod"

export const myProcedure = protectedProcedure
  .input(z.object({
    id: z.string().min(1),
    title: z.string().min(1).max(200),
    status: z.enum(["active", "archived"]).optional(),
  }))
  .handler(async ({ context, input }) => {
    // input is fully typed:
    // { id: string; title: string; status?: "active" | "archived" | undefined }
  })

The input parameter in the handler is automatically typed based on the Zod schema. You get full autocomplete and type checking without any manual type annotations.

Error Handling

When input validation fails, oRPC returns a structured error to the client. For application-level errors, throw ORPCError:

import { ORPCError } from "@orpc/server"

export const documentRouter = {
  get: protectedProcedure
    .input(z.object({ id: z.string().min(1) }))
    .handler(async ({ context, input }) => {
      const db = createDb()
      const [doc] = await db
        .select()
        .from(documents)
        .where(and(eq(documents.id, input.id), eq(documents.userId, context.session.user.id)))
        .limit(1)

      if (!doc) {
        throw new Error("Document not found")
      }
      return doc
    }),
}

Use Error for domain errors (like "not found") and ORPCError for framework-level errors (like UNAUTHORIZED). Both are caught by the global onError interceptor and logged.

Type Inference

The types flow automatically from server to client. No code generation needed:

// Server: packages/rpc/src/routers/profile.ts
export const profileRouter = {
  update: protectedProcedure
    .input(z.object({
      defaultModel: z.string().optional(),
      preferredLocale: z.string().optional(),
      outputTone: z.string().optional(),
      marketingOptIn: z.boolean().optional(),
    }))
    .handler(async ({ context, input }) => {
      // input type: { defaultModel?: string; preferredLocale?: string; ... }
    }),
}

// Client: the input type is inferred automatically
orpc.profile.update.useMutation({
  mutationFn: (input) => orpc.profile.update.mutate(input),
  // input is typed: { defaultModel?: string; preferredLocale?: string; ... }
})

Real-World Examples

Profile Update

packages/rpc/src/routers/profile.ts
update: protectedProcedure
  .input(z.object({
    defaultModel: z.string().optional(),
    preferredLocale: z.string().optional(),
    outputTone: z.string().optional(),
    marketingOptIn: z.boolean().optional(),
  }))
  .handler(async ({ context, input }) => {
    const db = createDb()
    const userId = context.session.user.id

    const [existing] = await db
      .select()
      .from(profiles)
      .where(eq(profiles.userId, userId))
      .limit(1)

    if (existing) {
      const [updated] = await db
        .update(profiles)
        .set({ ...input, updatedAt: new Date() })
        .where(eq(profiles.userId, userId))
        .returning()
      return updated
    }

    const [created] = await db
      .insert(profiles)
      .values({ userId, ...input })
      .returning()
    return created
  })

Document Get

packages/rpc/src/routers/document.ts
get: protectedProcedure
  .input(z.object({ id: z.string().min(1) }))
  .handler(async ({ context, input }) => {
    const db = createDb()
    const [doc] = await db
      .select()
      .from(documents)
      .where(and(eq(documents.id, input.id), eq(documents.userId, context.session.user.id)))
      .limit(1)
    if (!doc) throw new Error("Document not found")
    return doc
  })

Conversation Create

packages/rpc/src/routers/conversation.ts
create: protectedProcedure
  .input(z.object({
    title: z.string().min(1),
    task: z.string().optional(),
  }))
  .handler(async ({ context, input }) => {
    const db = createDb()
    const [convo] = await db
      .insert(conversations)
      .values({
        userId: context.session.user.id,
        title: input.title,
        task: input.task ?? "summarize",
      })
      .returning()
    return convo
  })

Conversation Send Message

packages/rpc/src/routers/conversation.ts
sendMessage: protectedProcedure
  .input(z.object({
    conversationId: z.string().min(1),
    content: z.string().min(1),
  }))
  .handler(async ({ context, input }) => {
    // Validates that both conversationId and content are non-empty strings
    // Returns typed response with userMessage and assistantMessage
  })

Validation Patterns

On this page