Overview
Courier validates data using the Standard Schema specification. Any library that implements the StandardSchemaV1 interface works out of the box -- including Zod, Valibot, and ArkType.
Validation happens at two points:
- Variables -- validated before the HTTP request is sent. If validation fails, the request never leaves the client.
- Response -- validated after the response is received. If the backend returns unexpected data, you get a clear error instead of a runtime crash.
Adding Schemas to Endpoints
Pass schemas to the .response() and .variables() steps of the chain:
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { z } from "zod"
const { endpoint } = courier()
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 api = defineApi({
baseURL: "https://api.example.com",
groups: {
users: defineGroup({
prefix: "users",
endpoints: {
// Response-only validation
getAll: endpoint.get("").response(z.array(UserSchema)),
// Both response and variables validation
create: endpoint.post("").variables(CreateUserSchema).response(UserSchema),
},
}),
},
})Types are inferred from schemas
When you pass schemas to .response() and .variables(), the return type and variables type are automatically inferred from the schema output types. You do not need to write .response<User>() or .variables<CreateUserInput>() -- the types are derived from the schema.
How Validation Works
Variables validation (pre-request)
When a variables schema is attached, Courier validates the variables before any middleware or HTTP call runs. If validation fails, fetch is never called:
// CreateUserSchema requires name (string) and email (string)
// This will throw a ValidationError immediately -- no HTTP request is made
await caller.users.create({ name: 123 })Response validation (post-request)
When a response schema is attached, Courier validates the parsed response body after the fetch completes but before returning data to the caller:
// If the backend returns { id: 1, name: null } but the schema
// requires name to be a string, a ValidationError is thrown
const user = await caller.users.getAll()Streams and push channels
A stream or a push channel served with .implement() holds each event it produces to its .response() schema before the event leaves the server. The client receives the schema's output, and the first event that does not match ends the subscription with a RealtimeValidationError (code 4422) whose issues are the schema's:
import { RealtimeValidationError } from "@noverax/courier/realtime"
try {
for await (const event of caller.live.ticks()) render(event.data)
} catch (error) {
if (error instanceof RealtimeValidationError) console.log(error.issues)
}Channels
What a channel's handlers send with ctx.send() or ctx.broadcast() is held to its .response() schema where it is sent, and what goes out is the schema's output. A value that does not match is not sent: the call throws a RealtimeValidationError, so in onMessage the client that sent the message hears of it with code 4422 and the channel goes on, and in onJoin the subscription is refused. A broadcast that does not match reaches no one.
ctx.send() and ctx.broadcast() go out in the order they are called, so the schema has to validate synchronously. One that answers asynchronously — a Zod schema with an async refinement — is refused the first time the channel sends, with ConfigurationError("ASYNC_RESPONSE_SCHEMA"), and the client reads code 5000.
Schema transforms
Standard Schema libraries often support transforms (e.g., z.coerce.date()). Courier respects these -- the validated and transformed value is what you receive:
const EventSchema = z.object({
id: z.number(),
title: z.string(),
createdAt: z.coerce.date(), // string → Date
})
const api = defineApi({
baseURL: "https://api.example.com",
groups: {
events: defineGroup({
prefix: "events",
endpoints: {
get: endpoint.get(":id").response(EventSchema),
},
}),
},
})
// event.createdAt is a Date object, not a string
const event = await caller.events.get({ id: 1 })ValidationError
When validation fails, Courier throws a ValidationError with structured issue data:
import { CourierError } from "@noverax/courier"
declare class ValidationError extends CourierError<"VALIDATION_ERROR"> {
/** Which schema failed: the answer's, the call's variables, or a message a channel received */
readonly target: "response" | "variables" | "receive"
/** Array of validation issues */
readonly issues: readonly ValidationIssue[]
}
type ValidationIssue = {
readonly message: string
readonly path?: ReadonlyArray<
PropertyKey | { readonly key: PropertyKey }
>
}Catching validation errors
Use the isValidationError type guard to narrow the error type:
import { isValidationError } from "@noverax/courier"
try {
await caller.users.create(formData)
} catch (error) {
if (isValidationError(error)) {
console.log(error.target) // "variables" or "response"
console.log(error.issues) // [{ message: "Required", path: ["email"] }]
console.log(error.message) // "Validation failed (variables): Required"
}
}Validation errors in proxy mode
When validation fails inside an adapter (e.g., apiRoute), the error is returned as a JSON response with status 422:
{
"success": false,
"message": "Validation failed (variables): Required",
"target": "variables",
"issues": [
{ "message": "Required", "path": ["email"] }
]
}A proxy client reads that answer back into the ValidationError a direct call rejects with, so isValidationError, error.target and error.issues work the same in both modes. A 422 the upstream API answered with itself is not one: it stays an HTTPError carrying the upstream's body.
Endpoints Without Schemas
When a slot is given a type argument instead of a schema, no validation runs. This is the default behavior and incurs zero overhead:
// No validation -- types are manual
endpoint.get("").response<User[]>()
// No validation -- only proxy hints
endpoint.get("").response<User[]>()Next Steps
- Zod Examples -- Zod-specific patterns and schema examples
- Error Handling -- how to handle all error types including ValidationError