GuidesAPI Development

Request Validation

Validate and transform request data using Zod and NextRush's built-in ValidationError system.

TypeScript types disappear at runtime. Without validation, malformed input propagates through your system, causes silent failures, and opens security holes.

This guide shows how to validate request data using Zod with NextRush — in both functional and class-based styles.

What You Will Build

A validated API endpoint that:

  • Rejects invalid input with structured error responses
  • Uses NextRush's ValidationError and ValidationIssue types
  • Integrates Zod with the @Body({ transform }) decorator
  • Returns consistent, machine-readable validation errors

Prerequisites

  • A working NextRush application (Getting Started)
  • Familiarity with Zod schemas
  • @nextrush/body-parser configured for JSON parsing

A dedicated validation package exists

@nextrush/validation provides a validate() middleware built specifically for this task — it validates the body/query/params against any Standard Schema library in one call and reuses NextRush's ValidationError. It is not re-exported by nextrush or nextrush/class — install it directly. Its own test suite integration-tests against Zod only; Valibot and ArkType are structurally compatible (same ~standard contract) but not exercised by that package's tests. This guide first shows @nextrush/validation's validate(), then a hand-rolled pattern for cases needing more control than one middleware call gives you.

$ pnpm add zod @nextrush/body-parser @nextrush/validation

For most routes, validate() is less code than hand-rolling safeParse + ValidationError yourself. It validates and replaces ctx.body with the coerced value on success:

src/routes/users.ts
import { createApp, listen } from 'nextrush';
import { json } from '@nextrush/body-parser';
import { validate } from '@nextrush/validation';
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  email: z.string().email('Invalid email format'),
  age: z.number().int().min(18, 'Must be at least 18').optional(),
});

const app = createApp();

app.post('/users', json(), validate(CreateUserSchema), (ctx) => {
  // ctx.body is now the validated, coerced value — no cast, no second variable
  ctx.status = 201;
  ctx.json({ data: ctx.body });
});

listen(app, 8080);

Validate query and route params together by passing a spec map instead of a bare schema:

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

const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
});

app.get('/users', validate({ query: PaginationSchema }), (ctx) => {
  // ctx.query is validated but left as strings — @nextrush/validation never
  // coerces query/params in place, only ctx.body
  ctx.json({ page: ctx.query.page, limit: ctx.query.limit });
});

Invalid input never reaches the handler — validate() throws the same ValidationError this guide covers throughout, so it flows into errorHandler() exactly like a manually-thrown one. See the @nextrush/validation README for the full options reference.

Step 1 — Hand-Roll Validation in Functional Routes

The rest of this guide covers validating without @nextrush/validation — useful when you need per-field control validate()'s single middleware call doesn't give you, or when composing validation into a helper function rather than route-level middleware.

Define a Zod schema

src/schemas/user.ts
import { import zz } from 'zod';

export const 
const CreateUserSchema: z.ZodObject<{
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>
CreateUserSchema
= import zz.
function object<{
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
}>(shape?: {
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>
object
({
name: z.ZodStringname: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1, 'Name is required')._ZodString<$ZodStringInternals<string>>.max(maxLength: number, params?: string | z.core.$ZodCheckMaxLengthParams): z.ZodStringmax(100), email: z.ZodStringemail: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.email(params?: string | z.core.$ZodCheckEmailParams): z.ZodString
@deprecatedUse `z.email()` instead.
email
('Invalid email format'),
age: z.ZodOptional<z.ZodNumber>age: import zz.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumbernumber()._ZodNumber<$ZodNumberInternals<number>>.int(params?: string | z.core.$ZodCheckNumberFormatParams): z.ZodNumber
Consider `z.int()` instead. This API is considered *legacy*; it will never be removed but a better alternative exists.
int
()._ZodNumber<$ZodNumberInternals<number>>.min(value: number, params?: string | z.core.$ZodCheckGreaterThanParams): z.ZodNumbermin(18, 'Must be at least 18').ZodType<any, any, $ZodNumberInternals<number>>.optional(): z.ZodOptional<z.ZodNumber>optional(),
}); export type
type CreateUserInput = {
    name: string;
    email: string;
    age?: number | undefined;
}
CreateUserInput
= import zz.
type infer<T> = T extends {
    _zod: {
        output: any;
    };
} ? T["_zod"]["output"] : unknown
export infer
infer
<typeof
const CreateUserSchema: z.ZodObject<{
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>
CreateUserSchema
>;
const
const result: z.ZodSafeParseResult<{
    name: string;
    email: string;
    age?: number | undefined;
}>
result
=
const CreateUserSchema: z.ZodObject<{
    name: z.ZodString;
    email: z.ZodString;
    age: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>
CreateUserSchema
.
ZodType<any, any, $ZodObjectInternals<{ name: ZodString; email: ZodString; age: ZodOptional<ZodNumber>; }, $strip>>.safeParse(data: unknown, params?: z.core.ParseContext<z.core.$ZodIssue>): z.ZodSafeParseResult<{
    name: string;
    email: string;
    age?: number | undefined;
}>
safeParse
({ name: stringname: 'Ada', email: stringemail: 'ada@example.com' });
if (
const result: z.ZodSafeParseResult<{
    name: string;
    email: string;
    age?: number | undefined;
}>
result
.success: booleansuccess) {
const
const data: {
    name: string;
    email: string;
    age?: number | undefined;
}
data
=
const result: z.ZodSafeParseSuccess<{
    name: string;
    email: string;
    age?: number | undefined;
}>
result
.
data: {
    name: string;
    email: string;
    age?: number | undefined;
}
data
;
}

Validate inside the route handler

Use safeParse and throw a ValidationError on failure:

src/routes/users.ts
import { createRouter, ValidationError } from 'nextrush';
import type { ValidationIssue } from 'nextrush';
import { CreateUserSchema } from '../schemas/user.js';

const router = createRouter();

router.post('/users', (ctx) => {
  const result = CreateUserSchema.safeParse(ctx.body);

  if (!result.success) {
    const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
      path: issue.path.join('.'),
      message: issue.message,
    }));
    throw new ValidationError(issues);
  }

  // result.data is typed as { name: string; email: string; age?: number }
  ctx.status = 201;
  ctx.json({ data: result.data });
});

export { router };

ValidationError takes an array of ValidationIssue objects as the first argument and an optional message as the second. Each issue uses path (not field) to identify the failing property.

Step 2 — Validate with Decorators

The @Body, @Param, and @Query decorators accept a transform option. The transform runs before your method executes. If it throws, the request fails with an error.

Use transform with Zod's parseAsync

src/controllers/users.controller.ts
import { Controller, Post, Get, Body, Query, Service } from 'nextrush/class';
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(18).optional(),
});

type CreateUserInput = z.infer<typeof CreateUserSchema>;

@Controller('/users')
class UsersController {
  @Post()
  async create(
    @Body({ transform: (data) => CreateUserSchema.parseAsync(data) }) data: CreateUserInput
  ) {
    return { data };
  }
}

Understand the execution flow

When a request arrives:

  1. The builder extracts the raw body from the context
  2. Your transform function runs (awaited for async transforms)
  3. If the transform throws, it is wrapped in a ParameterInjectionError
  4. If it succeeds, the validated result is passed to your method

Use parseAsync instead of parse for the transform. The handler builder awaits all transforms, and parseAsync handles schemas with async refinements correctly.

Step 3 — Create a Reusable Validation Helper

For functional routes, a helper that converts Zod errors to ValidationError reduces boilerplate:

src/utils/validate.ts
import { z, ZodSchema, ZodError } from 'zod';
import { ValidationError } from 'nextrush';
import type { ValidationIssue } from 'nextrush';

export function validate<T>(schema: ZodSchema<T>, data: unknown): T {
  const result = schema.safeParse(data);

  if (!result.success) {
    throw toValidationError(result.error);
  }

  return result.data;
}

function toValidationError(error: ZodError): ValidationError {
  const issues: ValidationIssue[] = error.issues.map((issue) => ({
    path: issue.path.join('.'),
    message: issue.message,
  }));
  return new ValidationError(issues);
}

Use it in routes:

import { validate } from '../utils/validate.js';
import { CreateUserSchema } from '../schemas/user.js';

router.post('/users', (ctx) => {
  const data = validate(CreateUserSchema, ctx.body);
  ctx.status = 201;
  ctx.json({ data });
});

Step 4 — Validate Query and Route Parameters

Query Parameters

Query values arrive as strings. Use z.coerce to parse them:

const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  sort: z.enum(['asc', 'desc']).default('desc'),
});

router.get('/users', (ctx) => {
  const query = validate(PaginationSchema, ctx.query);
  ctx.json({ page: query.page, limit: query.limit });
});

Route Parameters

const IdParamSchema = z.object({
  id: z.string().uuid('Invalid ID format'),
});

router.get('/users/:id', (ctx) => {
  const { id } = validate(IdParamSchema, ctx.params);
  ctx.json({ id });
});

With Decorators

@Controller('/users')
class UsersController {
  @Get('/:id')
  findOne(@Param('id', { transform: (v) => IdParamSchema.shape.id.parse(v) }) id: string) {
    return { id };
  }

  @Get()
  findAll(
    @Query({ transform: (q) => PaginationSchema.parseAsync(q) })
    query: z.infer<typeof PaginationSchema>
  ) {
    return { page: query.page, limit: query.limit };
  }
}

Step 5 — Validation Middleware

For routes that need reusable validation without decorators, create validation middleware:

src/middleware/validate.ts
import { ZodSchema } from 'zod';
import type { Middleware } from 'nextrush';
import { ValidationError } from 'nextrush';
import type { ValidationIssue } from 'nextrush';

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

export function validateRequest(schemas: ValidationSchemas): Middleware {
  return async (ctx) => {
    const issues: ValidationIssue[] = [];

    if (schemas.body) {
      const result = schemas.body.safeParse(ctx.body);
      if (!result.success) {
        for (const issue of result.error.issues) {
          issues.push({ path: `body.${issue.path.join('.')}`, message: issue.message });
        }
      } else {
        ctx.state.validatedBody = result.data;
      }
    }

    if (schemas.query) {
      const result = schemas.query.safeParse(ctx.query);
      if (!result.success) {
        for (const issue of result.error.issues) {
          issues.push({ path: `query.${issue.path.join('.')}`, message: issue.message });
        }
      } else {
        ctx.state.validatedQuery = result.data;
      }
    }

    if (schemas.params) {
      const result = schemas.params.safeParse(ctx.params);
      if (!result.success) {
        for (const issue of result.error.issues) {
          issues.push({ path: `params.${issue.path.join('.')}`, message: issue.message });
        }
      }
    }

    if (issues.length > 0) {
      throw new ValidationError(issues);
    }

    await ctx.next();
  };
}

Use as route-specific middleware:

router.post('/users', validateRequest({ body: CreateUserSchema }), (ctx) => {
  const data = ctx.state.validatedBody;
  ctx.status = 201;
  ctx.json({ data });
});

Error Response Format

When ValidationError is thrown, its toJSON() method produces this structure:

{
  "error": "ValidationError",
  "message": "Validation failed",
  "code": "VALIDATION_ERROR",
  "status": 400,
  "issues": [
    { "path": "email", "message": "Invalid email format" },
    { "path": "password", "message": "Password must be at least 8 characters" },
    { "path": "password", "message": "Must contain uppercase letter" }
  ]
}

Each issue has a path string and a message. The rule and expected fields appear when set. The received field is stripped from JSON output to prevent leaking sensitive input values.

Built-in Convenience Errors

@nextrush/errors also exports RequiredFieldError, TypeMismatchError, RangeValidationError, LengthError, PatternError, InvalidEmailError, and InvalidUrlError. These are ValidationError subclasses with pre-built issue formats for common cases.

Advanced Patterns

Cross-Field Validation

import { import zz } from 'zod';

const 
const PasswordSchema: z.ZodObject<{
    password: z.ZodString;
    confirmPassword: z.ZodString;
}, z.core.$strip>
PasswordSchema
= import zz
.
function object<{
    password: z.ZodString;
    confirmPassword: z.ZodString;
}>(shape?: {
    password: z.ZodString;
    confirmPassword: z.ZodString;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    password: z.ZodString;
    confirmPassword: z.ZodString;
}, z.core.$strip>
object
({
password: z.ZodStringpassword: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(8), confirmPassword: z.ZodStringconfirmPassword: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), }) .
ZodType<any, any, $ZodObjectInternals<{ password: ZodString; confirmPassword: ZodString; }, $strip>>.refine<(data: {
    password: string;
    confirmPassword: string;
}) => boolean>(check: (data: {
    password: string;
    confirmPassword: string;
}) => boolean, params?: string | {
    abort?: boolean | undefined | undefined;
    when?: ((payload: z.core.ParsePayload) => boolean) | undefined | undefined;
    path?: PropertyKey[] | undefined | undefined;
    params?: Record<string, any> | undefined;
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssue>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    password: z.ZodString;
    confirmPassword: z.ZodString;
}, z.core.$strip>
refine
((
data: {
    password: string;
    confirmPassword: string;
}
data
) =>
data: {
    password: string;
    confirmPassword: string;
}
data
.password: stringpassword ===
data: {
    password: string;
    confirmPassword: string;
}
data
.confirmPassword: stringconfirmPassword, {
message?: string | undefined
@deprecatedThis parameter is deprecated. Use `error` instead.
message
: 'Passwords do not match',
path?: PropertyKey[] | undefinedpath: ['confirmPassword'], }); type
type PasswordInput = {
    password: string;
    confirmPassword: string;
}
PasswordInput
= import zz.
type infer<T> = T extends {
    _zod: {
        output: any;
    };
} ? T["_zod"]["output"] : unknown
export infer
infer
<typeof
const PasswordSchema: z.ZodObject<{
    password: z.ZodString;
    confirmPassword: z.ZodString;
}, z.core.$strip>
PasswordSchema
>;
const
const valid: {
    password: string;
    confirmPassword: string;
}
valid
= { password: stringpassword: 'secret123', confirmPassword: stringconfirmPassword: 'secret123' };

Async Validation

const UniqueEmailSchema = z
  .object({
    email: z.string().email(),
  })
  .refine(
    async (data) => {
      const exists = await db.users.findByEmail(data.email);
      return !exists;
    },
    { message: 'Email already in use', path: ['email'] }
  );

// Use parseAsync for async refinements
router.post('/users', async (ctx) => {
  const data = await UniqueEmailSchema.parseAsync(ctx.body);
  ctx.json({ data });
});

Transform and Sanitize

import { import zz } from 'zod';

const 
const UserInputSchema: z.ZodObject<{
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
}, z.core.$strip>
UserInputSchema
= import zz.
function object<{
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
}>(shape?: {
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
} | undefined, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
    message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
}, z.core.$strip>
object
({
name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>name: import zz .function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() ._ZodString<$ZodStringInternals<string>>.trim(): z.ZodStringtrim() ._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1) .ZodType<any, any, $ZodStringInternals<string>>.transform<string>(transform: (arg: string, ctx: z.core.$RefinementCtx<string>) => string | Promise<string>): z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>transform((s: strings) => s: strings.
String.replace(searchValue: {
    [Symbol.replace](string: string, replaceValue: string): string;
}, replaceValue: string): string (+3 overloads)
Passes a string and {@linkcode replaceValue } to the `[Symbol.replace]` method on {@linkcode searchValue } . This method is expected to implement its own replacement algorithm.
@paramsearchValue An object that supports searching for and replacing matches within a string.@paramreplaceValue The replacement text.
replace
(/\s+/g, ' ')),
email: z.ZodStringemail: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.email(params?: string | z.core.$ZodCheckEmailParams): z.ZodString
@deprecatedUse `z.email()` instead.
email
()._ZodString<$ZodStringInternals<string>>.toLowerCase(): z.ZodStringtoLowerCase()._ZodString<$ZodStringInternals<string>>.trim(): z.ZodStringtrim(),
username: z.ZodStringusername: import zz .function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() ._ZodString<$ZodStringInternals<string>>.toLowerCase(): z.ZodStringtoLowerCase() ._ZodString<$ZodStringInternals<string>>.regex(regex: RegExp, params?: string | z.core.$ZodCheckRegexParams): z.ZodStringregex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'), }); type
type UserInput = {
    name: string;
    email: string;
    username: string;
}
UserInput
= import zz.
type output<T> = T extends {
    _zod: {
        output: any;
    };
} ? T["_zod"]["output"] : unknown
export output
output
<typeof
const UserInputSchema: z.ZodObject<{
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
}, z.core.$strip>
UserInputSchema
>;
const
const data: {
    name: string;
    email: string;
    username: string;
}
data
=
const UserInputSchema: z.ZodObject<{
    name: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
    email: z.ZodString;
    username: z.ZodString;
}, z.core.$strip>
UserInputSchema
.
ZodType<any, any, $ZodObjectInternals<{ name: ZodPipe<ZodString, ZodTransform<string, string>>; email: ZodString; username: ZodString; }, $strip>>.parse(data: unknown, params?: z.core.ParseContext<z.core.$ZodIssue>): {
    name: string;
    email: string;
    username: string;
}
parse
({ name: stringname: ' Ada ', email: stringemail: 'ADA@Example.com', username: stringusername: 'Ada_Codes' });

Verification

Test that validation rejects bad input and accepts good input:

src/routes/__tests__/users.test.ts
import { describe, it, expect } from 'vitest';
import { validate } from '../../utils/validate.js';
import { CreateUserSchema } from '../../schemas/user.js';
import { ValidationError } from 'nextrush';

describe('CreateUserSchema', () => {
  it('accepts valid input', () => {
    const data = validate(CreateUserSchema, {
      name: 'Alice',
      email: 'alice@example.com',
    });
    expect(data.name).toBe('Alice');
  });

  it('rejects missing name', () => {
    expect(() => validate(CreateUserSchema, { email: 'a@b.com' })).toThrow(ValidationError);
  });

  it('rejects invalid email', () => {
    try {
      validate(CreateUserSchema, { name: 'Alice', email: 'not-an-email' });
    } catch (error) {
      expect(error).toBeInstanceOf(ValidationError);
      const ve = error as ValidationError;
      expect(ve.issues[0]?.path).toBe('email');
    }
  });
});

Best Practices

  1. Validate at the boundary — validate input the moment it enters your system, not deep inside business logic.
  2. Use z.coerce for query parameters — query values are always strings. z.object({ page: z.number() }) fails; z.coerce.number() works.
  3. Use parseAsync in decorator transforms — the handler builder awaits transforms, so async refinements work correctly.
  4. Write descriptive error messagesz.string().min(1, 'Name is required') is better than z.string().min(1, 'Invalid').
  5. Separate input and output schemas — never return passwords or internal IDs in API responses.

Zod's .parse() throws a ZodError, not a NextRush ValidationError. In functional routes, use the validate helper to convert Zod errors into ValidationError with proper issues format. In decorator transforms, thrown errors are wrapped in a ParameterInjectionError automatically.

What's Next?

Was this helpful?

On this page