For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/routing/validation.md.

Validation

Any input of an API must be checked: it comes from clients you do not control. In Dotpress, each route declares the Zod schemas of its inputs, and the framework:

  • parses and validates the request before the handler runs;
  • answers 400 with every issue found, when the request is invalid;
  • gives the handler the parsed values, typed from the schemas.
defineRoute<"/members", {
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}, false, unknown[]>(route: RouteConfig<"/members", {
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<P extends string, S extends RouteSchema, TParentParams = EmptyObject, TRawBody extends boolean = false, TProvided extends readonly unknown[] = unknown[], TGroupContext = unknown>.method?: HttpMethod | undefined

Defaults to get.

method
: 'post',
RouteConfig<"/members", { body: ZodObject<{ name: ZodString; age: ZodNumber; location: ZodOptional<ZodString>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.path: "/members"
path
: '/members',
RouteConfig<"/members", { body: ZodObject<{ name: ZodString; age: ZodNumber; location: ZodOptional<ZodString>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<{
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}> | undefined
schema
: (
z: typeof import("zod")
z
) => ({
body: ZodObject<{
    name: ZodString;
    age: ZodNumber;
    location: ZodOptional<ZodString>;
}, $strip>
body
:
z: typeof import("zod")
z
.
function object<{
    name: ZodString;
    age: ZodNumber;
    location: ZodOptional<ZodString>;
}>(shape?: {
    name: ZodString;
    age: ZodNumber;
    location: ZodOptional<ZodString>;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<{
    name: ZodString;
    age: ZodNumber;
    location: ZodOptional<...>;
}, $strip>
object
({
name: ZodString
name
:
z: typeof import("zod")
z
.
function string(params?: string | $ZodStringParams): ZodString (+1 overload)
string
().
_ZodString<$ZodStringInternals<string>>.trim(): ZodString
trim
().
_ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | $ZodCheckMinLengthParams): ZodString
min
(1),
age: ZodNumber
age
:
z: typeof import("zod")
z
.
function number(params?: string | $ZodNumberParams): ZodNumber
number
().
_ZodNumber<$ZodNumberInternals<number>>.int(params?: string | $ZodCheckNumberFormatParams): ZodNumber

Consider z.int() instead. This API is considered legacy; it will never be removed but a better alternative exists.

int
().
_ZodNumber<$ZodNumberInternals<number>>.min(value: number, params?: string | $ZodCheckGreaterThanParams): ZodNumber
min
(13),
location: ZodOptional<ZodString>
location
:
z: typeof import("zod")
z
.
function string(params?: string | $ZodStringParams): ZodString (+1 overload)
string
().
ZodType<any, any, $ZodStringInternals<string>>.optional(): ZodOptional<ZodString>
optional
(),
}), }),
RouteConfig<"/members", { body: ZodObject<{ name: ZodString; age: ZodNumber; location: ZodOptional<ZodString>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<{
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}, {}, false, unknown>
handler
: ({
req: RouteRequest<{
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}, {}, false>
req
}) => ({
received: {
    name: string;
    age: number;
    location?: string | undefined;
}
received
:
req: RouteRequest<{
    body: ZodObject<{
        name: ZodString;
        age: ZodNumber;
        location: ZodOptional<ZodString>;
    }, $strip>;
}, {}, false>
req
.
DotpressRequest<{}, QueryValues, { name: string; age: number; location?: string | undefined; }, UploadedFiles>.body: {
    name: string;
    age: number;
    location?: string | undefined;
}
body
}),
})

What can be validated

SchemaValidatesTyped as
paramsPath parameters (strings)req.params
queryQuery stringreq.query
bodyRequest body: JSON, form fields, textreq.body
filesUploaded files, multipart/form-datareq.files
responseThe value returned by the handlerthe return type of the handler
defineRoute<"/orgs/:orgId/users/:userId", {
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, false, unknown[]>(route: RouteConfig<"/orgs/:orgId/users/:userId", {
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<P extends string, S extends RouteSchema, TParentParams = EmptyObject, TRawBody extends boolean = false, TProvided extends readonly unknown[] = unknown[], TGroupContext = unknown>.method?: HttpMethod | undefined

Defaults to get.

method
: 'put',
RouteConfig<"/orgs/:orgId/users/:userId", { params: ZodObject<{ orgId: ZodUUID; userId: ZodUUID; }, $strip>; query: ZodObject<{ notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>; }, $strip>; body: ZodObject<...>; }, EmptyObject, false, unknown[], unknown>.path: "/orgs/:orgId/users/:userId"
path
: '/orgs/:orgId/users/:userId',
RouteConfig<"/orgs/:orgId/users/:userId", { params: ZodObject<{ orgId: ZodUUID; userId: ZodUUID; }, $strip>; query: ZodObject<{ notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>; }, $strip>; body: ZodObject<...>; }, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}> | undefined
schema
: (
z: typeof import("zod")
z
) => ({
params: ZodObject<{
    orgId: ZodUUID;
    userId: ZodUUID;
}, $strip>
params
:
z: typeof import("zod")
z
.
function object<{
    orgId: ZodUUID;
    userId: ZodUUID;
}>(shape?: {
    orgId: ZodUUID;
    userId: ZodUUID;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<{
    orgId: ZodUUID;
    userId: ZodUUID;
}, $strip>
object
({
orgId: ZodUUID
orgId
:
z: typeof import("zod")
z
.
function uuid(params?: string | $ZodUUIDParams): ZodUUID
uuid
(),
userId: ZodUUID
userId
:
z: typeof import("zod")
z
.
function uuid(params?: string | $ZodUUIDParams): ZodUUID
uuid
() }),
query: ZodObject<{
    notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
}, $strip>
query
:
z: typeof import("zod")
z
.
function object<{
    notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
}>(shape?: {
    notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<{
    notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
}, $strip>
object
({
notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>
notify
:
z: typeof import("zod")
z
.
const stringbool: (_params?: string | $ZodStringBoolParams) => ZodCodec<ZodString, ZodBoolean>
stringbool
().
ZodType<any, any, $ZodPipeInternals<ZodString, ZodBoolean>>.default(def: boolean): ZodDefault<ZodCodec<ZodString, ZodBoolean>> (+1 overload)
default
(false) }),
body: ZodObject<{
    email: ZodEmail;
    name: ZodString;
}, $strip>
body
:
z: typeof import("zod")
z
.
function object<{
    email: ZodEmail;
    name: ZodString;
}>(shape?: {
    email: ZodEmail;
    name: ZodString;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<{
    email: ZodEmail;
    name: ZodString;
}, $strip>
object
({
email: ZodEmail
email
:
z: typeof import("zod")
z
.
function email(params?: string | $ZodEmailParams): ZodEmail
email
(),
name: ZodString
name
:
z: typeof import("zod")
z
.
function string(params?: string | $ZodStringParams): ZodString (+1 overload)
string
() }),
}),
RouteConfig<"/orgs/:orgId/users/:userId", { params: ZodObject<{ orgId: ZodUUID; userId: ZodUUID; }, $strip>; query: ZodObject<{ notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>; }, $strip>; body: ZodObject<...>; }, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, {
    orgId: string;
    userId: string;
}, false, unknown>
handler
: ({
req: RouteRequest<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, {
    orgId: string;
    userId: string;
}, false>
req
}) => ({ ...
req: RouteRequest<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, {
    orgId: string;
    userId: string;
}, false>
req
.
DotpressRequest<{ orgId: string; userId: string; }, { notify: boolean; }, { email: string; name: string; }, UploadedFiles>.params: {
    orgId: string;
    userId: string;
}
params
, ...
req: RouteRequest<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, {
    orgId: string;
    userId: string;
}, false>
req
.
DotpressRequest<{ orgId: string; userId: string; }, { notify: boolean; }, { email: string; name: string; }, UploadedFiles>.body: {
    email: string;
    name: string;
}
body
,
notify: boolean
notify
:
req: RouteRequest<{
    params: ZodObject<{
        orgId: ZodUUID;
        userId: ZodUUID;
    }, $strip>;
    query: ZodObject<{
        notify: ZodDefault<ZodCodec<ZodString, ZodBoolean>>;
    }, $strip>;
    body: ZodObject<{
        email: ZodEmail;
        name: ZodString;
    }, $strip>;
}, {
    orgId: string;
    userId: string;
}, false>
req
.
DotpressRequest<{ orgId: string; userId: string; }, { notify: boolean; }, { email: string; name: string; }, UploadedFiles>.query: {
    notify: boolean;
}
query
.
notify: boolean
notify
}),
})

Without a params schema, path parameters are still typed, as strings, from the path. Without a query schema, req.query holds the raw strings (arrays for repeated keys); without a body schema, req.body is unknown: check it before use.

When validation fails

The request never reaches the handler. The client gets a 400 with every issue, each with its source:

{
  "status": 400,
  "error": "VALIDATION_FAILED",
  "message": "Validation failed",
  "data": {
    "issues": [
      {
        "source": "query",
        "path": ["notify"],
        "code": "invalid_value",
        "message": "Invalid option: expected one of \"true\"|\"1\"|\"yes\"|\"on\"|\"y\"|\"enabled\"|\"false\"|\"0\"|\"no\"|\"off\"|\"n\"|\"disabled\""
      },
      {
        "source": "body",
        "path": ["age"],
        "code": "too_small",
        "message": "Too small: expected number to be >=13"
      }
    ]
  }
}

The code and message of each issue come from Zod: a client can display them, or map the codes to its own messages.

Validation runs after the middlewares: a request rejected by the authentication middleware gets its 401, whatever its body.

Query strings and forms

A query string only carries strings, and a key sent several times gives an array. Coerce the values with Zod:

QuerySchemaValue
?page=2z.coerce.number()2
?done=truez.stringbool()true (also 1, yes, on; false, 0, no, off for false)
?tag=a&tag=barrayOf(z.string())['a', 'b']
?tag=aarrayOf(z.string())['a']

arrayOf(), from @dotpress/core, always gives an array, whether the key is sent once or several times:

defineRoute<"/articles", {
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}, false, unknown[]>(route: RouteConfig<"/articles", {
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/articles", { query: ZodObject<{ tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.path: "/articles"
path
: '/articles',
RouteConfig<"/articles", { query: ZodObject<{ tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<{
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}> | undefined
schema
: (
z: typeof import("zod")
z
) => ({
query: ZodObject<{
    tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
}, $strip>
query
:
z: typeof import("zod")
z
.
function object<{
    tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
}>(shape?: {
    tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
} | undefined, params?: string | {
    error?: string | $ZodErrorMap<NonNullable<$ZodIssueInvalidType<unknown> | $ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): ZodObject<...>
object
({
tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>
tag
:
arrayOf<ZodString>(schema: ZodString): ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>

Validates a field that may be sent once or several times (query string, form fields, files): a single value is wrapped in an array before validation, so the handler always receives an array.

Pass an element schema, or an array schema to add array constraints. Missing values stay undefined: chain .optional() or .default([]).

@exampleschema : (z) => ({ query: z.object({ tag: arrayOf(z.string()).default([]) }), files: z.object({ photos: arrayOf(z.array(z.file().mime(['image/png'])).max(5)) }), })
arrayOf
(
z: typeof import("zod")
z
.
function string(params?: string | $ZodStringParams): ZodString (+1 overload)
string
()).
ZodType<any, any, $ZodPipeInternals<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>.default(def: string[]): ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>> (+1 overload)
default
([]) }),
}),
RouteConfig<"/articles", { query: ZodObject<{ tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>; }, $strip>; }, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<{
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}, {}, false, unknown>
handler
: ({
req: RouteRequest<{
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}, {}, false>
req
}) => ({
tags: string[]
tags
:
req: RouteRequest<{
    query: ZodObject<{
        tag: ZodDefault<ZodPipe<ZodTransform<unknown, unknown>, ZodArray<ZodString>>>;
    }, $strip>;
}, {}, false>
req
.
DotpressRequest<{}, { tag: string[]; }, unknown, UploadedFiles>.query: {
    tag: string[];
}
query
.
tag: string[]
tag
}),
})

Nested keys such as filter[status]=open are not expanded into objects: the key is filter[status]. URL-encoded form bodies follow the same rules.

Validating the response

A response schema checks the value returned by the handler:

const 
const User: z.ZodObject<{
    id: z.ZodString;
    email: z.ZodEmail;
}, z.core.$strip>
User
=
import z
z
.
function object<{
    id: z.ZodString;
    email: z.ZodEmail;
}>(shape?: {
    id: z.ZodString;
    email: z.ZodEmail;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    id: z.ZodString;
    email: z.ZodEmail;
}, z.core.$strip>
object
({
id: z.ZodString
id
:
import z
z
.
function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)
string
(),
email: z.ZodEmail
email
:
import z
z
.
function email(params?: string | z.core.$ZodEmailParams): z.ZodEmail
email
() })
defineRoute<"/users/:id", {
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}, false, unknown[]>(route: RouteConfig<"/users/:id", {
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}, EmptyObject, false, unknown[], unknown>): void

Declares a route on the global router.

defineRoute
({
RouteConfig<"/users/:id", { response: ZodObject<{ id: ZodString; email: ZodEmail; }, $strip>; }, EmptyObject, false, unknown[], unknown>.path: "/users/:id"
path
: '/users/:id',
RouteConfig<"/users/:id", { response: ZodObject<{ id: ZodString; email: ZodEmail; }, $strip>; }, EmptyObject, false, unknown[], unknown>.schema?: SchemaDefinition<{
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}> | undefined
schema
: () => ({
response: z.ZodObject<{
    id: z.ZodString;
    email: z.ZodEmail;
}, z.core.$strip>
response
:
const User: z.ZodObject<{
    id: z.ZodString;
    email: z.ZodEmail;
}, z.core.$strip>
User
}),
RouteConfig<"/users/:id", { response: ZodObject<{ id: ZodString; email: ZodEmail; }, $strip>; }, EmptyObject, false, unknown[], unknown>.handler: RouteHandler<{
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}, {
    id: string;
}, false, unknown>
handler
: async ({
req: RouteRequest<{
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}, {
    id: string;
}, false>
req
}) =>
const users: {
    find(id: string): Promise<{
        id: string;
        email: string;
        passwordHash: string;
    }>;
}
users
.
function find(id: string): Promise<{
    id: string;
    email: string;
    passwordHash: string;
}>
find
(
req: RouteRequest<{
    response: z.ZodObject<{
        id: z.ZodString;
        email: z.ZodEmail;
    }, z.core.$strip>;
}, {
    id: string;
}, false>
req
.
DotpressRequest<{ id: string; }, QueryValues, unknown, UploadedFiles>.params: {
    id: string;
}
params
.
id: string
id
),
})
  • At compile time, the handler must return a value matching the schema, or an error.
  • At runtime, the value is parsed by the schema: fields that are not in it, such as passwordHash here, are removed. The response cannot leak them, even when the database returns more than expected.
  • A value that does not match is a bug of the API: the client gets a 500, and the issues are logged.

The response schema also documents the route in the OpenAPI document. Give it an id with .meta({ id: 'User' }) to declare it once in the document and reference it everywhere.

A route that returns nothing answers 204 No Content. A response: z.never() schema makes it explicit, and rejects any returned value.

Tips

  • Transform while you validate: .trim(), .toLowerCase(), .transform() and defaults apply before the handler runs. The handler gets the clean value.
  • Cross-field rules go in .refine() or .superRefine() on the object schema.
  • Reuse schemas: declare them once in a module, and use them in several routes and in the tests.
  • Prototype without schemas, then add them: the types of the handler follow.
Coming from v1

The params: ['id'] shorthand is gone: path parameters are typed from the path. Validation errors now use the standard error format, with one flat list of issues. Dotpress 2 uses Zod 4.