Validation with Zod

MoroJS uses Zod for runtime validation and TypeScript type inference. Learn how to validate requests, responses, and create type-safe APIs.

Why Zod?

Zod is a TypeScript-first schema validation library that provides:

  • Runtime validation with compile-time type inference
  • Zero dependencies and excellent TypeScript support
  • Composable and reusable schemas
  • Clear error messages and validation feedback

Basic Validation

Request Body Validation

typescript

1import { createApp } from '@morojs/moro';
2import { z } from 'zod';
3
4const app = createApp();
5
6// Define a schema
7const CreateUserSchema = z.object({
8  name: z.string().min(1, "Name is required"),
9  email: z.string().email("Invalid email format"),
10  age: z.number().min(18, "Must be at least 18 years old")
11});
12
13// Use in route
14app.post('/users', {
15  body: CreateUserSchema,
16  handler: ({ body }) => {
17    // body is automatically typed and validated
18    // body: { name: string, email: string, age: number }
19    
20    console.log(`Creating user: ${body.name}`);
21    return { success: true, user: body };
22  }
23});

Parameter Validation

typescript

1const UserIdSchema = z.object({
2  id: z.string().uuid("Invalid user ID format")
3});
4
5app.get('/users/:id', {
6  params: UserIdSchema,
7  handler: ({ params }) => {
8    // params.id is validated as a UUID
9    return { userId: params.id };
10  }
11});

Query Parameter Validation

typescript

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  }
20});

Advanced Schema Patterns

Nested Objects and Arrays

typescript

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

typescript

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

typescript

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});

Error Handling

MoroJS automatically handles validation errors and returns structured error responses:

Automatic Error Responses

json

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

typescript

1app.post('/users', {
2  body: CreateUserSchema,
3  handler: ({ body }) => {
4    try {
5      // Your logic here
6      return { success: true };
7    } catch (error) {
8      return {
9        status: 400,
10        body: {
11          error: 'BUSINESS_LOGIC_ERROR',
12          message: error.message
13        }
14      };
15    }
16  },
17  
18  // Custom validation error handler
19  onValidationError: (errors) => {
20    return {
21      status: 422,
22      body: {
23        error: 'CUSTOM_VALIDATION_ERROR',
24        message: 'Your request is invalid',
25        fields: errors.map(err => ({
26          field: err.path.join('.'),
27          issue: err.message
28        }))
29      }
30    };
31  }
32});

Schema Composition and Reuse

Reusable Base Schemas

typescript

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

typescript

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  }
22});

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