Schemas: validation, typing and documentation
Routes accept a schemas option describing the request and response data. Mousse uses it for three things:
- Runtime validation — invalid requests are rejected before your handler runs
- Static typing — handler types are inferred from the schemas, no generics to write
- Documentation — the same schemas feed the documentation generators
Standard Schema
Mousse validates through the Standard Schema interface, implemented natively by Zod (>= 3.24), ArkType, Valibot and a growing list of libraries. Any schema from these libraries works directly — no adapter, no plugin, and Mousse itself depends on none of them:
import { z } from 'zod';
import { type } from 'arktype';
// Both are valid Mousse schemas as-is
const zodUser = z.object({ name: z.string(), age: z.number() });
const arkUser = type({ name: 'string', age: 'number' });Attaching schemas to a route
import { z } from 'zod';
app.post('/orgs/:org/users', {
schemas: {
Body: z.object({ name: z.string().trim(), email: z.string().email() }),
Query: z.object({ dryrun: z.coerce.boolean().optional() }),
Params: z.object({ org: z.string() }),
Response: z.object({ id: z.number() })
}
}, async (c) => {
const body = await c.body(); // { name: string; email: string } — validated AND typed
c.query.dryrun; // boolean | undefined — coerced by the schema
c.param('org'); // parameter names checked at compile time
return { id: 1 }; // must match the Response schema
});Handler and middleware types are inferred from the schemas: no explicit generics needed. Transformations declared in schemas (trim, coercion, defaults...) are applied — your handler receives the schema output.
What is validated, and when
| Part | Validated | On failure |
|---|---|---|
Params |
Before any middleware runs | 400 |
Query |
Before any middleware runs | 400 |
Body |
After the middlewares, before the handler (or earlier, at the first c.body() call of a middleware) |
400 |
Response |
Inside c.json(), before serialization, against the schema of the final status |
500 |
- A route without schemas validates nothing: zero overhead.
- The handler always receives a validated body, even if it never reads it. Middlewares run first, so an authentication middleware rejects a request before its body is parsed and validated.
c.body(true)stays the raw, unvalidatedBuffer(webhook signatures...). - A body the parser cannot read (malformed json) is a
400 { "error": "Malformed body" }, whether the route has a schema or not. Responseschemas must validate synchronously (serialization cannot await). Async refinements are fine everywhere else.
Responses per status
A single Response schema describes 2xx responses. To describe others, key schemas by status code or range — exact codes win over ranges:
app.get('/offers/:idOffer', {
schemas: {
Params: z.object({ idOffer: z.string() }),
Response: {
200: Offer,
'4xx': z.object({ error: z.string() })
}
}
}, async (c) => {
const offer = await findOffer(c.param('idOffer'));
if (!offer)
return c.status(404).json({ error: 'Offer not found' }); // typed and validated against '4xx'
return offer; // typed and validated against 200
});A status with no matching schema is sent unvalidated. Handler return values are typed from the 2xx schemas; c.json() also accepts the other shapes. The documentation generators list every status.
Undeclared fields
Response validation sends the schema output, so whether undeclared fields reach the client depends on the library: Zod z.object and Valibot v.object strip them, ArkType keeps them unless told otherwise (type({ '+': 'delete', id: 'string' })). Use stripping objects for responses built from database rows to avoid leaking fields such as password hashes.
Validation failures
Without a custom error handler, an invalid request gets a 400 (or the validationErrorStatus route / router option, e.g. 422) with the issues reported by the schema library:
{
"error": "Invalid body",
"issues": [
{ "message": "name must be a string", "path": ["name"] }
]
}An invalid response is a server bug: the client gets an empty 500 and the error is logged.
To customize this behavior, set an httpErrorHandler (per route or as a router default) and check for SchemaValidationError:
import { SchemaValidationError } from '@moussejs/core';
const errorHandler = {
handle: (error, c) => {
if (error instanceof SchemaValidationError)
return c.status(422).json({ part: error.part, issues: error.issues });
c.status(500).respond();
}
};error.part is one of 'body' | 'query' | 'params' | 'response'.
Schemas and serializers
The route's BodyParser and ResponseSerializer receive the relevant schema as an argument:
parse(raw, contentType, schema) // BodyParser — schema = schemas.Body
serialize(res, schema) // ResponseSerializer — schema = the Response schema of the status
compile(schema) // ResponseSerializer, optional — once per Response schema at startupThe defaults ignore it, and validation is handled by Mousse either way — a custom parser does not need to validate.
A serializer implementing compile gets each Response schema once, when the route is registered, and returns the function used for every response matching it: no per-request lookup, and an unsupported schema fails at startup instead of on the first request. Typical use is fast-json-stringify, which also drops undeclared fields, fed by a DocSchemaTranslator:
import fastJson from 'fast-json-stringify';
import { z } from 'zod';
app.setDefaultResponseSerializer({
serialize: (res) => JSON.stringify(res ?? {}), // responses without schema
compile: (schema) => fastJson(z.toJSONSchema(schema))
});Typing without schemas
Schemas are optional. Routes without them fall back to the explicit generic form described in Typescript:
app.post<MyContextTypes>('/legacy', handler);Note: when schemas is provided, inference always wins over explicit generics on the helper methods.
Documentation generation
The second purpose of route schemas is documentation — see Documentation generation for the full pipeline: one DocSchemaTranslator per schema library, OpenAPI 3.1 output, and Mousse's own HTML renderer.