@setu-ts/validation-plugin

Zod-compatible request validation. Registers a validation service under CAPABILITIES.VALIDATION ('validation') and ships middleware helpers for body, query, params, headers, and cookies.

Schemas are duck-typed through the public safeParse method. The supported Zod range is >=4.6.6 <5, and validation is tested with Zod 4 only. The plugin does not import Zod; applications install their own supported version. pnpm run check:compat exercises validation and OpenAPI generation with Zod 4.

Installation

import { validatedStateKey } from '@setu-ts/common';
import { validateBody, ValidationPlugin } from '@setu-ts/validation-plugin';

Usage

import { z } from 'zod';
import { validatedStateKey } from '@setu-ts/common';
import { validateBody, ValidationPlugin } from '@setu-ts/validation-plugin';

const CreateUser = z.object({ email: z.string().email(), age: z.number().int() });

app.register(ValidationPlugin({ errorFormat: 'rfc9457' }));

app.router.post('/users', {
  middleware: [validateBody(CreateUser)],
  handler: async (ctx) => {
    const body = ctx.state.get(validatedStateKey('body'));
    return ctx.response.json(body);
  },
});

Reading the validated value

The five validateXxx helpers and createValidationMiddleware return ValidatorMiddleware<ValidatedOutput<S>> — a MiddlewareFunction (so every existing call site type-checks unchanged) with one added member, value(ctx):

import { z } from 'zod';
import { validateBody, ValidationPlugin } from '@setu-ts/validation-plugin';

const CreateUser = z.object({ email: z.string().email(), age: z.number().int() });
const createBody = validateBody(CreateUser);

app.register(ValidationPlugin());

app.router.post('/users', {
  middleware: [createBody],
  handler: (ctx) => ctx.response.json(createBody.value(ctx)), // typed ValidatedOutput<typeof CreateUser>
});

ValidatedOutput<S> is the schema’s parsed output type — coercions and defaults included — and it is inferred from the schema you pass, so the type follows the schema instead of being restated in the handler. value(ctx) returns the stored value only when that validator ran on the request; it throws a TypeError naming the target otherwise — when the validator is not in the route’s chain, or a later validator for the same target overwrote it — a programming error that errorHandler masks to a 500. The old read still works and is unchanged: ctx.state.get(validatedStateKey('body')) keeps carrying the parsed value, and the @ValidateBody/@ValidateQuery/@ValidateParams resolvers read through it.

Form bodies

validateBody(schema, { forms: true }) and @ValidateBody(schema, { forms: true }) also validate application/x-www-form-urlencoded and multipart/form-data bodies with the same schema they validate a JSON body with. Off by default: a cross-site HTML <form> reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddleware from @setu-ts/session-plugin). Without the option a form body answers 415 “Unsupported Media Type”. With the option, a server-rendered <form> post validates against the schema a JSON client passes, and reads back through value(ctx).

The first-party validation service accepts application/json and any application/*+json media type (case-insensitive, parameters ignored). Unsupported types, including text/plain and an absent Content-Type, answer 415 before any body read, in the application’s configured error format. Send Content-Type: application/json when posting JSON; this also applies to an empty request on a body-validated route. Routes that call ctx.request.json() themselves are unchanged, and replacement IValidationService implementations own their media-type handling. Cookie-authenticated routes still need csrfMiddleware or csrfFormMiddleware. The 415 takes the error responder’s format, while a 400 validation failure takes ValidationPlugin({ errorFormat }), so set both to the same format; the CLI templates set both to 'rfc9457'.

The form is converted to a record before validation:

  • a field sent once is a single value; a repeated field becomes an array of its values in wire order;
  • a name ending [] is always an array, kept under the name as written;
  • values are strings or the framework’s FormFile — NOT a web File, so z.instanceof(File) refuses a file part and a schema checks the FormFile shape instead;
  • numbers and booleans arrive as strings — use z.coerce in the schema for them.

Stated limits: the generated OpenAPI document still describes a validated body as application/json; the session plugin’s _csrf field reaches the schema, where a Zod object strips the unknown key by default and forbidNonWhitelisted (or the schema’s own .strict()) refuses it; and an over-limit body answers 413 when RuntimePlugin({ maxBodyBytes }) is set — previously such a body answered 400 on a validated route.

Options

OptionTypeDefaultDescription
errorFormat'default' | 'rfc9457' | 'nestjs' or a function'default'Shape of the error response.
whitelistbooleanfalseStrip properties the schema does not declare (.strip()).
forbidNonWhitelistedbooleanfalseReject unknown properties (.strict()); wins over whitelist.

Whitelisting and strictness are applied once per middleware at registration time, not per request.

Error formats

rfc9457Formatter emits RFC 9457 Problem Details (carrying detail, never message); nestjsFormatter emits the NestJS-style shape. The middleware helpers and the service both honour the plugin’s configured errorFormat — there is one implementation behind both entry points.

A validation failure carries an errors extension member, so it is a distinct problem type with a concrete type URI rather than the about:blank that @setu-ts/exceptions emits for status-only problems. Both packages spell that URI https://setu-ts.dev/errors/validation.

'rfc7807' is deprecated. RFC 7807 was obsoleted by RFC 9457 in July 2023. Here the alias is bound to the same formatter, so the emitted body is byte-identical and migrating changes nothing on the wire. Removal is scheduled for v1.0.0.

The schema also documents the route

Every middleware this package produces — the five validateXxx helpers and IValidationService.middleware(schema, target) alike — is branded with RouteValidationMetadata from @setu-ts/common, carrying its target and the schema itself.

@setu-ts/openapi-plugin reads that brand, so a route carrying validateBody(schema) is documented with a matching requestBody and needs no second declaration in schema.body. Neither package imports the other; the Symbol.for-keyed brand in common is the whole channel.

The brand is a description, not a mechanism: it is symbol-keyed and non-enumerable, the middleware’s identity and behaviour are unchanged, and removing it would change nothing this package does.

Exports

ExportKind
createSanitizerfunction
createValidationMiddlewarefunction
defaultFormatterfunction
nestjsFormatterfunction
resolveFormatterfunction
rfc9457Formatterfunction
sanitizefunction
validateBodyfunction
validateCookiesfunction
validateHeadersfunction
validateParamsfunction
validateQueryfunction
ValidationPluginfunction
ValidationServiceclass
rfc7807Formatterconst
FormattedErrorinterface
FormatValidationErrorsinterface
SanitizationRulesinterface
ValidationPluginOptionsinterface
ErrorFormattype
ValidatedOutputtype
ValidationErrorFormattertype
ValidatorMiddlewaretype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

ValidationPlugin() (@setu-ts/validation-plugin)

Provides schema-based request validation with standardized error responses. Schemas are duck-typed via a structural safeParse() interface — no hard Zod dependency in the plugin itself.

Registration

import { ValidationPlugin } from '@setu-ts/validation-plugin';

app.register(
  ValidationPlugin({
    // ValidationPlugin's own ErrorFormat union — distinct from the exceptions
    // package's, which has no 'nestjs' arm.
    errorFormat: 'rfc9457', // 'default' | 'rfc9457' | 'rfc7807' | 'nestjs' | custom function
  }),
);

ValidationPluginOptions

interface ValidationPluginOptions {
  /** Error response format. Defaults to 'default'. */
  readonly errorFormat?: ErrorFormat | ValidationErrorFormatter;

  /**
   * Strip properties the schema does not declare. Applied once per middleware
   * at registration time via the schema's own `.strip()` (Zod-style). A schema
   * without `.strip()` is used unchanged.
   */
  readonly whitelist?: boolean;

  /**
   * Reject payloads carrying properties the schema does not declare. Applied
   * once per middleware at registration time via the schema's own `.strict()`
   * (Zod-style), and takes precedence over `whitelist` when both are set. A
   * schema without `.strict()` is used unchanged.
   */
  readonly forbidNonWhitelisted?: boolean;
}

Programmatic Validation

import { z } from 'zod';
import { CAPABILITIES, IValidationService, validatedStateKey } from '@setu-ts/common';

const CreateUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).max(120),
  role: z.enum(['admin', 'user', 'guest']).default('user'),
});

app.router.post('/users', {
  middleware: [
    async (ctx, next) => {
      const validation = ctx.services.get<IValidationService>(CAPABILITIES.VALIDATION);
      const body = await ctx.request.json();
      const result = validation.validate(CreateUserSchema, body);

      if (!result.success) {
        return ctx.response.status(400).json({ errors: result.error });
      }

      ctx.state.set(validatedStateKey('body'), result.value);
      await next();
    },
  ],
  handler: async (ctx) => {
    const body = ctx.state.get<z.infer<typeof CreateUserSchema>>(validatedStateKey('body'));
    // body is fully typed and validated
    const user = await createUser(body);
    return ctx.response.status(201).json(user);
  },
});

Validation Middleware Helpers

The helpers resolve IValidationService from the request context automatically. The five validateXxx helpers and createValidationMiddleware return ValidatorMiddleware<ValidatedOutput<S>> — a MiddlewareFunction (so every existing call site type-checks unchanged) with one added member, value(ctx):

import { z } from 'zod';
import {
  validateBody,
  validateCookies,
  validateHeaders,
  validateParams,
  validateQuery,
} from '@setu-ts/validation-plugin';

const listQuery = validateQuery(ListUsersQuerySchema);
const userIdParams = validateParams(z.object({ id: z.string().uuid() }));
const updateBody = validateBody(UpdateUserSchema);

app.router.get('/users', {
  middleware: [listQuery],
  handler: async (ctx) => {
    const query = listQuery.value(ctx); // ValidatedOutput<typeof ListUsersQuerySchema>
  },
});

app.router.put('/users/:id', {
  middleware: [userIdParams, updateBody],
  handler: async (ctx) => {
    const params = userIdParams.value(ctx);
    const body = updateBody.value(ctx);
    // both are validated and typed by their schemas
  },
});

ValidatedOutput<S> is the schema’s parsed output type — coercions and defaults included — inferred from the schema you pass, so the type follows the schema instead of being restated (or cast) in the handler. value(ctx) returns the stored value only when that validator ran on the request; it throws a TypeError naming the target otherwise — when the validator is not in the route’s chain, or a later validator for the same target overwrote it. Validated values are still stored in ctx.state under validatedStateKey(target) — the wire format is unchanged, and the @ValidateBody/@ValidateQuery/@ValidateParams resolvers read through it — so the old ctx.state.get(validatedStateKey('body')) read still works.

Using the Service’s middleware() Method

The IValidationService.middleware() method builds middleware with the formatter chosen at plugin construction time:

import { CAPABILITIES, IValidationService } from '@setu-ts/common';

app.router.post('/users', (ctx, next) => {
  const validation = ctx.services.get<IValidationService>(CAPABILITIES.VALIDATION);
  return validation.middleware(CreateUserSchema, 'body')(ctx, next);
});

Form body validation

validateBody(schema, { forms: true }) and @ValidateBody(schema, { forms: true }) also validate application/x-www-form-urlencoded and multipart/form-data bodies with the same schema they validate a JSON body with. Off by default: a cross-site HTML <form> reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddleware from @setu-ts/session-plugin). Without the option a form body answers 415 “Unsupported Media Type”. The option is ValidationMiddlewareOptions in @setu-ts/common, the optional third parameter of IValidationService.middleware, and has no effect on any target but body. With it, a server-rendered <form> post validates against the schema a JSON client passes and reads back through value(ctx).

The first-party validation service requires application/json or any application/*+json media type, ignoring case and parameters, or a recognised form type when forms are enabled. Unsupported types, including text/plain and an absent header, answer 415 before any body read, through the application’s error responder. Clients must send Content-Type: application/json for JSON, including on an empty body-validated request. Routes reading ctx.request.json() directly are unchanged; replacement IValidationService implementations own their media-type handling. Cookie-authenticated routes still need csrfMiddleware or csrfFormMiddleware. The 415 takes the error responder’s format, while a 400 validation failure takes ValidationPlugin({ errorFormat }), so set both to the same format; the CLI templates set both to 'rfc9457'.

The form is converted to a record before validation: a field sent once is a single value, a repeated field becomes an array of its values in wire order, and a name ending [] is always an array (kept under the name as written). Values are strings or the framework’s FormFile — NOT a web File, so z.instanceof(File) refuses a file part and a schema checks the FormFile shape instead; numbers and booleans arrive as strings, so schemas use z.coerce for them. A form that carries the session plugin’s _csrf field reaches the schema, where a Zod object strips the unknown key by default and forbidNonWhitelisted (or the schema’s own .strict()) refuses it. A body the runtime refuses for size (RuntimePlugin({ maxBodyBytes })) answers 413 — on a validated route it used to answer 400 — and a form body that cannot be parsed answers the same 400 a malformed body does. The generated OpenAPI document still describes a validated body as application/json.

Sanitization

Sanitization is a standalone export (not a method on IValidationService):

import { SanitizationRules, sanitize } from '@setu-ts/validation-plugin';

const rules: SanitizationRules = {
  htmlEncode: true,
  stripTags: true,
  maxLength: 1000,
  trim: true,
};

const clean = sanitize(userInput, rules);

You can also create a reusable sanitizer function:

import { createSanitizer } from '@setu-ts/validation-plugin';

const sanitizer = createSanitizer({ htmlEncode: true, maxLength: 500 });
const clean1 = sanitizer(inputA);
const clean2 = sanitizer(inputB);

Error Response Formats

Default format

{
  "message": "Validation failed with 2 issue(s).",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

RFC 9457 Problem Details

The 'rfc7807' alias produces a byte-identical body: this formatter’s type is a semantic URI that was never derived from the status code, so it was already valid under RFC 9457.

{
  "type": "https://setu-ts.dev/errors/validation",
  "title": "Validation Error",
  "status": 400,
  "detail": "The request contains 1 validation error(s).",
  "instance": "/users",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

NestJS format

{
  "statusCode": 400,
  "message": ["email: Invalid email address"],
  "error": "Bad Request",
  "errors": [{ "field": "email", "message": "Invalid email address", "code": "invalid_string" }]
}

Custom Error Formatter

import { ValidationPlugin } from '@setu-ts/validation-plugin';

app.register(
  ValidationPlugin({
    errorFormat: (issues) => ({
      ok: false,
      fields: issues.map((i) => ({ name: i.path, reason: i.message })),
    }),
  }),
);

Agent card · TypeDoc API reference