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
IValidationAdapterand 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:
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 type | Detected adapter |
|---|---|
| Class constructor with class-validator decorators | ClassValidatorAdapter (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 else | Highest-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.
| Decorator | Source | Optional name | Optional schema |
|---|---|---|---|
@validatedBody(schema?, options?) | req.body | no | yes |
@validatedQuery(name?, schema?) | req.query | yes | yes |
@validatedParam(name?, schema?) | req.params | yes | yes |
@validatedHeaders(name?, schema?) | req.headers | yes | yes |
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
- Zod
- Yup
- class-validator
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 };
}
}
import * as yup from "yup";
import { provide } from "@expressots/core";
import { controller, Post, validatedBody } from "@expressots/adapter-express";
const CreateUserSchema = yup.object({
name: yup.string().min(2).required(),
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
type CreateUserDTO = yup.InferType<typeof CreateUserSchema>;
@provide(UsersController)
@controller("/users")
export class UsersController {
@Post("/")
create(@validatedBody(CreateUserSchema) dto: CreateUserDTO) {
return { ok: true, ...dto };
}
}
import { IsEmail, IsString, MinLength } from "class-validator";
import { provide } from "@expressots/core";
import { controller, Post, validatedBody } from "@expressots/adapter-express";
export class CreateUserDTO {
@IsString()
@MinLength(2)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
@provide(UsersController)
@controller("/users")
export class UsersController {
@Post("/")
create(@validatedBody(CreateUserDTO) dto: CreateUserDTO) {
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",
"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
- Error handling: typed errors and the RFC 7807 exception-filter pipeline.
- Exception filters: customize how thrown errors become HTTP responses.
- DTO validator: the class-validator-flavoured walkthrough.
- Configuration: typed environment validation with
defineConfig+Env.*.