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 theINFRASTRUCTURE_PACKAGESallowlist alongsidedi,modularity,exception,retry).@modularityjs/validation— theValidatorruntime 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
// 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:
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 } | undefinedThe 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)
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)
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.):
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:
@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.
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
| Decorator | Source | Notes |
|---|---|---|
@ValidatedBody(schema) | request.body | Requires a body parser (e.g. JSON via Fastify's default). |
@ValidatedQuery(schema) | request.query | Use z.coerce.* / Ajv coerceTypes for string-to-X casts. |
@ValidatedParams(schema) | request.params | Path parameters are always strings before coercion. |
@ValidatedHeaders(schema) | request.headers | Header 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:
{
"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
| Need | Pick |
|---|---|
| TypeScript-first inference, transforms, branded types | validation-zod |
| Standard JSON Schema, OpenAPI integration, format library | validation-ajv |
| Both — different schemas in the same app | Pick 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).