ReferenceValidation
@nextrush/validation

Validation

Request validation middleware — bring your own schema library (Zod, Valibot, ArkType, or any Standard Schema).

Why This Package Exists

Validating request input is something every API does, yet hand-rolled validation is where bugs and inconsistency creep in: every handler reinvents type and presence checks, error shapes drift between routes, and ctx.body stays unknown without a validation step forcing a cast.

NextRush ships the glue, not a validator. You bring the schema library you already use; @nextrush/validation runs it and produces one consistent, secure error shape.

Source & internals

Requires a Standard Schema–compatible library: Zod 3.24+, Valibot 1.0+, or ArkType 2.0+. @nextrush/validation never knows or cares which one produced the schema.

Installation

$ pnpm add @nextrush/validation
$ pnpm add -D zod

Minimal Usage

import { createApp } from '@nextrush/core';
import { json } from '@nextrush/body-parser';
import { validate } from '@nextrush/validation';
import { z } from 'zod';

const app = createApp();
const User = z.object({ name: z.string().min(1), email: z.string().email() });

app.post('/users', json(), validate(User), (ctx) => {
  const body = ctx.body; // validated and coerced
  ctx.status = 201;
  ctx.json(body);
});

On success, ctx.body is the validated, coerced value — no separate accessor.

validate(schema) / validate(spec)

Signature:

function validate(arg: StandardSchemaV1 | RequestSchemas): Middleware;
FormValidates
validate(schema)the request body against schema
validate({ body?, query?, params? })each provided target against its own schema

Behavior:

  • On success: overwrites ctx.body with the coerced value; leaves ctx.query/ctx.params unmodified; calls next().
  • On failure: throws ValidationError, aggregating every issue across every validated target — never calls next().
  • Atomic across targets: if any target fails, ctx.body is not overwritten.
  • A schema whose own validator throws an unexpected error (not a validation failure) propagates unchanged — it is never swallowed into a 400.

Validating Multiple Targets

app.get(
  '/users/:id',
  validate({
    params: z.object({ id: z.string().uuid() }),
    query: z.object({ sort: z.enum(['asc', 'desc']) }),
  }),
  (ctx) => {
    const { id } = ctx.params;
    const { sort } = ctx.query;
    ctx.json({ id, sort });
  }
);

One Source of Truth

Behavior by target

PropertyTypeDescription
bodyunknownValidated, coerced, and written back to ctx.body.
queryRecord<string, string | string[]>Validated; rejected if invalid; left as the original string values.
paramsRecord<string, string>Validated; rejected if invalid; left as the original string values.

Why body overwrites and query/params don't: ctx.body is typed unknown, so replacing it with the coerced object is honest — the static type never disagrees with the runtime value. ctx.query/ctx.params are declared as string maps; writing a coerced number back would make TypeScript claim string while the runtime holds a number. So query and params are validated but intentionally left unmodified — coerce explicitly at the read site if needed:

const page = Number(ctx.query.page);

Middleware Order

validate() validates the already-parsed ctx.body — it does not read the request stream. Place it after the body parser:

app.post(
  '/users',
  json(), // 1. parses the body → ctx.body
  validate(User), // 2. validates ctx.body, replaces it with the coerced value
  handler // 3. reads the validated ctx.body
);

If no body parser ran, ctx.body is undefined, and the schema decides the outcome (a required object schema rejects it with a 400).

Errors

Failures throw the framework's existing ValidationError from @nextrush/errors — there is no separate error type for this package. It is rendered automatically by the framework's error handler, and toJSON() strips raw input so passwords or tokens never leak into the response:

{
  "error": "ValidationError",
  "message": "Validation failed",
  "code": "VALIDATION_ERROR",
  "status": 400,
  "issues": [
    { "path": "body.email", "message": "Invalid email address" },
    { "path": "query.sort", "message": "Invalid enum value. Expected 'asc' | 'desc'" }
  ]
}

ValidationError and ValidationIssue are re-exported from @nextrush/validation for a single import site:

import { ValidationError } from '@nextrush/validation';

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err instanceof ValidationError) {
      ctx.status = 400;
      ctx.json(err.toJSON());
      return;
    }
    throw err;
  }
});

Security

  • Standard Schema issues never carry the offending value, and ValidationError.toJSON() strips received — invalid passwords or tokens never appear in an error response.
  • No prototype pollution: issue paths are joined only into display strings (body.__proto__.x is a label, never an assignment).
  • Fail-closed: a schema that signals failure with an empty issues array still rejects the request — validation never silently passes.

Runtime Compatibility

Zero runtime dependencies beyond @nextrush/types and @nextrush/errors. Runs anywhere NextRush runs: Node.js 22+, Bun 1.0+, Deno 1.0+, Cloudflare Workers, Vercel Edge.

Non-Goals

  • Coercing ctx.query/ctx.params — validated but left unmodified today; typed coercion is a planned non-breaking upgrade.
  • A schema DSL — your schema library is the DSL.
  • Body parsing — that is @nextrush/body-parser's job.
  • Response validation — planned alongside @nextrush/openapi.
Was this helpful?

On this page