Skip to main content

Validation

access-router validates requests before the service layer runs.

Built-in Validation​

The routers validate:

  • path params such as id, subId, and field
  • query params such as page_size, include_count, include_permissions, and try_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 filter and select

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 (mode absent or 'legacy'): requires localField/foreignField for batched joins.
  • Correlated (mode: 'correlated'): requires model, op ('read'/'list'/'count'), and an explicit output path; exactly one of id (identifier reads) or filter (filter reads, lists, counts) must be present. localField/foreignField must be absent. Args are allowlisted per operation (reads { select, sort, include }; lists add { skip, limit, page, pageSize }; counts take no args) and wire options must be absent or {}. A legacy-shaped entry carrying $parent markers is BadRequest — 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-schema objects 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:

  • create
  • update
  • upsert
  • Overview
  • Configuration
  • Hooks
  • OpenAPI
  • count
  • distinct
  • advancedList
  • advancedReadFilter
  • advancedRead
  • advancedCreate
  • advancedCreateData
  • advancedUpdate
  • advancedUpdateData
  • advancedUpsert
  • advancedUpsertData
  • subList
  • subRead
  • subCreate
  • subUpdate
  • subBulkUpdate

Data routers support:

  • advancedList
  • advancedReadFilter
  • advancedRead

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 };
});