Skip to content

Validation Wiring

Recipes are working wiring examples with the sharp edges annotated. This page and the validation-wiring recipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.

Four packages compose:

  • @modularityjs/validation-schemaSchema<T> interface, ValidationResult<T>, and the native-schema side-channel registry (recordNativeSchema / getNativeSchema). Zero deps, no @Module, infrastructure-tier. Apps doing OpenAPI documentation only need this (no ValidationModule wiring).
  • @modularityjs/validationValidator abstract (check returns ValidationResult<T>, assert throws ValidationException), ValidationModule (declares the Validator contract). Import this only when an app injects Validator at runtime (e.g. via HttpValidationModule).
  • @modularityjs/validation-zodZodValidator driver bound via preferences: [{ provide: Validator, useClass: ZodValidator }], plus zodSchema(zodType) adapter that wraps a Zod type into a framework Schema<T>. Calls recordNativeSchema(schema, 'zod', zodType) so OpenAPI drivers can recover the Zod schema for documentation.
  • @modularityjs/http-validationHttpValidationModule + the @ValidatedBody / @ValidatedQuery / @ValidatedParams / @ValidatedHeaders parameter decorators.

HttpValidationModule registers its @Validated* parameter resolvers declaratively via preferences (not afterLoad) and depends on a Validator driver being wired. Include a driver (ValidationZodModule) somewhere in modules: [...]; the loader topologically sorts modules, so relative order does not matter — boot fails only if no Validator driver is present at all, never because of ordering.

The decorators take a Schema<T> value, not a class — call zodSchema(z.object({...})) once at module scope (or import-level constant), pass the result. Each call to a @Validated* decorator registers the schema in a module-scoped registry; declaring the schema inline inside the controller method (e.g. @ValidatedBody(zodSchema(z.object({...}))) re-evaluated per request) leaks a new registry entry each time the decorator factory runs — only fine because decorators run once at class-evaluation time, not per request.

On schema failure the underlying validator.assert throws ValidationException, which FrameworkExceptionFilter (from @modularityjs/http-fastify) maps to 422 with body { code: 'VALIDATION_FAILED', message, errors: [{ field, message, code }] }. The Fastify adapter applies this filter as an implicit fallback for every FrameworkException, so a ValidationException maps to 422 even without registering the filter — the explicit @UseErrorFilter(FrameworkExceptionFilter) below is optional and only needed to override or customize that default.

typescript
import { Inject, Injectable } from '@modularityjs/di';
import {
  Controller,
  Get,
  HttpControllersPool,
  HttpModule,
  Post,
  UseErrorFilter,
} from '@modularityjs/http';
import {
  FrameworkExceptionFilter,
  HttpFastifyModule,
} from '@modularityjs/http-fastify';
import {
  HttpValidationModule,
  ValidatedBody,
  ValidatedQuery,
} from '@modularityjs/http-validation';
import { Module } from '@modularityjs/modularity';
import { ValidationModule } from '@modularityjs/validation';
import { ValidationZodModule, zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';

const createUserSchema = zodSchema(
  z.object({
    name: z.string().min(1),
    age: z.number().int().nonnegative(),
  }),
);

const listFilterSchema = zodSchema(
  z.object({
    limit: z.coerce.number().int().positive().max(100), // query strings are strings — coerce
  }),
);

@Injectable()
@Controller('/users')
@UseErrorFilter(FrameworkExceptionFilter) // turns ValidationException into 422
class UsersController {
  @Post('/')
  create(@ValidatedBody(createUserSchema) body: { name: string; age: number }) {
    return { created: body };
  }

  @Get('/')
  list(@ValidatedQuery(listFilterSchema) filter: { limit: number }) {
    return { filter };
  }
}

@Module({
  name: 'users',
  imports: [HttpModule],
  providers: [UsersController, FrameworkExceptionFilter],
  pools: [
    { pool: HttpControllersPool, key: 'users', useClass: UsersController },
  ],
})
class UsersModule {}

// modules: [
//   ModularityModule,
//   HttpModule.forRoot({ port: 3000 }),
//   HttpFastifyModule,
//   ValidationModule,       // contract
//   ValidationZodModule,    // driver — provides Validator (order-independent)
//   HttpValidationModule,   // registers @Validated* resolvers via preferences
//   UsersModule,
// ]

To validate outside HTTP (jobs, CLI), inject Validator directly: validator.assert(schema, value) returns the typed value or throws; validator.check(schema, value) returns { ok: true, value } | { ok: false, errors } for inline handling. Don't write fallback null-checks around @Inject(Validator) — boot fails if no driver is bound, so the dependency is always present at runtime.