Documentation/titan/Validation Error Handling
In this section

Validation Error Handling

When validation fails, the framework throws a ValidationError. The client receives the same class with the same payload over the wire. This page covers what's in the error and how to customise the client-facing shape.

Default error shape

The canonical ValidationError (from @omnitron-dev/titan/errors) extends TitanError, so it carries code / httpStatus / details like any other. The validation issues live in details.errors, and each issue's location is a dotted-string path:

{
  name:       'ValidationError',
  code:       422,                       // ErrorCode.VALIDATION_ERROR
  httpStatus: 422,
  message:    'Validation failed',
  details: {
    errors: [
      { path: 'email', message: 'Invalid email address', code: 'invalid_format' },
      { path: 'age',   message: 'Too small: expected number to be >=13', code: 'too_small' },
    ],
  },
  timestamp:  1747339200000,
}

The lightweight ValidationError from @omnitron-dev/titan/validation (used by ValidationEngine) differs: there, code is the string 'VALIDATION_ERROR' and statusCode is 422, and its toJSON() emits { code, message, errors }. Both expose the issues under an errors array, not fields.

Status code 422 follows REST convention for "syntactically valid but semantically wrong". For requests that fail to parse at all (malformed msgpack, unknown service), the framework uses 400.

On the client side

import { ValidationError } from '@omnitron-dev/titan/errors';

try {
  await users.create(input);
} catch (e) {
  if (e instanceof ValidationError) {
    for (const err of e.validationErrors) {       // also at e.details.errors
      console.log(`field=${err.path}: ${err.message}`);
    }
  }
}

The error is preserved across the wire — Netron serialises the TitanError and rehydrates it as the same class (the hierarchy is shared via @omnitron-dev/netron-protocol), so code, httpStatus, and details survive intact.

Structured field errors

Each entry in validationErrors (mirrored at details.errors):

FieldTypeMeaning
pathstringDotted path to the bad field ('address.zip')
messagestringHuman-readable message (from Zod or override)
codestringZod issue code (invalid_format, too_small)
expectedunknown?What was expected (when applicable)
receivedunknown?What was received (when applicable)

path is built by joining the Zod issue path with . — so nested fields read like 'address.zip'. Frontend forms can use it directly:

catch (e) {
  if (e instanceof ValidationError) {
    e.validationErrors.forEach((err) => {
      form.setFieldError(err.path, err.message);
    });
  }
}

Customising messages

Per-field messages via Zod:

const Schema = z.object({
  email: z.string().email({ message: 'Please enter a valid email address.' }),
  age:   z.number().int().min(13, { message: 'You must be 13 or older.' }),
});

Or globally through a custom error map:

import { z } from '@omnitron-dev/titan/validation';

// zod 4: the map takes ONE argument. A second parameter makes the function
// unassignable to `$ZodErrorMap`, and there is no `ctx.defaultError` — return
// `undefined` to fall through to zod's own message.
z.setErrorMap((issue) => {
  if (issue.code === 'invalid_format' && issue.format === 'email') {
    return { message: 'Bad email.' };
  }
  return undefined;
});

Translating to client formats

The canonical ValidationError has helper methods for common formats and per-field queries:

const e = ...; // ValidationError instance

e.getSimpleFormat();
// { code: 'VALIDATION_ERROR', message, errors: ['Invalid email format', …] }

e.getDetailedFormat();
// { code: 'VALIDATION_ERROR', message, errors: [{ path, message, code, expected?, received? }, …] }

e.hasFieldError('email');   // boolean
e.getFieldErrors('email');  // [{ message, code }, …]

(getSimpleFormat / getDetailedFormat are also the right things to put in an API response body.)

Validation vs domain errors

Two error categories are easy to confuse:

  • ValidationError — the input does not match the schema. Status 422. Client should fix the input.

  • A conflict / domain error — the input matches the schema but cannot be applied (email already taken, order already shipped). Status 409, thrown via Errors.conflict(...) (a TitanError with ErrorCode.CONFLICT) or a DomainError. Client should retry with different input or show a domain message.

Use the right one. Throwing ValidationError for "email already taken" tells the client "your email is malformed", which is wrong.

@Public()
@Validate({ input: CreateUserSchema })
async create(input: z.infer<typeof CreateUserSchema>) {
  const existing = await this.repo.findByEmail(input.email);
  if (existing) throw Errors.conflict('email already registered', { email: input.email });
  // … create user
}

Anti-patterns

  • Catching ValidationError and re-throwing as Error. Strips the structured detail; the client sees an opaque 500.

  • Validating in the method body manually. Defeats the pre-compiled validator's performance gain and duplicates the schema. Use @Validate or @Contract.

  • Returning validation errors as success values. A 200 response with an errors array makes the client check both the HTTP status and the body. Throw the error; let the framework signal failure.

→ Back to Validation Overview.

Rejected HTTP uploads

The native Node HTTP transport returns a correlated 413 before invoking a service when either the declared length or the streamed body exceeds maxRequestSize. It discards the remaining input without retaining its chunks. Closing the socket immediately after sending the refusal can reset an upload still in flight, causing a client to see EPIPE or ECONNRESET instead of the HTTP result. The transport therefore allows the response and request directions to finish before a requested close.

Rejected uploads remain bounded by requestTimeout; if ordinary request execution has explicitly disabled that timeout, the normal 30-second default still bounds disposal of a refused body. A client that never completes the upload cannot keep that rejected request alive indefinitely. Admission limits and service validation remain independent.