Advanced Patterns
A prefix validator runs for every method and every nested path under that prefix. That is fine when one schema fits the whole prefix, but a real resource often mixes shapes under a single path. This page covers the pattern that picks the right schema per request.
Validators Run Before Routing
Middleware runs before the router matches a method or a path, so a prefix validator fires on every request the prefix touches, even ones no handler serves. A validator on /accounts runs for POST /accounts and for GET /accounts/anything, both before the router decides there is no such route.
When that validator fails, its 422 reaches the client first and hides the status the router would have produced:
POST /accountswith a missing header returns 422, not the 405 the missing POST handler would give.GET /accounts/missingwith a missing header returns 422, not the 404 for an unknown path.
Gating the validator by method and path keeps validation on the requests it belongs to and lets the router answer the rest. With the right gate, POST /transfers/tx_abc123 returns a clean 405 instead of a body-validation 422, because the validator skips a request it was never meant to check.
One Prefix, Several Shapes
router.use('/transfers', ...) matches /transfers and every path that continues with a slash, such as /transfers/tx_abc123. The matching rule comes from Route-Specific Middleware. A transfers resource usually carries two different requests under that one prefix:
POST /transferssends a JSON body that needs ajsoncontract.GET /transfers/:idcarries no body and validates its param inside the handler.
Registering a json validator on the whole prefix would run it on the GET too, and reading a body that is not there turns a valid request into a failure. The validator needs to fire only for the POST.
The selectValidator Helper
A small wrapper solves it. It takes a picker that returns a schema for the current request or undefined to skip, builds the validator on demand, and caches it so each schema is wrapped once:

import { type Context, type MiddlewareFn, Mware, type ValidationSchema } from '@neabyte/deserve'
// Pick a schema or skip validation
function selectValidator(pick: (ctx: Context) => ValidationSchema | undefined): MiddlewareFn {
const cache = new Map<ValidationSchema, MiddlewareFn>()
return async (ctx: Context, next) => {
const schema = pick(ctx)
if (schema === undefined) {
return await next()
}
let validator = cache.get(schema)
if (validator === undefined) {
// Build once, reuse on later requests
validator = Mware.validator(schema)
cache.set(schema, validator)
}
return await validator(ctx, next)
}
}Returning undefined calls next straight away, so the request flows through untouched. Returning a schema runs the matching Validator Middleware before the handler.
Wiring It To A Prefix
The picker reads ctx.pathname and the request method to decide. Here the json contract runs only for the collection POST, and the GET passes through to validate its param in the handler:
// Validate body only on collection POST
router.use(
'/transfers',
selectValidator((ctx) =>
ctx.pathname === '/transfers' && ctx.request.method === 'POST'
? createTransfer
: undefined
)
)The GET /transfers/:id handler then validates its own param with Validator.check, the approach from Reading Validated Data. Body validation and param validation stay separate, each firing only where it belongs.
Picking Between Several Schemas
The same picker handles more than one branch when a prefix hosts many methods. Each branch returns the schema for that case, and anything unmatched returns undefined:
// One picker, one schema per method
router.use(
'/users',
selectValidator((ctx) => {
const isCollection = ctx.pathname === '/users'
if (isCollection && ctx.request.method === 'GET') {
return listQuery
}
if (isCollection && ctx.request.method === 'POST') {
return createBody
}
return undefined
})
)This keeps one validator registration per prefix while each method gets the exact schema it needs.
Order Of Validation
Knowing what fails first makes a 422 predictable. Two rules cover every case, one for sources and one for guards.
A schema with several sources validates them in the order the keys appear, and the first source that fails stops the rest. A schema of { query, headers, cookies } with a bad query and a missing header reports only the query reason, since query comes first and the header contract never runs:

import { Define } from '@neabyte/deserve'
// Sources validate in key order
const listAccounts = {
query: Define((q: Record<string, string>) => q),
headers: Define((h: Record<string, string>) => h),
cookies: Define((c: Record<string, string>) => c)
}Within one source, the contract decides how much it reports. A single guard that pushes into a reasons array surfaces every broken field at once, while a list of guards stops at the first failure. That split comes straight from Define Schema, so a shape guard can report all missing fields while a later invariant guard only runs once the shape holds.
The result reads cleanly. Across sources the first failure wins, inside a source the contract chooses one reason or many, and across guards the first failing guard wins.
Structuring Schemas
Contracts do not have to live next to the routes that use them. As a project grows, a folder of its own keeps each contract small and lets several routes share the same rule. A layout that scales tends to look like this:
schemas/
_shared.ts # small guard helpers reused across contracts
transfer.ts # one resource, its contracts
account.ts
index.ts # barrel that groups contracts into schemas
routes/
transfers.ts
accounts.tsThe barrel groups single contracts into the per-source schemas a route reads, so the wiring stays in one place:
// schemas/index.ts groups contracts per source
export const createTransferSchema = {
json: Transfer
}
export const listAccountsSchema = {
query: AccountQuery,
headers: ApiKeyHeader
}A route imports only the schema type it needs, which keeps the handler focused on the response rather than the rules:
// routes/transfers.ts reads the validated body
export function POST(ctx: Context): Response {
const { json } = Validator.read<typeof createTransferSchema>(ctx)
return ctx.send.json({ amount: json.amount }, { status: 201 })
}This is a suggestion, not a rule. A tiny app keeps contracts inline beside the route, and a larger one splits them out once a contract earns reuse.
Where To Go Next
- Validator Middleware - the per-source registration this pattern wraps.
- Reading Validated Data - validate params in the handler beside this pattern.
- Route-Specific Middleware - the prefix matching rule behind it.
- Validation Overview - how the pieces fit together.