Skip to content

ValidationZod Examples

Zod Examples

Practical examples of using Zod schemas for request validation and type inference in Courier endpoints.

2 min readboth modes

Why Zod?

Zod is the most popular Standard Schema-compatible validation library. When you pass Zod schemas to .response() and .variables(), Courier automatically infers TypeScript types from the schema -- no manual generics needed.

Not just Zod

Everything on this page works identically with Valibot and ArkType (or any library implementing the Standard Schema spec). The examples use Zod for familiarity, but the patterns apply universally.

Type Inference from Schemas

Without schemas, you write explicit generics:

ts
// Manual types -- you maintain the generic parameters yourself
endpoint.get("").variables<{ status: "active" | "inactive" }>().response<User[]>()

With Zod schemas, types are inferred automatically:

lib/sources.ts
import { courier } from "@noverax/courier/define"
import { z } from "zod"

const { endpoint } = courier()

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
  role: z.enum(["admin", "user"]),
})

const FilterSchema = z.object({
  status: z.enum(["active", "inactive"]),
})

// Return type is z.infer<typeof UserSchema>[]
// Variables type is z.infer<typeof FilterSchema>
// No generics needed -- everything is inferred
endpoint.get("").variables(FilterSchema).response(z.array(UserSchema))

Common Schema Patterns

Object schemas

lib/schemas.ts
import { z } from "zod"

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
  createdAt: z.string(),
})

export const CreateUserSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.email("Invalid email address"),
  role: z.enum(["admin", "user"]).default("user"),
})

export const UpdateUserSchema = CreateUserSchema.partial()

Array responses

lib/sources.ts
const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response(z.array(UserSchema)),
  },
})

Nested objects

lib/schemas.ts
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().regex(/^\d{5}$/),
})

const CompanySchema = z.object({
  id: z.number(),
  name: z.string(),
  address: AddressSchema,
  employees: z.array(UserSchema),
})

Paginated responses

lib/schemas.ts
function paginated<T extends z.ZodType>(itemSchema: T) {
  return z.object({
    items: z.array(itemSchema),
    total: z.number(),
    page: z.number(),
    pageSize: z.number(),
  })
}

const PaginatedUsersSchema = paginated(UserSchema)

Variables with path params

lib/sources.ts
const GetByIdSchema = z.object({
  id: z.number(),
})

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getById: endpoint.get(":id").variables(GetByIdSchema).response(UserSchema),
  },
})

Full Source Definition

Here is a complete source definition using Zod schemas for every endpoint:

lib/sources.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { z } from "zod"

const { endpoint } = courier()

// --- Schemas ---

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
})

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
})

const UpdateUserSchema = z.object({
  id: z.number(),
  name: z.string().min(1).optional(),
  email: z.email().optional(),
})

const DeleteSchema = z.object({
  id: z.number(),
})

// --- Source ---

export const api = defineApi({
  baseURL: process.env.API_URL!,
  groups: {
    users: defineGroup({
      prefix: "users",
      endpoints: {
        getAll: endpoint.get("").response(z.array(UserSchema)),

        getById: endpoint.get(":id").variables(z.object({ id: z.number() })).response(UserSchema),

        create: endpoint.post("").variables(CreateUserSchema).response(UserSchema),

        update: endpoint.put(":id").variables(UpdateUserSchema).response(UserSchema),

        delete: endpoint.del(":id").variables(DeleteSchema),
      },
    }),
  },
})

Response-only vs Variables-only

You can attach a schema to just one side:

lib/sources.ts
// Response-only: validate what the backend returns, no variables validation
endpoint.get("").response(z.array(UserSchema))

// Variables-only: validate what the caller sends, no response validation
endpoint.post("").variables(CreateUserSchema)

// Both: full bidirectional validation
endpoint.post("").variables(CreateUserSchema).response(UserSchema)

Transforms and Coercion

Zod transforms are applied during validation. The validated value (after transforms) is what your code receives:

lib/schemas.ts
const EventSchema = z.object({
  id: z.number(),
  title: z.string(),
  // Backend sends ISO string, Zod coerces to Date
  startDate: z.coerce.date(),
  // Trim and lowercase the slug
  slug: z.string().trim().toLowerCase(),
})

// event.startDate is a Date, event.slug is trimmed and lowercased

Handling Validation Errors

components/create-user-form.tsx
import { isValidationError } from "@noverax/courier"

async function handleSubmit(formData: FormData) {
  try {
    await caller.users.create({
      name: formData.get("name") as string,
      email: formData.get("email") as string,
    })
  } catch (error) {
    if (isValidationError(error)) {
      // error.target === "variables" means the input was invalid
      // error.issues contains Zod's detailed error messages
      for (const issue of error.issues) {
        console.log(issue.path, issue.message)
        // ["email"] "Invalid email address"
      }
    }
  }
}

Using Valibot

The same patterns work with Valibot -- just swap the import:

lib/sources.ts
import * as v from "valibot"

const UserSchema = v.object({
  id: v.number(),
  name: v.string(),
  email: v.pipe(v.string(), v.email()),
})

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response(v.array(UserSchema)),
  },
})

Next Steps