Skip to main content
Version: 4.0.0-preview

Validation

ExpressoTS v4 ships a pluggable validation system that decouples what you validate (the schema language) from how it runs (the registry and parameter decorators). Pick one: or several: of:

  • Zod: schema-first, fully type-inferred. Highest signal-to-noise for new code.
  • Yup: chainable schema builder, classic ergonomics.
  • class-validator: decorator-based on classes (the v3 default). Great when your DTOs already exist.
  • Custom adapter: implement IValidationAdapter and register your own.

The same parameter decorators (@validatedBody, @validatedQuery, @validatedParam, @validatedHeaders) work with all adapters.

Why a registry?

Validation in v3 was hardcoded to class-validator. v4 keeps that path open but lets you mix adapters per project: or per route: without rewriting your DTOs. Enable validation once in your App class with addValidation(). The class-validator adapter is registered automatically; pass any extra adapter classes you plan to use:

src/app.ts (one-time setup)
import { AppExpress } from "@expressots/adapter-express";
import { ZodValidatorAdapter, YupValidatorAdapter } from "@expressots/core";

export class App extends AppExpress {
async configureServices(): Promise<void> {
// The registry resolves the right adapter at request time
// based on the schema you pass to @validatedBody.
this.Middleware.addValidation({
adapters: [ZodValidatorAdapter, YupValidatorAdapter],
});
}
}

Out of the box the framework auto-detects the adapter from the schema type (higher priority is checked first):

Schema typeDetected adapter
Class constructor with class-validator decoratorsClassValidatorAdapter (priority 100)
Object with safeParse / parse methods (Zod)ZodValidatorAdapter (priority 90)
Object with the __isYupSchema__ brand or validate + cast methods (Yup)YupValidatorAdapter (priority 80)
Anything elseHighest-priority registered adapter whose canHandle() accepts it

Parameter decorators

All four come from @expressots/adapter-express. They have the same call signatures as the legacy @body / @query / @param / @headers, plus an optional schema first argument.

DecoratorSourceOptional nameOptional schema
@validatedBody(schema?, options?)req.bodynoyes
@validatedQuery(name?, schema?)req.queryyesyes
@validatedParam(name?, schema?)req.paramsyesyes
@validatedHeaders(name?, schema?)req.headersyesyes

Each decorator supports two validated forms:

  • (schema, options?): validate the whole source object against the schema.
  • (name, schema) (query, params, headers): extract the single named value and validate just it. Error paths are prefixed with the name (e.g. id), and header names are matched case-insensitively.

If you skip the schema, the decorator just injects the value (same as the legacy @body etc.). The "validated" power kicks in when you pass a schema.

End-to-end examples

import { z } from "zod";
import { provide } from "@expressots/core";
import { controller, Post, validatedBody } from "@expressots/adapter-express";

const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
});
type CreateUserDTO = z.infer<typeof CreateUserSchema>;

@provide(UsersController)
@controller("/users")
export class UsersController {
@Post("/")
create(@validatedBody(CreateUserSchema) dto: CreateUserDTO) {
// dto is fully typed AND already validated.
return { ok: true, ...dto };
}
}

The behaviour is identical for every flavour: a request with an invalid body is rejected before your handler runs with a 400 plain JSON response formatted by the validation system (see Validation error format below).

Validating query / params / headers

import { z } from "zod";
import { controller, Get, validatedQuery, validatedParam } from "@expressots/adapter-express";
import { provide } from "@expressots/core";

const ListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});

const UserId = z.string().uuid();

@provide(UsersController)
@controller("/users")
export class UsersController {
@Get("/")
list(@validatedQuery(ListQuery) query: z.infer<typeof ListQuery>) {
return this.users.findAll(query);
}

@Get("/:id")
getById(@validatedParam("id", UserId) id: string) {
return this.users.findById(id);
}
}

@validatedQuery / @validatedParam / @validatedHeaders accept an entire schema (no name) to validate the whole object. With a name, they extract a single field and validate it.

Programmatic validation

When you need to validate something outside an HTTP request: a queue payload, an event, a CLI input: use a ValidationRegistry with the adapters you need:

import { provide, ValidationRegistry, createZodValidator } from "@expressots/core";

const registry = new ValidationRegistry();
registry.register(createZodValidator());

@provide(ImportService)
export class ImportService {
async importBatch(rows: Array<unknown>) {
for (const row of rows) {
const result = await registry.validate(row, CreateUserSchema);
if (!result.success) {
throw new Error(`Invalid row: ${JSON.stringify(result.errors)}`);
}
}
}
}

createZodValidator() (and createYupValidator()) take no arguments; they are convenience factories equivalent to new ZodValidatorAdapter() / new YupValidatorAdapter(). registry.validate(data, schema, options?) returns:

interface ValidationResult<T = unknown> {
success: boolean;
data?: T; // validated/transformed data when success is true
errors?: ValidationFieldError[]; // present when success is false
}

Smart field detection

The validation system also includes a SmartFieldDetector that infers semantics from field names (email, password, phone, url, userId, createdAt, postal codes, credit card numbers, IP addresses, ...) and attaches an example and a hint to each error so messages suggest fixes. This kicks in automatically when validation is enabled: you don't need to wire it up.

Disable it globally with the smartDetection config option:

this.Middleware.addValidation({ smartDetection: false });

Custom adapters

Implement IValidationAdapter and register it. The adapter is consulted whenever a schema is passed to a @validated* decorator:

import {
IValidationAdapter,
ValidationOptions,
ValidationResult,
} from "@expressots/core";

export class MyCustomAdapter implements IValidationAdapter {
readonly name = "my-custom";
readonly priority = 50;

canHandle(schema: unknown): boolean {
return typeof schema === "object" && schema !== null && "myCustomMarker" in schema;
}

async validate(
data: unknown,
schema: unknown,
options?: ValidationOptions,
): Promise<ValidationResult> {
// ...call your validator, normalise failures into ValidationFieldError[]
return { success: true, data };
}
}

Register it through the validation config:

this.Middleware.addValidation({ adapters: [MyCustomAdapter] });

Validation error format

When request validation fails, the framework formats the ValidationFieldError[] with HelpfulErrorFormatter and responds with res.status(400).json(...). The response is plain application/json (validation responses do not use application/problem+json). The default helpful format includes the received value, an example, and a hint per error:

{
"type": "validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "2 validation errors in fields: email, password",
"errors": [
{
"field": "email",
"message": "Must be a valid email address",
"code": "invalid_email",
"received": "not-an-email",
"expected": "valid email format",
"example": "[email protected]",
"hint": "Must be a valid email address with @ and domain"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"code": "min_length",
"received": "short",
"expected": "string with at least 8 characters",
"example": "SecureP@ss123",
"hint": "Password should be at least 8 characters"
}
]
}

Switch the shape with the errorFormat config option ("helpful", "simple", or "rfc7807"):

this.Middleware.addValidation({ errorFormat: "simple" });

See also