Skip to main content

Validation Pipes

HexaJS can validate routed message payloads and responses without a runtime validation library. You define DTO classes with validation decorators, the CLI scans them during build, and the generated bootstrap registers validation pipes for background and content messaging.

What validation pipes do

Validation pipes run in two places for each routed message:

  • Inbound validation: Validates the payload before the controller or handler method runs.
  • Outbound validation: Validates the returned value before it is sent back to the caller.

This gives you route-aware validation for @Action(...) and @Handle(...) methods with no manual validator registration.

DTO example

ClipVault uses DTO classes in src/contract/messages.ts to define route contracts. Keep these classes small, serializable, and explicit about the fields that must be validated.

import { IsBoolean, IsNumber, IsOptional, IsString } from '@hexajs-dev/common';

export class GetConfigMessage {
@IsNumber() requestedAt: number;

constructor(requestedAt: number) {
this.requestedAt = requestedAt;
}
}

export class RemoveClipMessage {
@IsString() clipId: string;

constructor(clipId: string) {
this.clipId = clipId;
}
}

export class ClipItem {
@IsString() id: string;
@IsString() text: string;
@IsString() sourceUrl: string;
@IsString() sourceDomain: string;
@IsString() sourceElement: string;
@IsNumber() capturedAt: number;
@IsBoolean() sensitive: boolean;
}

export class GetClipsMessage {
@IsNumber() requestedAt: number;
@IsOptional() @IsString() domain?: string;
}

These decorators are metadata-only. They do not validate by themselves at runtime. Their job is to give the CLI enough information to generate validators ahead of time.

Route example

Content sends a validated request

const configResponse = await this.client.sendMessage<GetConfigMessage, ConfigResponseMessage>(
configApi.Get,
new GetConfigMessage(Date.now())
);

Background receives the payload and returns a validated response

@Controller({ namespace: configNamespace })
export class ClipVaultConfigController {
@Action(ConfigActionsApi.Get)
async onGetConfig(_payload: GetConfigMessage): Promise<ConfigResponseMessage> {
const config = await this.configService.loadConfig();
return new ConfigResponseMessage(config);
}
}

ClipVault also validates other routes such as clipboard:remove:

@Action(ClipboardActionsApi.Remove)
async onRemoveClip(payload: RemoveClipMessage): Promise<ClipsResponseMessage> {
const clips = this.clipboardManager.removeClip(existingClips, payload.clipId);
return new ClipsResponseMessage(clips);
}

In these routes:

  • GetConfigMessage is the inbound DTO for config:get.
  • ConfigResponseMessage is the outbound DTO for config:get.
  • RemoveClipMessage is the inbound DTO for clipboard:remove.
  • ClipsResponseMessage is the outbound DTO for clipboard:remove.

How the pipeline works

1. Decorator scanning

During build, the CLI scans DTO classes and stores property-level validation metadata. It only generates validators for DTOs that are actually referenced by routed controller or handler methods.

2. Route to DTO mapping

For each route, the scanner extracts:

  • The first method parameter type as the inbound DTO.
  • The return type or Promise<T> type as the outbound DTO.

For example:

@Action(ConfigActionsApi.Get)
onGetConfig(payload: GetConfigMessage): ConfigResponseMessage {
return new ConfigResponseMessage(config);
}

This maps:

  • config:get inbound payload -> GetConfigMessage
  • config:get outbound response -> ConfigResponseMessage

3. Validator generation

The build emits context-specific validator modules:

  • background.validators.js
  • content.validators.js

Each file contains:

  • Generated validator functions for each used DTO
  • A route map for inbound payload validation
  • A route map for outbound response validation
  • Factory functions: createAotValidationPipe() and createAotOutboundValidationPipe()

The generated route map looks like this:

const routeValidators = {
'config:get': validateGetConfigMessage,
'clipboard:remove': validateRemoveClipMessage,
};

const routeResponseValidators = {
'config:get': validateResponseConfigResponseMessage,
'clipboard:remove': validateResponseClipsResponseMessage,
};

4. Bootstrap registration

The generated background and content bootstraps automatically wire the pipes into dedicated pipe runners used by the container:

const pipeRunner = new HexaPipeRunner();
pipeRunner.usePipe(createAotValidationPipe());
pipeRunner.useOutboundPipe(createAotOutboundValidationPipe());
controllerContainer.setPipeRunner(pipeRunner);

Content uses the same pattern with HandlerContainer.

5. Runtime execution order

For a unicast route, the runtime flow is:

Incoming message
-> run inbound validation pipe
-> invoke @Action / @Handle method
-> run outbound validation pipe
-> send response back to caller

If inbound validation fails, your controller or handler method is not executed.

Supported decorator checks

The current generated validator logic enforces these common checks:

  • @IsDefined()
  • @IsOptional()
  • @IsNotEmpty()
  • @IsString()
  • @IsBoolean()
  • @IsNumber()
  • @IsInt()
  • @IsArray()
  • @Min(...)
  • @Max(...)
  • @MinLength(...)
  • @MaxLength(...)
  • @Length(min, max)
  • @Matches(...)
  • @IsEmail()

Other decorators may exist as metadata markers in @hexajs-dev/common, but the generated validator currently enforces the subset above.

Response validation behavior

Outbound validation is strict for object DTOs.

If a response DTO does not declare an index signature, the generated validator also rejects unknown properties:

function validateResponseClipsResponseMessage(data) {
if (!data || typeof data !== 'object') {
return { valid: false, error: 'ClipsResponseMessage response must be an object' };
}

const allowedKeys = new Set(['clips']);
const extraKeys = Object.keys(data).filter(key => !allowedKeys.has(key));
if (extraKeys.length > 0) {
return {
valid: false,
error: 'ClipsResponseMessage response has unknown properties',
details: { extraKeys },
};
}

return { valid: true };
}

That means response DTOs are useful not just for request validation, but also for keeping controller and handler outputs stable over time.

Failure behavior

Inside the pipe system, validation failures become HexaPipeValidationError. The container catches that error and serializes it into a structured message payload.

The current transport-level failure shape is:

{
__hexa_error__: 'message must be a string',
__hexa_code__: 'HEXA_VALIDATION_FAILED',
__hexa_details__: undefined,
}

For outbound failures, the code is typically:

'HEXA_RESPONSE_VALIDATION_FAILED'

Important: today these failures are sent back as structured error payloads by the container. They are not automatically re-thrown on the caller side by sendMessage(...), so callers should inspect the response when they expect validation-sensitive routes.

Example:

const response = await contentClient.sendMessage(configApi.Get, {
requestedAt: 'not-a-number',
});

if (response && typeof response === 'object' && '__hexa_error__' in response) {
console.error(response.__hexa_code__, response.__hexa_error__, response.__hexa_details__);
}

Best practices

  • Use DTO classes for any route whose payload shape matters.
  • Return DTO classes from @Action(...) and @Handle(...) when response shape matters too.
  • Keep DTOs small and serializable.
  • Prefer explicit DTOs over anonymous object types if you want generated validation.
  • Treat response DTOs as part of the route contract, not just a convenience.
  • Check for __hexa_error__ in callers when handling potentially invalid remote input.

Limits to know

  • Validation is route-driven: no route, no generated validator.
  • Only the first method parameter is used as the inbound DTO contract.
  • Primitive return types like string or number do not generate DTO validators.
  • Decorators are compile-time metadata, not runtime enforcement by themselves.