Node.js API Validation: Zod and Joi Schemas

Input validation is non-negotiable for production APIs. Without proper validation, your application becomes vulnerable to injection attacks, data corruption, and runtime errors that crash your...

Key Insights

  • Schema-based validation libraries like Zod and Joi eliminate brittle manual validation code and provide type-safe contracts for your API endpoints
  • Joi offers a mature, feature-rich API with excellent documentation, while Zod provides superior TypeScript integration with automatic type inference from schemas
  • Choose Zod for greenfield TypeScript projects where type safety is paramount, and Joi for JavaScript projects or teams prioritizing stability and extensive validation features

Introduction to API Validation

Input validation is non-negotiable for production APIs. Without proper validation, your application becomes vulnerable to injection attacks, data corruption, and runtime errors that crash your server. Manual validation with if-statements and regex patterns quickly becomes unmaintainable as your API grows.

Schema-based validation libraries solve this by letting you define the shape and constraints of your data once, then reuse those definitions across your application. They provide comprehensive error messages, type coercion, and sanitization out of the box. More importantly, they enforce a contract between your API and its consumers, making your endpoints predictable and self-documenting.

Both Zod and Joi excel at this, but they take different philosophical approaches. Understanding these differences will help you choose the right tool for your project.

Joi Fundamentals

Joi has been the go-to validation library for Node.js since 2013. It provides a fluent, chainable API for building validation schemas with extensive built-in validators.

Here’s basic validation with Joi:

const Joi = require('joi');

const userSchema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(18).max(120),
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().min(8).required()
});

const userData = {
  email: 'user@example.com',
  age: 25,
  username: 'john_doe',
  password: 'secret123'
};

const { error, value } = userSchema.validate(userData);

if (error) {
  console.log(error.details);
} else {
  console.log('Valid user:', value);
}

Joi’s strength lies in its extensive validation options. You can customize error messages and validation behavior:

const registrationSchema = Joi.object({
  email: Joi.string()
    .email()
    .required()
    .messages({
      'string.email': 'Please provide a valid email address',
      'any.required': 'Email is required'
    }),
  
  profile: Joi.object({
    firstName: Joi.string().required(),
    lastName: Joi.string().required(),
    bio: Joi.string().max(500).optional(),
    socialLinks: Joi.object({
      twitter: Joi.string().uri().optional(),
      github: Joi.string().uri().optional()
    })
  }).required(),
  
  preferences: Joi.object({
    newsletter: Joi.boolean().default(false),
    notifications: Joi.string().valid('all', 'important', 'none').default('important')
  })
});

Joi automatically strips unknown properties by default, which is a security best practice. You can configure this behavior with options like allowUnknown or stripUnknown.

Zod Fundamentals

Zod takes a TypeScript-first approach. Every schema automatically generates a TypeScript type, eliminating the need to maintain separate validation logic and type definitions.

Here’s the same user validation in Zod:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(18).max(120).optional(),
  username: z.string().min(3).max(30),
  password: z.string().min(8)
});

// Automatically infer the TypeScript type
type User = z.infer<typeof userSchema>;

// User type is now:
// {
//   email: string;
//   age?: number;
//   username: string;
//   password: string;
// }

const userData = {
  email: 'user@example.com',
  age: 25,
  username: 'john_doe',
  password: 'secret123'
};

// parse() throws on validation error
const validUser = userSchema.parse(userData);

// safeParse() returns a result object
const result = userSchema.safeParse(userData);
if (result.success) {
  console.log('Valid user:', result.data);
} else {
  console.log('Errors:', result.error.issues);
}

Zod excels at complex type scenarios like discriminated unions and array validation:

const apiResponseSchema = z.discriminatedUnion('status', [
  z.object({
    status: z.literal('success'),
    data: z.object({
      id: z.string(),
      name: z.string()
    })
  }),
  z.object({
    status: z.literal('error'),
    message: z.string(),
    code: z.number()
  })
]);

const productSchema = z.object({
  name: z.string(),
  tags: z.array(z.string()).min(1).max(5),
  price: z.number().positive(),
  category: z.enum(['electronics', 'clothing', 'food'])
});

Express Middleware Integration

Both libraries integrate seamlessly with Express through custom middleware. Here’s a production-ready Joi middleware:

const createJoiValidator = (schema, property = 'body') => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req[property], {
      abortEarly: false,
      stripUnknown: true
    });
    
    if (error) {
      const errors = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message
      }));
      
      return res.status(400).json({
        status: 'error',
        errors
      });
    }
    
    // Replace request property with validated/sanitized value
    req[property] = value;
    next();
  };
};

// Usage in routes
const express = require('express');
const router = express.Router();

router.post('/users', 
  createJoiValidator(registrationSchema),
  (req, res) => {
    // req.body is now validated and sanitized
    const user = req.body;
    // ... create user logic
    res.json({ status: 'success', user });
  }
);

The equivalent Zod middleware with TypeScript support:

import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';

const validateZod = (schema: AnyZodObject, property: 'body' | 'query' | 'params' = 'body') => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      req[property] = await schema.parseAsync(req[property]);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        const errors = error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message
        }));
        
        return res.status(400).json({
          status: 'error',
          errors
        });
      }
      next(error);
    }
  };
};

// Usage with type safety
router.post('/users',
  validateZod(userSchema),
  (req, res) => {
    // TypeScript knows the exact shape of req.body
    const user = req.body; // Type: User
    res.json({ status: 'success', user });
  }
);

You can also validate multiple request properties simultaneously:

const validateRequest = (schemas: {
  body?: AnyZodObject;
  query?: AnyZodObject;
  params?: AnyZodObject;
}) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schemas.body) req.body = await schemas.body.parseAsync(req.body);
      if (schemas.query) req.query = await schemas.query.parseAsync(req.query);
      if (schemas.params) req.params = await schemas.params.parseAsync(req.params);
      next();
    } catch (error) {
      // ... error handling
    }
  };
};

Advanced Patterns and Comparison

Both libraries support conditional validation, but with different approaches. Joi uses the .when() method:

const orderSchema = Joi.object({
  type: Joi.string().valid('pickup', 'delivery').required(),
  address: Joi.string().when('type', {
    is: 'delivery',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  }),
  pickupTime: Joi.date().when('type', {
    is: 'pickup',
    then: Joi.required(),
    otherwise: Joi.forbidden()
  })
});

Zod achieves this through refinements and discriminated unions:

const baseOrderSchema = z.object({
  type: z.enum(['pickup', 'delivery'])
});

const orderSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('pickup'),
    pickupTime: z.date()
  }),
  z.object({
    type: z.literal('delivery'),
    address: z.string()
  })
]);

Schema composition and reusability are crucial for large applications:

// Zod composition
const timestampSchema = z.object({
  createdAt: z.date(),
  updatedAt: z.date()
});

const userBaseSchema = z.object({
  email: z.string().email(),
  username: z.string()
});

const userWithTimestamps = userBaseSchema.merge(timestampSchema);

// Transform data during validation
const normalizedUserSchema = z.object({
  email: z.string().email().transform(val => val.toLowerCase()),
  age: z.string().transform(val => parseInt(val, 10))
});
// Joi composition
const timestampSchema = Joi.object({
  createdAt: Joi.date().required(),
  updatedAt: Joi.date().required()
});

const userWithTimestamps = userBaseSchema.concat(timestampSchema);

// Custom validators
const passwordSchema = Joi.string().custom((value, helpers) => {
  if (!/[A-Z]/.test(value)) {
    return helpers.error('password.uppercase');
  }
  if (!/[0-9]/.test(value)) {
    return helpers.error('password.number');
  }
  return value;
});

Best Practices and Recommendations

Choose Zod if you’re building a TypeScript application and want compile-time type safety. The automatic type inference eliminates an entire class of bugs where validation and types drift apart. Zod’s smaller bundle size (8KB vs Joi’s 146KB) also matters for frontend validation or serverless functions.

Choose Joi if you’re working with JavaScript, need battle-tested stability, or require advanced validation features like external references and conditional schemas. Joi’s documentation and ecosystem are more mature, and it has better support for complex validation scenarios.

Here’s a side-by-side comparison of the same validation:

// Zod
const productSchema = z.object({
  name: z.string().min(1).max(100),
  price: z.number().positive(),
  inStock: z.boolean(),
  tags: z.array(z.string()).optional()
});

type Product = z.infer<typeof productSchema>;
// Joi
const productSchema = Joi.object({
  name: Joi.string().min(1).max(100).required(),
  price: Joi.number().positive().required(),
  inStock: Joi.boolean().required(),
  tags: Joi.array().items(Joi.string()).optional()
});

// TypeScript types must be defined separately
interface Product {
  name: string;
  price: number;
  inStock: boolean;
  tags?: string[];
}

For migration between libraries, start by identifying your most critical endpoints and converting them incrementally. Both libraries can coexist in the same project, allowing gradual migration without a risky big-bang rewrite.

Validate early and fail fast. Run validation middleware before any business logic executes. Always sanitize data even after validation—strip unknown properties and normalize inputs. Log validation failures for monitoring and security analysis, but never expose internal validation logic in error messages sent to clients.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.