# Standard Schema Validation

Validate request variables and response data with any Standard Schema library -- Zod, Valibot, ArkType, and more.

## Overview

Courier validates data using the [Standard Schema](https://github.com/standard-schema/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:

1. **Variables** -- validated **before** the HTTP request is sent. If validation fails, the request never leaves the client.
2. **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:

```ts title="lib/sources.ts"
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:

```ts
// 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:

```ts
// 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:

```ts
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:

```ts title="lib/sources.ts"
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:

```ts title="types"
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:

```ts title="app/actions.ts"
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:

```json
{
  "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:

```ts
// No validation -- types are manual
endpoint.get("").response<User[]>()

// No validation -- only proxy hints
endpoint.get("").response<User[]>()
```

## Next Steps

- [Zod Examples](/docs/validation/zod) -- Zod-specific patterns and schema examples
- [Error Handling](/docs/core-concepts/error-handling) -- how to handle all error types including ValidationError
