Validation
access-router validates requests before the service layer runs.
Built-in Validation
The routers validate:
- path params such as
id,subId, andfield - query params such as
page_size,include_count,include_permissions, andtry_list - body shapes for list, read, create, update, upsert, distinct, count, and sub-document routes
Naming conventions:
- query-string routes use snake_case
- advanced body payloads use camelCase
- advanced body payloads use
filterandselect
Include Validation (Legacy And Correlated)
include entries are validated by the shared includeItemSchema, reused
identically by direct model routes and grouped root entries, so both paths
return the same verdicts. Two variants are accepted:
- Legacy (
modeabsent or'legacy'): requireslocalField/foreignFieldfor batched joins. - Correlated (
mode: 'correlated'): requiresmodel,op('read'/'list'/'count'), and an explicit outputpath; exactly one ofid(identifier reads) orfilter(filter reads, lists, counts) must be present.localField/foreignFieldmust be absent. Args are allowlisted per operation (reads{ select, sort, include }; lists add{ skip, limit, page, pageSize }; counts take no args) and wireoptionsmust be absent or{}. A legacy-shaped entry carrying$parentmarkers isBadRequest— never silently reinterpreted.
Marker rules enforced at validation: { "$parent": "<field>" } (exactly
one own key, non-empty string value, dotted paths allowed except
__proto__/prototype/constructor segments) only in supported filter
value positions; { "$escape": { "$parent": "<field>" } } matches the
literal object. Bare { note: { $escape: { $parent: 'x' } } } resolves to
{ note: { $eq: { $parent: 'x' } } }, consistent with bare substituted parent
objects. Explicit $eq remains equivalent; operator operands and array elements
receive the literal without another equality wrapper. Escape resolution is
single-pass and never reads parent data, even if x is absent, null or changed.
Malformed escapes are rejected and expanded operands still consume complexity
budgets. The corrected bare form requires an updated server; older servers with
the bare-escape defect still need explicit $eq.
Markers in $$sq/$$date subtrees, as object keys, in root
$text/$where/$comment, in populate.match, or in sort/select are
BadRequest. Output path must be a non-empty valid field path (not
_id, no $ prefix); duplicate correlated paths in one include array are
rejected. Direct count bodies reject include entirely.
After shape validation, shared service preflight also rejects any legacy/correlated
output path overlapping the receiving model's documentPermissionField (equal,
ancestor or descendant, including equivalent legacy bracket paths). Nested receiving
models use their own settings, before target persistence. Direct HTTP returns 400;
the affected root entry returns statusCode 400/code bad_request in its HTTP 200
envelope. Legacy grouped-count schema cast failures are also controlled BadRequest.
Request Schemas
Model routers support requestSchemas for stricter application-level validation.
requestSchemas are validation-library agnostic for user-defined validation:
- raw Zod schemas still work
- raw
standard-schemaobjects work - custom validator functions work
- adapter objects with
validate(value)work
If you want custom OpenAPI output for a user-defined validator, wrap it with defineRequestSchema(...).
RequestSchemas Inputs
requestSchemas can be provided as:
- a raw schema object the router already understands, such as Zod or
standard-schema - a custom function returning
{ success, data }or{ success, issues } - an adapter object with
validate(value)returning the same result shape
Custom validators must return:
{ success: true, data }
or:
{
success: false,
issues: [{ message: 'Required', path: ['data', 'name'] }],
}
The router converts those issues into the standard bad-request error format.
Helper Adapters
Helpers exported by @web-ts-toolkit/access-router:
- generic adapters:
fromZod(schema),fromStandardSchema(schema) - schema/helper adapters:
fromYup(schema),fromJoi(schema),fromAjv(validate) - schema/helper adapters:
fromValibot(schema, safeParse),fromArkType(type) - schema/helper adapters:
fromIoTs(codec),fromSuperstruct(struct, validate),fromVine(validator)
AJV: synchronous verdicts and tagged asynchronous data
Install ajv in the application; access-router has no mandatory AJV runtime dependency.
fromAjv and AjvValidatorLike<T> are root exports.
import { Ajv, type AsyncSchema } from 'ajv';
import { fromAjv } from '@web-ts-toolkit/access-router';
const ajv = new Ajv();
const sync = fromAjv<boolean>(ajv.compile<boolean>({ type: 'boolean' }));
const asyncSchema: AsyncSchema = { $async: true, type: 'boolean' };
const asyncData = fromAjv(ajv.compile<boolean>(asyncSchema));
await sync(false); // { success: true, data: false }
await asyncData(false); // { success: true, data: false }
Sync boolean results are verdicts: true returns input (with any in-place AJV
mutations), false returns issues snapshotted from mutable validate.errors before
another call. Tagged $async: true promises/thenables return validated data:
false, true, null, scalars and objects all succeed with the fulfilled value.
Async paths never consult shared errors. Real AJV ValidationError rejections
(ajv: true, validation: true, errors array) retain input-local diagnostics under
concurrency; other throws/rejections propagate unchanged as operational failures.
Migration: untagged promises/thenables now reject with a configuration TypeError
and are observed to prevent unhandled rejection; synchronous non-boolean returns
also reject. Pass genuine compiled validators directly without casts or tag-dropping
wrappers. Structural async validators require a literal $async: true as const
and data/rejection semantics. Do not merely tag an async boolean-verdict wrapper:
its false/true would now be successful data. Convert it to a RequestSchemaValidator
with explicit result objects, or return validated data and reject AJV validation
errors. An errors property alone no longer identifies validation failure.
AJV's async type extends its sync type with overloads, so a sync-typed reference can
hide the tag; runtime discrimination remains authoritative. Use fromAjv<T>(sync)
for explicit structural sync output types; genuine AJV sync/async types infer output
via their type guard, structural async types via their promise. An AsyncSchema-typed
schema selects AJV's async compile/compileAsync overload without casts; inline
schemas can match its earlier sync overload even with $async: true.
defineRequestSchema(...)
Use defineRequestSchema(validator, { openapi }) when you want a custom validator and still want the generated OpenAPI document to describe that request body.
import { defineRequestSchema } from '@web-ts-toolkit/access-router';
requestSchemas: {
advancedCreate: defineRequestSchema(
async (value) => {
const body = value as { data?: { role?: string } };
if (body?.data?.role !== 'user' && body?.data?.role !== 'admin') {
return {
success: false,
issues: [{ message: 'role must be user or admin', path: ['data', 'role'] }],
};
}
return { success: true, data: body };
},
{
openapi: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
role: { type: 'string', enum: ['user', 'admin'] },
},
},
},
},
},
),
}
Without openapi metadata, custom validators still work for runtime validation, but the generated OpenAPI schema falls back to a generic object shape.
Examples
Example with standard-schema:
requestSchemas: {
advancedRead: {
'~standard': {
version: 1,
vendor: 'my-validator',
validate(value) {
const body = value as { select?: unknown };
if (body.select !== undefined && !Array.isArray(body.select)) {
return {
issues: [{ message: 'Expected array', path: ['select'] }],
};
}
return { value: body };
},
},
},
}
Example with Valibot:
import * as v from 'valibot';
import { fromValibot } from '@web-ts-toolkit/access-router';
requestSchemas: {
advancedRead: fromValibot(
v.object({
select: v.optional(v.array(v.string())),
}),
v.safeParse,
),
}
Example with ArkType:
import { type } from 'arktype';
import { fromArkType } from '@web-ts-toolkit/access-router';
requestSchemas: {
advancedList: fromArkType(
type({
'filter?': {
'public?': 'boolean',
},
}),
),
}
Example with io-ts:
import * as t from 'io-ts';
import { fromIoTs } from '@web-ts-toolkit/access-router';
requestSchemas: {
advancedRead: fromIoTs(
t.type({
select: t.union([t.undefined, t.array(t.string)]),
}),
),
}
Example with Superstruct:
import { object, optional, array, string, validate } from 'superstruct';
import { fromSuperstruct } from '@web-ts-toolkit/access-router';
const schema = object({
select: optional(array(string())),
});
requestSchemas: {
advancedRead: fromSuperstruct(schema, validate),
}
Example with Vine:
import vine from '@vinejs/vine';
import { fromVine } from '@web-ts-toolkit/access-router';
const validator = vine.create({
select: vine.array(vine.string()).optional(),
});
requestSchemas: {
advancedRead: fromVine(validator),
}
Example with a custom validator:
requestSchemas: {
advancedCreate: {
data: async (value) => {
const data = value as { role?: unknown };
if (data.role !== 'user') {
return {
success: false,
issues: [{ message: 'Invalid role', path: ['role'] }],
};
}
return { success: true, data };
},
},
}
Useful keys include:
createupdateupsert
Related Pages
- Overview
- Configuration
- Hooks
- OpenAPI
countdistinctadvancedListadvancedReadFilteradvancedReadadvancedCreateadvancedCreateDataadvancedUpdateadvancedUpdateDataadvancedUpsertadvancedUpsertDatasubListsubReadsubCreatesubUpdatesubBulkUpdate
Data routers support:
advancedListadvancedReadFilteradvancedRead
Root Batch Validation
RootRouter validates each batch entry by operation before dispatch.
That means required fields such as id, data, field, or subId fail early with a bad request response.
Custom Routes
Use the advanced subpath when you want the same validation helpers in your own routes.
import {
parseBody,
parsePathParam,
parseQuery,
requestSchemas,
readByIdBodySchema,
} from '@web-ts-toolkit/access-router/advanced';
router.router.post('/custom/:id', async (req) => {
const id = parsePathParam(req.params.id, 'id');
const query = parseQuery(requestSchemas.readQuery, req.query);
const body = parseBody(readByIdBodySchema, req.body);
return { id, query, body };
});