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:
- Use for backend API validation
- Detailed error reporting out of the box
- Conditional validation with when()
- Async validation with external()
- Express middleware for clean routes
Next Steps:
- Compare Yup
- Try Zod
- Build Express API
Resources: