validate() parses request input against a schema and returns typed data. If
the input is invalid it throws a ValidationException, which the HTTP kernel
renders as a 422 with per-field errors — no manual checking.
Bring a schema library
Keel's validate() is schema-agnostic: it works with any schema that has a
Zod-style safeParse. Zod is the recommended choice — the
framework never bundles it, so install it in your app:
npm install zod
Nothing about validate() is Zod-specific: it only ever calls schema.safeParse(input)
and reads back .success, .data, and .error.issues. Anything that mirrors
that shape (see the Schema type) works — including a hand-rolled
validator or a mock in a test.
Validating a request body
Call validate(schema) with no data and it parses the JSON body. The result is
fully typed from the schema:
import { json, validate } from "@shaferllc/keel/core";
import { z } from "zod";
const NewUser = z.object({
email: z.string().email(),
age: z.number().min(18),
});
export class UserController {
async store() {
const data = await validate(NewUser); // { email: string; age: number }
return json({ created: data.email }, 201);
}
}
Invalid input never reaches your logic — it becomes a 422:
// POST /users { "email": "nope", "age": 15 }
{
"error": "The given data was invalid.",
"status": 422,
"errors": {
"email": ["Invalid email address"],
"age": ["Too small: expected number to be >=18"]
}
}
Validating other input
Pass data explicitly to validate anything — query strings, params, config.
validate() is async in both forms, so await it even when you hand it
data directly:
import { validate, request } from "@shaferllc/keel/core";
const Search = z.object({ q: z.string().min(1), page: z.coerce.number().default(1) });
async function search() {
const { q, page } = await validate(Search, request.query());
// …
}
The rule is simple: if the second argument is anything other than undefined,
validate() parses that value; if it's omitted, it awaits the JSON body. Because
the check is data !== undefined, passing null counts as "explicit data" — the
schema sees null, not the body.
Declarative validation (before the handler)
validate() above is imperative — you call it inside the handler. For the
common case, validateRequest() is a middleware that validates the request
before the handler runs, rejecting a bad request with a 422 so your handler
only ever sees valid input:
import { validateRequest, validated } from "@shaferllc/keel/core";
const NewUser = z.object({ email: z.string().email(), name: z.string().min(1) });
router
.post("/users", [Users, "store"])
.middleware([validateRequest({ body: NewUser })]);
// in Users@store — guaranteed valid, fully typed:
const user = validated<z.infer<typeof NewUser>>("body");
Validate body, query, and params together — errors from every part are
aggregated into one 422, keyed body.field / query.field / params.field:
router.get("/posts/:id", [Posts, "show"]).middleware([
validateRequest({
params: z.object({ id: z.coerce.number() }),
query: z.object({ page: z.coerce.number().min(1).default(1) }),
}),
]);
// validated<{ id: number }>("params"); validated<{ page: number }>("query");
validated(part) returns the parsed, typed value for that part (defaults to
"body"). Coercion (z.coerce.number()) is the schema's job — it applies before
your handler sees the value. Built on the same validate() engine.
Body parsing is JSON-only
The no-argument form reads the body with body(), which calls request.json().
That means validate(schema) expects a JSON request body; a form-encoded or empty
body will reject at JSON parse time before the schema even runs. If you need to
validate merged query + form input, pass it in explicitly:
import { validate, request } from "@shaferllc/keel/core";
// request.all() merges the query string with the parsed body (JSON or form)
const data = await validate(NewUser, await request.all());
The error shape
On failure validate() walks error.issues and folds them into a
Record<string, string[]> — one array of messages per field:
- Each issue's
pathis joined with.into a key: a nested path["address", "zip"]becomes"address.zip". - Symbol path segments use their
.description; everything else is stringified. - A root-level issue (empty path) is keyed
"_". - Multiple issues on the same path accumulate in that field's array.
// nested + root-level errors
{
"errors": {
"address.zip": ["Invalid postal code"],
"_": ["Passwords do not match"]
}
}
That map is exactly what ValidationException.errors carries.
Handling errors yourself
ValidationException carries the field errors, so a custom error handler (see
Errors) can format them however you like:
import { ValidationException } from "@shaferllc/keel/core";
if (err instanceof ValidationException) {
return response.json({ fields: err.errors }, 422);
}
API reference
validate(schema, data?)
validate<T>(schema: Schema<T>, data?: unknown): Promise<T>
Parses data (or the JSON request body, when data is omitted) against schema
and resolves to the typed value, throwing ValidationException on failure.
import { validate } from "@shaferllc/keel/core";
import { z } from "zod";
const NewUser = z.object({ email: z.string().email(), age: z.number().min(18) });
const fromBody = await validate(NewUser); // parses request.json()
const fromData = await validate(NewUser, { email, age }); // parses the given value
Notes: always returns a Promise, in both forms — await it even when you
pass data. The body form calls body() (request.json()), so it expects a JSON
body. The "use my data" branch triggers on data !== undefined, so null is
treated as explicit input (the schema sees null). On failure it throws
ValidationException whose errors is a Record<string, string[]> keyed by
dotted field path (root-level issues key "_"); it never returns a partial
result. T is inferred from the schema, so the resolved value is fully typed.
validateRequest(schemas)
validateRequest(schemas: RequestSchemas): MiddlewareHandler
Middleware that validates body / query / params before the handler,
throwing a 422 ValidationException (errors from all parts aggregated, keyed
part.field). On success the parsed values are stashed for validated().
router.post("/users", [Users, "store"]).middleware([validateRequest({ body: NewUser })]);
Notes: validates every declared part and aggregates their errors, rather than
failing on the first. body reads the JSON body; query/params read the URL.
Coercion is the schema's responsibility.
validated(part?)
validated<T>(part?: "body" | "query" | "params"): T
The parsed, typed value for a request part (default "body"), set by
validateRequest.
const user = validated<z.infer<typeof NewUser>>("body");
Notes: throws if that part wasn't validated (no validateRequest for it), or
if called outside a request.
Interfaces & types
Schema<T>
interface Schema<T> {
safeParse(data: unknown):
| { success: true; data: T }
| {
success: false;
error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> };
};
}
The minimal contract validate() needs from a schema — a single safeParse that
returns a discriminated success union. Zod's z.object({...}) satisfies it out
of the box, which is why you normally never write this type by hand. Implement it
yourself only to plug in a different validator or a test stub:
import { validate, type Schema } from "@shaferllc/keel/core";
const Positive: Schema<number> = {
safeParse: (data) =>
typeof data === "number" && data > 0
? { success: true, data }
: { success: false, error: { issues: [{ path: [], message: "must be > 0" }] } },
};
const n = await validate(Positive, 42); // number
Notes: validate() only reads success, data, and error.issues (each
issue's path and message). It ignores every other field a real Zod result
carries, so any object matching this shape is enough. An issue with an empty
path (as above) lands under the "_" key in the thrown errors.
ValidationException
new ValidationException(errors: Record<string, string[]>, message?: string)
The 422 exception validate() throws on failure; re-exported from the framework's
HTTP exceptions. You rarely construct it — you catch it.
import { ValidationException } from "@shaferllc/keel/core";
try {
await validate(NewUser);
} catch (err) {
if (err instanceof ValidationException) {
err.status; // 422
err.errors; // Record<string, string[]>, per-field messages
}
}
Notes: extends HttpException with status fixed at 422 and a default
message of "The given data was invalid.". The per-field errors map is the
one validate() built from the schema's issues. See Errors for
how the kernel renders it and how to override that.