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:
// Manual types -- you maintain the generic parameters yourself
endpoint.get("").variables<{ status: "active" | "inactive" }>().response<User[]>()With Zod schemas, types are inferred automatically:
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
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
const users = defineGroup({
prefix: "users",
endpoints: {
getAll: endpoint.get("").response(z.array(UserSchema)),
},
})Nested objects
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
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
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:
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:
// 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:
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 lowercasedHandling Validation Errors
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:
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
- Standard Schema -- how validation works under the hood
- Error Handling -- catching and displaying validation errors