#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
400with 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 | undefinedDefaults 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): ZodNumberConsider 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
| Schema | Validates | Typed as |
|---|---|---|
params | Path parameters (strings) | req.params |
query | Query string | req.query |
body | Request body: JSON, form fields, text | req.body |
files | Uploaded files, multipart/form-data | req.files |
response | The value returned by the handler | the 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 | undefinedDefaults 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:
| Query | Schema | Value |
|---|---|---|
?page=2 | z.coerce.number() | 2 |
?done=true | z.stringbool() | true (also 1, yes, on; false, 0, no, off for false) |
?tag=a&tag=b | arrayOf(z.string()) | ['a', 'b'] |
?tag=a | arrayOf(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
passwordHashhere, 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.
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.