Core Concepts
Validation Errors
Control exactly how validation failures are formatted and returned to the client. Every validation library in MoroJS normalizes to a single shape, so you write one error handler and it works everywhere.
How Error Handling Works
When a validation schema rejects a request, MoroJS runs the failures through a single pipeline:
- Normalize: raw errors from Zod, Joi, Yup, class-validator, or custom functions are converted to a shared
ValidationErrorDetailType[]. - Resolve handler: the per-route
onValidationErrorwins if present, then the global handler passed tocreateApp(), then the built-in default. - Respond: the handler returns a
ValidationErrorResponse({ status, body, headers? }) which MoroJS writes to the client.
Because the errors array is always the same shape, you never have to branch on which validation library produced a given failure.
Default Response Format
With no custom handler, MoroJS returns a 400 Bad Request with a consistent JSON body listing each failing field.
1{
2 "success": false,
3 "error": "Validation failed for body",
4 "details": [
5 { "field": "email", "message": "Invalid email", "code": "invalid_string" },
6 { "field": "age", "message": "Expected number, received string" }
7 ],
8 "requestId": "..."
9}The error string names which part of the request failed (body, query, params, or headers). The details array is omitted when NODE_ENV=production to avoid leaking schema internals.
Per-Route Custom Handler
Pass onValidationError on any route to override the response shape for that route only.
1import type { ValidationErrorHandler } from '@morojs/moro';
2
3const rfc7807: ValidationErrorHandler = (errors, ctx) => ({
4 status: 422,
5 headers: { 'Content-Type': 'application/problem+json' },
6 body: {
7 type: 'https://example.com/errors/validation',
8 title: 'Validation Failed',
9 status: 422,
10 instance: ctx.request.url,
11 field: ctx.field,
12 errors: errors.map(e => ({ field: e.field, message: e.message, code: e.code })),
13 },
14});
15
16// Pass onValidationError via .validate() — the route builder
17// takes a ValidationConfig, not a separate chainable method.
18app.post('/users')
19 .validate({ body: CreateUserSchema, onValidationError: rfc7807 })
20 .handler(createUser);1// In defineModule(), set onValidationError on the route object:
2defineModule({
3 name: 'users',
4 version: '1.0.0',
5 routes: [
6 {
7 method: 'POST',
8 path: '/users',
9 validation: { body: CreateUserSchema },
10 onValidationError: rfc7807,
11 handler: createUser,
12 },
13 ],
14});What you get in the handler
- •
errors: normalized failure list, always the same shape regardless of validation library - •
context.field: which request part failed (body,query, etc.) - •
context.request:method,url,path,headers - •
context.route: matched route method/path when available
Global Handler
Set one handler at app construction and every unvoiced route inherits it. Individual routes can still override with onValidationError.
1import { createApp, type ValidationErrorHandler } from '@morojs/moro';
2
3const handler: ValidationErrorHandler = (errors, ctx) => ({
4 status: 422,
5 body: {
6 ok: false,
7 path: ctx.request.path,
8 in: ctx.field,
9 errors: errors.map(e => ({ field: e.field, reason: e.message })),
10 },
11});
12
13// Note: the key on createApp() is 'onError', not 'onValidationError'.
14// createApp() is async — it must be awaited.
15const app = await createApp({
16 validation: { onError: handler },
17});Resolution Order
- Per-route
onValidationError - Global handler from
createApp({ validation }) - Built-in default (400 + normalized body)
Type Reference
1type ValidationErrorHandler = (
2 errors: ValidationErrorDetailType[],
3 context: ValidationErrorContext,
4) => ValidationErrorResponse;1interface ValidationErrorDetailType {
2 field: string;
3 message: string;
4 code?: string;
5 value?: unknown;
6 path?: (string | number)[];
7}1interface ValidationErrorContext {
2 request: { method: string; url: string; path: string; headers: Record<string, any> };
3 route?: { method: string; path: string };
4 field: 'params' | 'query' | 'body' | 'headers';
5}1interface ValidationErrorResponse {
2 status: number;
3 body: any;
4 headers?: Record<string, string>;
5}