Joi Complete Guide | Powerful Schema Validation for JavaScript

Joi Complete Guide | Powerful Schema Validation for JavaScript

이 글의 핵심

Joi is the most powerful schema description language and data validator for JavaScript. It provides rich validation capabilities with excellent error messages.

Introduction

Joi is the most feature-rich schema validation library for JavaScript. It’s designed for Node.js backends where complex validation logic and detailed error messages are crucial.

Why Joi?

Manual validation:

function validateUser(data) {
  if (!data.email || typeof data.email !== 'string') {
    throw new Error('Invalid email');
  }
  if (!data.age || typeof data.age !== 'number' || data.age < 18) {
    throw new Error('Invalid age');
  }
  // ... dozens more checks
}

With Joi:

const Joi = require('joi');

const schema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(18).required(),
});

const { error, value } = schema.validate(data);

1. Installation

npm install joi

2. Basic Validation

const Joi = require('joi');

// Define schema
const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(18).max(120),
  password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
});

// Validate data
const data = {
  username: 'john_doe',
  email: 'john@example.com',
  age: 25,
  password: 'secret123',
};

const { error, value } = schema.validate(data);

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

3. String Validation

Joi.string()
  .required()
  .min(2)
  .max(100)
  .email()
  .uri()
  .alphanum()
  .lowercase()
  .uppercase()
  .trim()
  .pattern(/^[a-zA-Z]+$/)
  .length(10)
  .creditCard()
  .domain()
  .ip()
  .dataUri()
  .hex()
  .base64()
  .guid() // UUID
  .isoDate();

// Examples
const emailSchema = Joi.string().email().required();
const urlSchema = Joi.string().uri().required();
const uuidSchema = Joi.string().guid({ version: 'uuidv4' });
const phoneSchema = Joi.string().pattern(/^\+?[1-9]\d{1,14}$/);

4. Number Validation

Joi.number()
  .required()
  .integer()
  .min(0)
  .max(100)
  .positive()
  .negative()
  .greater(10)
  .less(100)
  .multiple(5)
  .precision(2)
  .port();

// Examples
const ageSchema = Joi.number().integer().min(18).max(120).required();
const priceSchema = Joi.number().precision(2).positive().required();
const portSchema = Joi.number().port();

5. Object Validation

const userSchema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().email().required(),
  address: Joi.object({
    street: Joi.string().required(),
    city: Joi.string().required(),
    zipCode: Joi.string().pattern(/^\d{5}$/),
    country: Joi.string().required(),
  }),
  settings: Joi.object({
    notifications: Joi.boolean().default(true),
    theme: Joi.string().valid('light', 'dark').default('light'),
  }),
});

// Validate
const result = userSchema.validate({
  name: 'Alice',
  email: 'alice@example.com',
  address: {
    street: '123 Main St',
    city: 'New York',
    zipCode: '10001',
    country: 'USA',
  },
});

6. Array Validation

// Array of strings
const tagsSchema = Joi.array()
  .items(Joi.string())
  .min(1)
  .max(5)
  .unique();

// Array of objects
const usersSchema = Joi.array().items(
  Joi.object({
    id: Joi.number().required(),
    name: Joi.string().required(),
    email: Joi.string().email().required(),
  })
);

// Ordered items (tuple)
const coordsSchema = Joi.array().ordered(
  Joi.number().required(), // latitude
  Joi.number().required()  // longitude
);

// Mixed types
const mixedSchema = Joi.array().items(
  Joi.string(),
  Joi.number(),
  Joi.boolean()
);

await tagsSchema.validateAsync(['react', 'typescript', 'nodejs']);

7. Conditional Validation

const schema = Joi.object({
  accountType: Joi.string().valid('personal', 'business').required(),
  
  // Required only for business accounts
  companyName: Joi.string().when('accountType', {
    is: 'business',
    then: Joi.string().required(),
    otherwise: Joi.forbidden(),
  }),
  
  // Multiple conditions
  taxId: Joi.string().when(Joi.object({ 
    accountType: Joi.string().valid('business'),
    country: Joi.string().valid('US'),
  }).unknown(), {
    then: Joi.string().required(),
    otherwise: Joi.optional(),
  }),
  
  country: Joi.string().required(),
});

8. Custom Validation

// Custom validation function
const passwordSchema = Joi.string().custom((value, helpers) => {
  if (!/[A-Z]/.test(value)) {
    return helpers.error('any.invalid');
  }
  if (!/[a-z]/.test(value)) {
    return helpers.error('any.invalid');
  }
  if (!/[0-9]/.test(value)) {
    return helpers.error('any.invalid');
  }
  return value;
}, 'strong password validation');

// With custom error message
const emailSchema = Joi.string()
  .email()
  .custom((value, helpers) => {
    // Block disposable email domains
    const disposableDomains = ['tempmail.com', '10minutemail.com'];
    const domain = value.split('@')[1];
    
    if (disposableDomains.includes(domain)) {
      return helpers.error('string.disposableEmail');
    }
    
    return value;
  })
  .messages({
    'string.disposableEmail': 'Disposable email addresses are not allowed',
  });

9. Async Validation

const usernameSchema = Joi.string().external(async (value) => {
  // Check if username exists in database
  const exists = await db.users.findOne({ username: value });
  
  if (exists) {
    throw new Error('Username already taken');
  }
  
  return value;
});

// Usage
const result = await usernameSchema.validateAsync('john_doe');

10. Express Middleware

const express = require('express');
const Joi = require('joi');

const app = express();
app.use(express.json());

// Validation middleware
function validate(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false,
      stripUnknown: true,
    });
    
    if (error) {
      const errors = error.details.map(detail => ({
        field: detail.path.join('.'),
        message: detail.message,
      }));
      
      return res.status(400).json({ errors });
    }
    
    req.body = value;
    next();
  };
}

// Define schemas
const createUserSchema = Joi.object({
  name: Joi.string().min(2).max(50).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  age: Joi.number().integer().min(18).required(),
});

// Use in route
app.post('/users', validate(createUserSchema), (req, res) => {
  // req.body is validated and sanitized
  res.json({ message: 'User created', user: req.body });
});

11. Advanced Middleware

// Validate multiple sources
function validateRequest(schemas) {
  return (req, res, next) => {
    const errors = [];
    
    // Validate body
    if (schemas.body) {
      const { error } = schemas.body.validate(req.body);
      if (error) errors.push(...error.details);
    }
    
    // Validate query
    if (schemas.query) {
      const { error } = schemas.query.validate(req.query);
      if (error) errors.push(...error.details);
    }
    
    // Validate params
    if (schemas.params) {
      const { error } = schemas.params.validate(req.params);
      if (error) errors.push(...error.details);
    }
    
    if (errors.length > 0) {
      return res.status(400).json({ errors });
    }
    
    next();
  };
}

// Usage
app.get('/users/:id', validateRequest({
  params: Joi.object({
    id: Joi.number().integer().required(),
  }),
  query: Joi.object({
    page: Joi.number().integer().min(1).default(1),
    limit: Joi.number().integer().min(1).max(100).default(10),
  }),
}), (req, res) => {
  res.json({ userId: req.params.id, page: req.query.page });
});

12. Error Handling

const schema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().min(18).required(),
});

const { error } = schema.validate({ email: 'invalid', age: 15 }, {
  abortEarly: false, // Get all errors
});

if (error) {
  console.log(error.details);
  // [
  //   {
  //     message: '"email" must be a valid email',
  //     path: ['email'],
  //     type: 'string.email',
  //     context: { value: 'invalid', label: 'email', key: 'email' }
  //   },
  //   {
  //     message: '"age" must be greater than or equal to 18',
  //     path: ['age'],
  //     type: 'number.min',
  //     context: { limit: 18, value: 15, label: 'age', key: 'age' }
  //   }
  // ]
}

Custom Error Messages

const schema = Joi.object({
  email: Joi.string().email().required().messages({
    'string.email': 'Please provide a valid email address',
    'any.required': 'Email is required',
  }),
  password: Joi.string().min(8).required().messages({
    'string.min': 'Password must be at least 8 characters long',
    'any.required': 'Password is required',
  }),
});

13. Validation Options

const options = {
  abortEarly: false,        // Return all errors
  allowUnknown: true,       // Allow unknown keys
  stripUnknown: true,       // Remove unknown keys
  convert: true,            // Type conversion
  presence: 'required',     // All keys required by default
  noDefaults: false,        // Apply default values
  escapeHtml: true,         // Escape HTML
};

const { error, value } = schema.validate(data, options);

14. Reusable Schemas

// Base schemas
const idSchema = Joi.number().integer().positive();
const emailSchema = Joi.string().email().required();
const timestampSchema = Joi.date().iso();

// Compose larger schemas
const userSchema = Joi.object({
  id: idSchema,
  email: emailSchema,
  createdAt: timestampSchema,
  updatedAt: timestampSchema,
});

// Extend schemas
const adminSchema = userSchema.keys({
  role: Joi.string().valid('admin', 'superadmin').required(),
  permissions: Joi.array().items(Joi.string()).required(),
});

15. Real-World Example: Blog API

const Joi = require('joi');

// Post schema
const postSchema = Joi.object({
  title: Joi.string().min(5).max(200).required(),
  content: Joi.string().min(10).required(),
  excerpt: Joi.string().max(300),
  tags: Joi.array().items(Joi.string()).min(1).max(5).unique(),
  published: Joi.boolean().default(false),
  publishedAt: Joi.date().when('published', {
    is: true,
    then: Joi.required(),
    otherwise: Joi.forbidden(),
  }),
  author: Joi.object({
    id: Joi.number().required(),
    name: Joi.string().required(),
  }).required(),
});

// Comment schema
const commentSchema = Joi.object({
  postId: Joi.number().required(),
  content: Joi.string().min(1).max(1000).required(),
  author: Joi.object({
    name: Joi.string().required(),
    email: Joi.string().email().required(),
  }).required(),
});

// Query schema
const postQuerySchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(10),
  sort: Joi.string().valid('createdAt', 'title', 'views').default('createdAt'),
  order: Joi.string().valid('asc', 'desc').default('desc'),
  tags: Joi.alternatives().try(
    Joi.string(),
    Joi.array().items(Joi.string())
  ),
  published: Joi.boolean(),
});

// Routes
app.post('/posts', validate(postSchema), createPost);
app.get('/posts', validate(postQuerySchema, 'query'), getPosts);
app.post('/posts/:id/comments', validate(commentSchema), createComment);

16. Best Practices

1. Use Strict Mode

const schema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().email().required(),
}).strict(); // Reject unknown keys

2. Sanitize Input

const schema = Joi.object({
  name: Joi.string().trim().lowercase(),
  email: Joi.string().email().lowercase().trim(),
});

3. Provide Clear Error Messages

const schema = Joi.object({
  password: Joi.string()
    .min(8)
    .pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
    .messages({
      'string.min': 'Password must be at least 8 characters',
      'string.pattern.base': 'Password must include uppercase, lowercase, and number',
    }),
});

Summary

Joi provides enterprise-grade validation:

  • Rich validation rules for any data type
  • Detailed error messages for debugging
  • Conditional logic for complex scenarios
  • Async validation for database checks
  • Express integration via middleware

Key Takeaways:

  1. Use for backend API validation
  2. Detailed error reporting out of the box
  3. Conditional validation with when()
  4. Async validation with external()
  5. Express middleware for clean routes

Next Steps:

  • Compare Yup
  • Try Zod
  • Build Express API

Resources: