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.

DTO classes without supported validation decorators are ignored, so validation is never forced for undecorated route contracts.

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 transport-level failure shape remains:

{
__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'

sendMessage(...) and sendToTab(...) now reject their promise when the response payload has a validation code:

  • HEXA_VALIDATION_FAILED
  • HEXA_RESPONSE_VALIDATION_FAILED

The client maps the internal transport payload to HexaRemoteError, so your catch blocks use standard error fields:

  • error.message
  • error.code
  • error.details

Other structured Hexa payloads (for example HEXA_BOUNDARY_POLICY_DENIED) are still returned as resolved responses.

Promise.catch(...) example:

import { HexaRemoteError } from '@hexajs-dev/core';

contentClient
.sendMessage(configApi.Get, { requestedAt: 'not-a-number' })
.catch((error: unknown) => {
if (error instanceof HexaRemoteError) {
console.error(error.code, error.message, error.details);
return;
}

throw error;
});

async/await example:

import { HexaRemoteError } from '@hexajs-dev/core';

try {
await contentClient.sendMessage(configApi.Get, { requestedAt: 'not-a-number' });
} catch (error: unknown) {
if (error instanceof HexaRemoteError) {
console.error(error.code, error.message, error.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.
  • Use try/catch (or .catch) around validation-sensitive calls and narrow errors with instanceof HexaRemoteError.

Limits to know​

  • Validation is route-driven: no route, no generated validator.
  • Validation is decorator-driven: DTOs without supported decorators do not get generated validators.
  • 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.

@Equals​

import { Equals } from '@hexajs-dev/common';
@Equals(value: any)

@IsArray​

import { IsArray } from '@hexajs-dev/common';
@IsArray()

@IsBoolean​

import { IsBoolean } from '@hexajs-dev/common';
@IsBoolean()

@IsDateString​

import { IsDateString } from '@hexajs-dev/common';
@IsDateString()

@IsDefined​

Validation Decorators These are pure metadata decorators—they do NOT enforce validation at runtime. Actual validation is performed by AOT-generated validators at compile time.

import { IsDefined } from '@hexajs-dev/common';
@IsDefined()

@IsEmail​

import { IsEmail } from '@hexajs-dev/common';
@IsEmail()

@IsEmpty​

import { IsEmpty } from '@hexajs-dev/common';
@IsEmpty()

@IsEnum​

import { IsEnum } from '@hexajs-dev/common';
@IsEnum(enumClass: any)

@IsIn​

import { IsIn } from '@hexajs-dev/common';
@IsIn(values: any[])

@IsInt​

import { IsInt } from '@hexajs-dev/common';
@IsInt()

@IsJSON​

import { IsJSON } from '@hexajs-dev/common';
@IsJSON()

@IsLowercase​

import { IsLowercase } from '@hexajs-dev/common';
@IsLowercase()

@IsNegative​

import { IsNegative } from '@hexajs-dev/common';
@IsNegative()

@IsNotEmpty​

import { IsNotEmpty } from '@hexajs-dev/common';
@IsNotEmpty()

@IsNotIn​

import { IsNotIn } from '@hexajs-dev/common';
@IsNotIn(values: any[])

@IsNumber​

import { IsNumber } from '@hexajs-dev/common';
@IsNumber()

@IsObject​

import { IsObject } from '@hexajs-dev/common';
@IsObject()

@IsOptional​

import { IsOptional } from '@hexajs-dev/common';
@IsOptional()

@IsPositive​

import { IsPositive } from '@hexajs-dev/common';
@IsPositive()

@IsString​

import { IsString } from '@hexajs-dev/common';
@IsString()

@IsUppercase​

import { IsUppercase } from '@hexajs-dev/common';
@IsUppercase()

@IsUrl​

import { IsUrl } from '@hexajs-dev/common';
@IsUrl()

@IsUUID​

import { IsUUID } from '@hexajs-dev/common';
@IsUUID()

@Length​

import { Length } from '@hexajs-dev/common';
@Length(min: number, max: number)

@Matches​

import { Matches } from '@hexajs-dev/common';
@Matches(pattern: RegExp | string)

@Max​

import { Max } from '@hexajs-dev/common';
@Max(value: number)

@MaxLength​

import { MaxLength } from '@hexajs-dev/common';
@MaxLength(max: number)

@Min​

import { Min } from '@hexajs-dev/common';
@Min(value: number)

@MinLength​

import { MinLength } from '@hexajs-dev/common';
@MinLength(min: number)

@NotEquals​

import { NotEquals } from '@hexajs-dev/common';
@NotEquals(value: any)

@ValidateNested​

import { ValidateNested } from '@hexajs-dev/common';
@ValidateNested()

Functions​

EmailField​

import { EmailField } from '@hexajs-dev/common';
function EmailField(): () => (target: any, propertyKey: string | symbol | undefined) => void

Constants​

BooleanField​

import { BooleanField } from '@hexajs-dev/common';
const BooleanField: () => (target: any, propertyKey: string | symbol | undefined) => void;

NumberField​

import { NumberField } from '@hexajs-dev/common';
const NumberField: () => (target: any, propertyKey: string | symbol | undefined) => void;

OptionalField​

import { OptionalField } from '@hexajs-dev/common';
const OptionalField: () => (target: any, propertyKey: string | symbol | undefined) => void;

RequiredField​

import { RequiredField } from '@hexajs-dev/common';
const RequiredField: () => (target: any, propertyKey: string | symbol | undefined) => void;

StringField​

import { StringField } from '@hexajs-dev/common';
const StringField: () => (target: any, propertyKey: string | symbol | undefined) => void;