Skip to content

Validation

The validation ecosystem splits along DI lines:

  • @modularityjs/validation-schema — schema typing primitive. Pure types + the native-schema side-channel registry. Zero deps, no DI, no Module. Importable from anywhere without wiring (lives in the INFRASTRUCTURE_PACKAGES allowlist alongside di, modularity, exception, retry).
  • @modularityjs/validation — the Validator runtime contract + ValidationModule. Used when an app actually wants to inject a validator service.

Apps doing OpenAPI documentation only (no runtime validation) need only validation-schema. Apps that validate request bodies at runtime need both.

Contract

typescript
// from @modularityjs/validation-schema (no DI, importable anywhere)
interface Schema<T> {
  parse(value: unknown): ValidationResult<T>;
}

type ValidationResult<T> =
  | { ok: true; value: T }
  | { ok: false; errors: ValidationError[] };

// from @modularityjs/validation (DI contract — wire ValidationModule + a driver)
abstract class Validator {
  abstract check<T>(schema: Schema<T>, value: unknown): ValidationResult<T>;

  // Convenience helper — throws ValidationException on failure.
  assert<T>(schema: Schema<T>, value: unknown): T;
}

ValidationError is the standard shape from @modularityjs/exception: { field: string; message: string; code?: string }. Issue paths flatten to dotted strings — user.email, items.0.name.

Native-schema side channel

@modularityjs/validation-schema also exposes a module-global WeakMap registry used by validation drivers to record the underlying native schema (Zod type, AJV JSON Schema, …) and by OpenAPI tooling to read it back for documentation generation:

typescript
import {
  recordNativeSchema,
  getNativeSchema,
} from '@modularityjs/validation-schema';

// Driver side (e.g. validation-zod) — record when wrapping a Zod schema
recordNativeSchema(schema, 'zod', zodType);

// Consumer side (e.g. http-openapi builder) — read for serialization
const record = getNativeSchema(schema); // { kind: 'zod', native: zodType } | undefined

The registry is keyed by Schema instance identity via WeakMap, so schemas are garbage-collected normally when no longer referenced.

assert(schema, value) throws ValidationException. The Fastify driver's FrameworkExceptionFilter maps that to 422 Unprocessable Entity with field-level errors in the response body.

Drivers

Zod (@modularityjs/validation-zod)

typescript
import { ValidationModule } from '@modularityjs/validation';
import { ValidationZodModule, zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';

const modules = [ValidationModule, ValidationZodModule];

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

zodSchema(zodType) wraps any Zod type as a Schema<T>. Zod's coercions (z.coerce.number(), etc.) and transforms work as expected — parse() returns the post-transform value.

Ajv (@modularityjs/validation-ajv)

typescript
import { ValidationModule } from '@modularityjs/validation';
import { ValidationAjvModule, jsonSchema } from '@modularityjs/validation-ajv';
import type { JSONSchemaType } from 'ajv';

const modules = [ValidationModule, ValidationAjvModule];

interface User {
  name: string;
  age: number;
}

const userJson: JSONSchemaType<User> = {
  type: 'object',
  properties: {
    name: { type: 'string', minLength: 1 },
    age: { type: 'integer', minimum: 0 },
  },
  required: ['name', 'age'],
  additionalProperties: false,
};

const userSchema = jsonSchema(userJson);

jsonSchema(json) compiles the JSON Schema via a shared module-level Ajv({ allErrors: true }) instance. Pass a second argument to use your own configured Ajv (custom formats, keywords, etc.):

typescript
import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true, coerceTypes: true });
addFormats(ajv);

const userSchema = jsonSchema(userJson, ajv);

Manual Validation

When you don't need the HTTP extension, inject Validator directly:

typescript
@Injectable()
class UserService {
  constructor(@Inject(Validator) private readonly validator: Validator) {}

  parse(input: unknown): User {
    return this.validator.assert(userSchema, input); // throws ValidationException
  }

  tryParse(input: unknown): User | undefined {
    const result = this.validator.check(userSchema, input);
    return result.ok ? result.value : undefined;
  }
}

The Validator token is the same regardless of driver — apps can swap Zod for Ajv (or any future driver) without touching service code.

HTTP Bridge

@modularityjs/http-validation registers four parameter decorators that pluck the source from the request and validate it before the handler runs.

typescript
import { HttpModule } from '@modularityjs/http';
import { ValidationModule } from '@modularityjs/validation';
import { HttpFastifyModule } from '@modularityjs/http-fastify';
import {
  HttpValidationModule,
  ValidatedBody,
  ValidatedQuery,
} from '@modularityjs/http-validation';
import { ValidationZodModule, zodSchema } from '@modularityjs/validation-zod';
import { z } from 'zod';

const modules = [
  HttpModule.forRoot({ port: 3000 }),
  HttpFastifyModule,
  ValidationModule,
  ValidationZodModule,
  HttpValidationModule,
];

const filterSchema = zodSchema(
  z.object({ limit: z.coerce.number().int().positive().max(100) }),
);

@Controller('/users')
class UsersController {
  @Post('/')
  create(@ValidatedBody(userSchema) body: User) {
    // body is fully typed and validated
    return { id: 1, ...body };
  }

  @Get('/')
  list(@ValidatedQuery(filterSchema) filter: { limit: number }) {
    return { users: [], filter };
  }
}

Decorators

DecoratorSourceNotes
@ValidatedBody(schema)request.bodyRequires a body parser (e.g. JSON via Fastify's default).
@ValidatedQuery(schema)request.queryUse z.coerce.* / Ajv coerceTypes for string-to-X casts.
@ValidatedParams(schema)request.paramsPath parameters are always strings before coercion.
@ValidatedHeaders(schema)request.headersHeader values are string | string[] | undefined.

Error Response

When validation fails, ValidationException propagates through the standard error pipeline. The Fastify driver's built-in FrameworkExceptionFilter maps it to 422 Unprocessable Entity automatically — no @UseErrorFilter registration needed. The client receives:

json
{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed: 2 error(s)",
  "errors": [
    {
      "field": "name",
      "message": "Too small: expected string to be >= 1",
      "code": "TOO_SMALL"
    },
    {
      "field": "age",
      "message": "Number must be greater than or equal to 0",
      "code": "TOO_SMALL"
    }
  ]
}

HTTP status: 422 Unprocessable Entity.

Choosing a Driver

NeedPick
TypeScript-first inference, transforms, branded typesvalidation-zod
Standard JSON Schema, OpenAPI integration, format libraryvalidation-ajv
Both — different schemas in the same appPick one for the Validator token; use the other's parse() directly when needed

The Schema<T> adapter shape is library-agnostic — a Zod-wrapped schema and an Ajv-wrapped schema are interchangeable as far as Validator.assert is concerned. The driver only matters for cross-cutting behaviour (config, telemetry hooks).