Core Concepts

Universal Validation System

Use any validation library you prefer. MoroJS supports Zod, Joi, Yup, class-validator, or custom functions, all with the same consistent API.

Overview

Don't like Zod? Use Joi. Prefer Yup? That works too. Same API, your choice of library.

Zod

TypeScript-first, zero dependencies (default)

Joi

Mature, feature-rich validation

Yup

Simple, lightweight validation

Class Validator

Decorator-based validation

Or write your own custom validation functions.

Don't rewrite your validation logic. Use what you already know. Without universal support, you're locked into one library or forced to rewrite everything.

Without Universal Support

  • Locked into one validation library
  • Rewriting schemas when switching
  • Team members forced to learn new syntax
  • No flexibility for different use cases

With MoroJS

  • Use any validation library you prefer
  • Same consistent API across all libraries
  • Team uses what they already know
  • Mix and match as needed
Zod (default) - works directly
1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4app.post('/users')
5  .body(z.object({
6    name: z.string().min(1),
7    email: z.string().email(),
8    age: z.number().min(18)
9  }))
10  .handler(({ body }) => {
11    return { success: true, user: body };
12  }));

Flexible

Use the validation library your team knows best. No forced migrations.

Consistent

Same API regardless of library. Learn once, use everywhere.

Powerful

Mix libraries or write custom validators. Full control.

How It Works

MoroJS uses universal adapters to support any validation library. Define your schema using your preferred library's syntax, and MoroJS handles the rest: validation, error handling, and type inference.

Validation Libraries

Zod (Default) - Works Directly
1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4app.post('/users')
5  .validate({
6    body: z.object({
7      name: z.string().min(1),
8      email: z.string().email(),
9      age: z.number().min(18)
10    })
11  })
12  .handler((req) => {
13    const body = req.body;
14    return { success: true, user: body };
15  });
Joi - Via Adapter
1import { createApp } from '@morojs/moro';
2import { joi } from '@morojs/moro';
3import Joi from 'joi';
4
5app.post('/users')
6  .validate({
7    body: joi(Joi.object({
8      name: Joi.string().required(),
9      email: Joi.string().email().required(),
10      age: Joi.number().min(18)
11    }))
12  })
13  .handler((req) => {
14    return { success: true, user: req.body };
15  });
Yup - Via Adapter
1import { createApp } from '@morojs/moro';
2import { yup } from '@morojs/moro';
3import * as yupLib from 'yup';
4
5app.post('/users')
6  .validate({
7    body: yup(yupLib.object({
8      name: yupLib.string().required(),
9      email: yupLib.string().email().required(),
10      age: yupLib.number().min(18)
11    }))
12  })
13  .handler((req) => {
14    return { success: true, user: req.body };
15  });
Class Validator - Via Adapter
1import { createApp } from '@morojs/moro';
2import { classValidator } from '@morojs/moro';
3import { IsString, IsEmail, IsNumber, Min } from 'class-validator';
4
5class CreateUserDto {
6  @IsString()
7  name!: string;
8
9  @IsEmail()
10  email!: string;
11
12  @IsNumber()
13  @Min(18)
14  age!: number;
15}
16
17app.post('/users')
18  .validate({ body: classValidator(CreateUserDto) })
19  .handler((req) => {
20    return { success: true, user: req.body };
21  });
Custom Validation Functions
1import { createApp } from '@morojs/moro';
2import { customValidator } from '@morojs/moro';
3
4const validateUser = customValidator(async (data: any) => {
5  if (!data.name || typeof data.name !== 'string') {
6    throw new Error('Name is required and must be a string');
7  }
8  if (!data.email || !data.email.includes('@')) {
9    throw new Error('Valid email is required');
10  }
11  return {
12    name: data.name.trim(),
13    email: data.email.toLowerCase(),
14    validated: true
15  };
16}, 'user-validator');
17
18app.post('/users')
19  .body(validateUser)
20  .handler(({ body }) => {
21    return { success: true, user: body };
22  }));
Query Parameter Validation
1const SearchQuerySchema = z.object({
2  q: z.string().min(1, "Search query is required"),
3  limit: z.coerce.number().min(1).max(100).default(10),
4  page: z.coerce.number().min(1).default(1)
5});
6
7app.get('/search')
8  .query(SearchQuerySchema)
9  .handler(({ query }) => {
10    // All query params are validated and have proper types
11    // query: { q: string, limit: number, page: number }
12
13    return {
14      query: query.q,
15      limit: query.limit,
16      page: query.page,
17      results: []
18    };
19  }));

Advanced Patterns

Nested Objects and Arrays
1const AddressSchema = z.object({
2  street: z.string(),
3  city: z.string(),
4  country: z.string(),
5  postalCode: z.string().regex(/^\d{5}$/, "Invalid postal code")
6});
7
8const CreateUserSchema = z.object({
9  name: z.string().min(1),
10  email: z.string().email(),
11  addresses: z.array(AddressSchema).min(1, "At least one address required"),
12  tags: z.array(z.string()).optional(),
13  preferences: z.object({
14    newsletter: z.boolean().default(false),
15    theme: z.enum(['light', 'dark']).default('light')
16  }).optional()
17});
Conditional Validation
1const PaymentSchema = z.discriminatedUnion('type', [
2  z.object({
3    type: z.literal('credit_card'),
4    cardNumber: z.string().regex(/^\d{16}$/, "Invalid card number"),
5    expiryDate: z.string().regex(/^\d{2}\/\d{2}$/, "Invalid expiry date"),
6    cvv: z.string().regex(/^\d{3,4}$/, "Invalid CVV")
7  }),
8  z.object({
9    type: z.literal('bank_transfer'),
10    accountNumber: z.string(),
11    routingNumber: z.string()
12  }),
13  z.object({
14    type: z.literal('paypal'),
15    paypalEmail: z.string().email()
16  })
17]);
Custom Validation with Refinements
1const PasswordSchema = z.string()
2  .min(8, "Password must be at least 8 characters")
3  .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
4  .regex(/[a-z]/, "Password must contain at least one lowercase letter")
5  .regex(/\d/, "Password must contain at least one number")
6  .regex(/[^\w\s]/, "Password must contain at least one special character");
7
8const RegisterSchema = z.object({
9  email: z.string().email(),
10  password: PasswordSchema,
11  confirmPassword: z.string()
12}).refine(data => data.password === data.confirmPassword, {
13  message: "Passwords don't match",
14  path: ["confirmPassword"]
15});
Schema Composition and Reuse
1// Base schemas
2const BaseEntitySchema = z.object({
3  id: z.string().uuid(),
4  createdAt: z.string().datetime(),
5  updatedAt: z.string().datetime()
6});
7
8const PaginationSchema = z.object({
9  page: z.coerce.number().min(1).default(1),
10  limit: z.coerce.number().min(1).max(100).default(20)
11});
12
13// Composed schemas
14const UserSchema = BaseEntitySchema.extend({
15  name: z.string(),
16  email: z.string().email(),
17  role: z.enum(['user', 'admin'])
18});
19
20const CreateUserSchema = UserSchema.omit({
21  id: true,
22  createdAt: true,
23  updatedAt: true
24});
25
26const UpdateUserSchema = CreateUserSchema.partial();
27
28const ListUsersQuerySchema = PaginationSchema.extend({
29  role: z.enum(['user', 'admin']).optional(),
30  search: z.string().optional()
31});
Schema Transformations
1const ProcessedUserSchema = z.object({
2  name: z.string().transform(val => val.trim().toLowerCase()),
3  email: z.string().email().transform(val => val.toLowerCase()),
4  age: z.coerce.number(), // Automatically converts strings to numbers
5  tags: z.string().transform(val => val.split(',').map(s => s.trim())),
6  isActive: z.union([z.boolean(), z.string()])
7    .transform(val => val === true || val === 'true')
8});
9
10// Usage
11app.post('/users')
12  .body(ProcessedUserSchema)
13  .handler(({ body }) => {
14    // body.name is trimmed and lowercase
15    // body.email is lowercase
16    // body.age is a number
17    // body.tags is an array of strings
18    // body.isActive is a boolean
19
20    return { user: body };
21  }));

Error Handling

MoroJS automatically handles validation errors and returns structured error responses.

Automatic Error Responses
1// When validation fails, MoroJS automatically returns:
2{
3  "error": "VALIDATION_ERROR",
4  "message": "Request validation failed",
5  "details": [
6    {
7      "field": "email",
8      "message": "Invalid email format"
9    },
10    {
11      "field": "age",
12      "message": "Must be at least 18 years old"
13    }
14  ]
15}
Custom Error Handling
1// Pass schemas and onValidationError together in one .validate() call.
2app.post('/users')
3  .validate({
4    body: CreateUserSchema,
5
6    // Shapes the 4xx the client sees when a schema rejects the request
7    onValidationError: (errors) => ({
8      status: 422,
9      body: {
10        error: 'CUSTOM_VALIDATION_ERROR',
11        message: 'Your request is invalid',
12        fields: errors.map(err => ({
13          field: err.field,
14          issue: err.message
15        }))
16      }
17    })
18  })
19  .handler((req) => {
20    try {
21      // Your logic here
22      return { success: true };
23    } catch (error) {
24      return {
25        status: 400,
26        body: {
27          error: 'BUSINESS_LOGIC_ERROR',
28          message: error.message
29        }
30      };
31    }
32  });

Best Practices

Do

  • Use descriptive error messages
  • Create reusable base schemas
  • Validate all external inputs
  • Use transformations for data cleaning
  • Define response schemas for documentation
  • Use z.infer for type extraction

Don't

  • Skip validation for "trusted" inputs
  • Use overly complex nested schemas
  • Ignore validation error details
  • Validate the same data multiple times
  • Use any types instead of proper schemas
  • Forget to handle edge cases

Next Steps