Validation
Validate a message handler's request before the handler runs, short-circuiting the
pipeline with a validation-error result when the request is invalid.
Overview
In the .NET original, request validation ships as two packages — Benzene.FluentValidation (wrapping
FluentValidation) and Benzene.DataAnnotations (wrapping
System.ComponentModel.DataAnnotations). Neither wrapped library has a TypeScript existence, so per the
port's "third-party integrations are adapted, not reimplemented" convention (see the README
Porting conventions), they are re-created as adapters over the popular
JavaScript validation libraries. This one page replaces both .NET docs.
The shared abstraction stays core (@benzene/abstractions-validation), and each validator gets its own
adapter package, all three mirroring the Benzene.FluentValidation integration shape:
| Package | Adapts | Register a schema | Wire into the router |
|---|---|---|---|
@benzene/zod |
Zod | registerZodSchema |
useZodValidation |
@benzene/joi |
Joi | registerJoiSchema |
useJoiValidation |
@benzene/yup |
Yup | registerYupSchema |
useYupValidation |
Each adapter adds a single piece of per-handler middleware, ValidationMiddleware<TRequest, TResponse>,
to the handler pipeline. For every request:
- No schema registered for the request type → the middleware does nothing and calls
next()(the handler runs). Validation is opt-in per request type, exactly like FluentValidation only running when anIValidator<TRequest>exists. - Schema present, request is
null/undefined→ short-circuit with the mapped status (see Failure status mapping) and the message"Request is null". - Schema present, validation fails → short-circuit with the mapped status and one error message per validation issue. The handler is never invoked.
- Validation passes →
next()runs and the handler executes as normal.
A schema plays the role FluentValidation's IValidator<TRequest> plays in .NET. Because TypeScript erases
generic type arguments, you can't resolve "the validator for TRequest" by runtime type the way C# does;
instead you associate a request class (constructor) with a schema in a typed schema registry, and
the middleware recovers the request class from the handler's @message metadata.
Prerequisites / Installation
- Node 22+.
- One adapter package, plus its underlying validator (each adapter takes its validator as a real runtime dependency — that is the whole point of an adapter package):
npm install @benzene/zod zod
# or
npm install @benzene/joi joi
# or
npm install @benzene/yup yup
You can install more than one and mix them across services; a single router usually uses one.
Basic usage
Three steps: declare a schema, register it against the request class, and wire the adapter into the
handler pipeline via useMessageHandlersWithRouter
— the router seam that runs middleware per handler invocation, around the actual call.
Zod
import { z } from 'zod';
import { IBenzeneResultOf } from '@benzene/abstractions';
import { IMessageHandler } from '@benzene/abstractions-message-handlers';
import { message, useMessageHandlersWithRouter } from '@benzene/core-message-handlers';
import { BenzeneResult } from '@benzene/results';
import { registerZodSchema, useZodValidation } from '@benzene/zod';
class CreateWidget {
name: string | undefined;
}
class WidgetCreated {
id: string | undefined;
}
// The Zod schema plays the role of FluentValidation's IValidator<CreateWidget>.
registerZodSchema(CreateWidget, z.object({ name: z.string().max(10) }));
@message('widget:create', { requestType: CreateWidget, responseType: WidgetCreated })
export class CreateWidgetHandler implements IMessageHandler<CreateWidget, WidgetCreated> {
handleAsync(request: CreateWidget): Promise<IBenzeneResultOf<WidgetCreated>> {
const payload = new WidgetCreated();
payload.id = `widget-${request.name}`;
return Promise.resolve(BenzeneResult.created(payload));
}
}
// Wire validation into the handler pipeline (app is your MessageRouter builder / pipeline builder):
useMessageHandlersWithRouter(app, (router) => useZodValidation(router), CreateWidgetHandler);
If CreateWidget.name is longer than 10 characters, CreateWidgetHandler.handleAsync is never called and
the caller receives a validation-error result carrying Zod's issue messages.
Joi
The same shape — only the schema library changes:
import Joi from 'joi';
import { registerJoiSchema, useJoiValidation } from '@benzene/joi';
registerJoiSchema(CreateWidget, Joi.object({ name: Joi.string().max(10) }));
useMessageHandlersWithRouter(app, (router) => useJoiValidation(router), CreateWidgetHandler);
Yup
import * as yup from 'yup';
import { registerYupSchema, useYupValidation } from '@benzene/yup';
registerYupSchema(CreateWidget, yup.object({ name: yup.string().max(10) }));
useMessageHandlersWithRouter(app, (router) => useYupValidation(router), CreateWidgetHandler);
use<Lib>Validation(router) does two things, mirroring .UseFluentValidation():
- Registers a
DefaultValidationStatusMapperas theIValidationStatusMapperif one isn't already registered (viatryAddSingleton— the first registration wins, so you can supply your own first). - Adds the adapter's
ValidationMiddlewareBuilderto the handler pipeline, which builds aValidationMiddlewareper handler invocation, resolving that handler's schema from the registry.
Because it is per-handler middleware, adding it once validates every handler in that router that has a schema registered for its request type — there's no per-handler opt-in step. Handlers without a registered schema simply pass through unvalidated.
The schema registry
register<Lib>Schema(RequestClass, schema) associates a request class with the schema that validates
its instances, on a process-wide global registry. This replaces FluentValidation's
services.AddSingleton<IValidator<TRequest>, ...>() — the erased request type is recovered from the
handler's @message requestType, then looked up here.
The binding is typed: the schema's inferred/static type is checked against the request class, so a
schema for an unrelated shape is a compile error — recovering the compile-time link
IValidator<TRequest> gave for free in .NET:
class TypedRequest {
name?: string;
}
registerZodSchema(TypedRequest, z.object({ name: z.string() })); // ok
registerZodSchema(TypedRequest, z.object({ name: z.number() })); // compile error
Where does
RequestClasscome from? The handler's@messagedecorator declaresrequestType: CreateWidget. Under type erasure the runtime needs that concrete class to key the topic, generate specs, and — here — find the schema. See Message Handlers; a handler with norequestTypehas nothing to validate against, so the middleware falls through tonext().
For tests (or any module that shouldn't leak into global discovery), construct an isolated registry instance instead of using the global one:
import { ZodSchemaRegistry } from '@benzene/zod';
const registry = new ZodSchemaRegistry();
registry.register(CreateWidget, z.object({ name: z.string().max(10) }));
registry.get(CreateWidget); // the schema; does not affect the global registry
Where validation sits in the pipeline
ValidationMiddleware is an IHandlerMiddlewareBuilder added inside the router callback passed to
useMessageHandlersWithRouter — the same place a handler's request/response types have already been
resolved, and it runs before the handler. Compose it alongside other per-handler middleware:
import { MiddlewarePipelineBuilder } from '@benzene/core-middleware';
import { BenzeneMessageContext } from '@benzene/core-messages';
import {
addBenzene,
addBenzeneMessage,
BenzeneMessageApplication,
useMessageHandlersWithRouter,
} from '@benzene/core-message-handlers';
import { DefaultBenzeneServiceContainer } from '@benzene/dependencies';
import { useZodValidation } from '@benzene/zod';
const container = new DefaultBenzeneServiceContainer();
addBenzene(container);
addBenzeneMessage(container);
const builder = new MiddlewarePipelineBuilder<BenzeneMessageContext>(container);
useMessageHandlersWithRouter(builder, (router) => useZodValidation(router), CreateWidgetHandler);
const app = new BenzeneMessageApplication(builder.build());
Sending { "name": "foo" } routes to the handler and returns ok/created; sending
{ "name": "foo-bar-foo-bar" } short-circuits with validation-error and the handler never runs — this is
exactly the behavior the adapters' pipeline tests assert.
Don't stack two adapters on the same router unless you deliberately want a request checked twice — each adapter registers its own
ValidationMiddlewareand both would run.
Failure status mapping
By default a failed validation maps to BenzeneResultStatus.validationError (the validation-error wire
status — a 422 over HTTP; see Message Results). The status is
resolved through IValidationStatusMapper.getStatus(handlerType, requestType, result), implemented by
DefaultValidationStatusMapper (in @benzene/abstractions-validation, shared by all three adapters),
which checks, in order:
- Per-handler status — if the handler class carries a
@validationStatus('...')decorator, that status is used. - Default — otherwise
BenzeneResultStatus.validationError.
Override the status for a specific handler with the @validationStatus decorator:
import { validationStatus } from '@benzene/abstractions-validation';
import { BenzeneResultStatus } from '@benzene/results';
@validationStatus(BenzeneResultStatus.forbidden)
@message('widget:create', { requestType: CreateWidget, responseType: WidgetCreated })
export class CreateWidgetHandler implements IMessageHandler<CreateWidget, WidgetCreated> {
// A validation failure on CreateWidget now returns `forbidden` instead of `validation-error`.
}
To use a custom mapper, register your own IValidationStatusMapper before calling
use<Lib>Validation(...) — registration uses tryAddSingleton, so the first one wins.
Port note. FluentValidation's per-rule
.WithStatus(...)override (aValidationFailure.CustomStatecarrying a status) is a FluentValidation-specific feature with no schema-library-neutral equivalent, so it is not ported. Only the handler-level@validationStatusbranch and the default branch exist. Set a rule-specific outcome inside your schema instead (e.g. a Zod refinement), or use@validationStatusfor a per-handler status.
Client-side validation
Each adapter also ships a ValidationClientMiddleware<TRequest, TResponse> that applies the same
"resolve schema → validate → short-circuit on failure" behavior to outgoing Benzene client calls
(IBenzeneClientContext<TRequest, TResponse>) rather than incoming handler requests. On the client side
there is no handler to recover the request type from, so the schema is resolved from the outgoing
message instance's own constructor against the registry:
import { ValidationClientMiddleware } from '@benzene/zod';
const middleware = new ValidationClientMiddleware<CreateWidget, WidgetCreated>();
Client-side failures always map to BenzeneResultStatus.validationError — the per-handler
@validationStatus mapping applies only to the handler-side ValidationMiddleware, matching the .NET
client middleware. There is no use<Lib>ValidationClient free function yet: the port has no pipeline
builder that consumes an IBenzeneClientContextMiddlewareBuilder, so ValidationClientMiddlewareBuilder
is exported for manual wiring, and the use... helper will be added once a client router exposes it.
Choosing (and the behavioral differences)
The three adapters are interchangeable at the Benzene seam — the difference is the validator you already know and the shape of its API:
| Zod | Joi | Yup | |
|---|---|---|---|
| Schema builder | z.object({ ... }) |
Joi.object({ ... }) |
yup.object({ ... }) |
| Validation call | schema.safeParse (sync) |
schema.validate(..., { abortEarly: false }) (sync) |
await schema.validate(..., { abortEarly: false }) (async) |
| Reports failure by | returning { success: false, error } |
returning { error } |
throwing a ValidationError |
| Messages source | error.issues[].message |
error.details[].message |
error.errors (string[]) |
The adapters normalize all of this: whichever you pick, a failure becomes a validation-error result
carrying one message per issue, and the handler is skipped. Pick the library your team already uses; if
you have no preference, Zod's static type inference pairs most naturally with the port's typed schema
registry.
Not ported (yet)
- Schema / OpenAPI generation via
IValidationSchemaBuilder.Benzene.FluentValidation'sIValidationSchemaBuilder(FluentValidationSchemaBuilder) has only its abstraction ported (IValidationSchemaBuilderand theIValidationSchemafamily live in@benzene/abstractions-validation); no adapter ships a concrete builder for that specific shape yet. Schema generation itself is delivered through a different seam, though: the Zod / Joi / Yup adapters each ship a concreteITypeJsonSchemaSource(ZodJsonSchemaSource/JoiJsonSchemaSource/YupJsonSchemaSource) that converts the validation schema — rules included (minLength/maxLength/pattern/enum/format) — to JSON Schema, which@benzene/schema-openapi'suseSpecpublishes. So a validated handler's payload schema is documented automatically; see Serialization and@benzene/schema-openapi. - Custom string rule extensions. FluentValidation's
Benzene.FluentValidation.Commonhelpers (IsGuid(),IsOneOf(...),IsJson(), ...) are not re-created — Zod, Joi, and Yup each provide these as native schema methods/refinements, so use the validator's own idioms. - DataAnnotations-style attribute validation. The .NET
Benzene.DataAnnotations"always validate the request object's attributes, no registration" model has no direct analog; declare a schema and register it instead.
See also
- Message Handlers — where validation middleware sits in the request lifecycle, and
the
useMessageHandlersWithRouterseam it plugs into. - Message Results —
IBenzeneResultOf<T>, thevalidation-errorstatus, and how it maps onto transport responses (HTTP422, queue nack, ...). - Middleware — the pipeline mechanism the per-handler validation middleware runs inside.
- Getting Started — build and run a first Benzene service end to end.
- README Porting conventions — the "third-party integrations are adapted, not reimplemented" rule these adapters follow.