Skip to main content

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.