Getting started
Installation
npm install joi
Basic usage
const Joi = require("joi");
const schema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18),
});
const result = schema.validate({
username: "john",
email: "john@example.com",
age: 25,
});
// result = { value: {...}, error: undefined }
Validation returns an object with value (coerced/validated data) and error (validation error or undefined).
Quick validation
const Joi = require("joi");
// Simple string validation
const { error, value } = Joi.string().min(5).validate("hello");
// With options
const result = Joi.number().validate("42", {
convert: true, // Coerce string to number
});
// Throw on error
const value = Joi.attempt("test@example.com", Joi.string().email());
Schema types
String
Joi.string()
.min(3)
.max(100)
.alphanum() // Only a-z, A-Z, 0-9
.email() // Valid email
.uri() // Valid URI
.uuid() // Valid UUID
.pattern(/^[A-Z]/) // Regex match
.lowercase() // Convert to lowercase
.uppercase() // Convert to uppercase
.trim() // Remove whitespace
.domain() // Valid domain name
.hostname(); // Valid hostname
Number
Joi.number()
.min(0)
.max(100)
.integer() // Whole numbers only
.positive() // Greater than 0
.negative() // Less than 0
.multiple(5) // Divisible by 5
.port() // Valid port (0-65535)
.greater(10) // Strictly greater
.less(100) // Strictly less
.precision(2); // Max decimal places
Boolean
Joi.boolean()
.truthy("Y", "yes", "1") // Custom true values
.falsy("N", "no", "0") // Custom false values
.sensitive(); // Don't expose in errors
Date
Joi.date()
.min("1-1-2000") // Minimum date
.max("now") // Maximum date
.greater("now") // Must be future
.less("12-31-2030") // Must be before
.timestamp("unix") // Unix timestamp
.iso(); // ISO 8601 format
Array
Joi.array()
.items(Joi.string()) // Array of strings
.min(1) // Min length
.max(10) // Max length
.length(5) // Exact length
.unique() // No duplicates
.sparse() // Allow undefined items
.single(); // Convert single value to array
Object
Joi.object({
name: Joi.string(),
age: Joi.number(),
})
.unknown(false) // No unknown keys
.min(2) // Min key count
.max(10) // Max key count
.length(5); // Exact key count
Alternatives
// One of multiple types
Joi.alternatives().try(Joi.string(), Joi.number());
// Conditional alternatives
Joi.alternatives().conditional("type", {
is: "admin",
then: Joi.string().required(),
otherwise: Joi.forbidden(),
});
Other types
Joi.any(); // Matches anything
Joi.binary(); // Buffer/binary data
Joi.function(); // Function type
Joi.symbol(); // Symbol type
Joi.link("#ref"); // Reference to another schema
Schema modifiers
Required and optional
Joi.string().required(); // Must be present
Joi.string().optional(); // May be absent
Joi.string().forbidden(); // Must not be present
Allow and valid
// Allow specific values
Joi.string().allow("", null);
// Only allow specific values (whitelist)
Joi.string().valid("red", "green", "blue");
// Disallow specific values (blacklist)
Joi.string().invalid("admin", "root");
Default values
// Static default
Joi.string().default("guest");
// Function default
Joi.date().default(Date.now, "current date");
// Context-based default
Joi.string().default(Joi.ref("$username"));
Metadata
Joi.string()
.description("User full name")
.example("John Doe")
.note("Must match legal name")
.tag("user", "profile")
.meta({ internal: true });
Presence
// Change presence for all keys
Joi.object({
name: Joi.string(),
email: Joi.string(),
}).required(); // Both name and email required
// Override for specific keys
Joi.object({
name: Joi.string().required(),
email: Joi.string(),
}).optional(); // Name still required, email optional
Validation options
Common options
const options = {
// Stop validation on first error
abortEarly: false,
// Allow unknown keys in objects
allowUnknown: true,
// Type coercion (string to number, etc)
convert: true,
// Remove unknown keys from result
stripUnknown: true,
// Default presence (required/optional/forbidden)
presence: "required",
};
schema.validate(data, options);
Error options
const options = {
// Custom error messages
messages: {
"string.min": "{{#label}} must be at least {{#limit}} characters",
"string.email": "Please enter a valid email",
},
// Language for error messages
errors: {
wrap: {
label: "`", // Wrap labels with backticks
},
},
};
Context and preferences
// Pass external values
const schema = Joi.string().valid(Joi.ref("$validValue"));
schema.validate("test", {
context: { validValue: "test" },
});
// Preferences (alternative to options)
const schema = Joi.string().prefs({
convert: false,
abortEarly: false,
});
References and conditions
References
// Reference sibling field
Joi.object({
password: Joi.string().required(),
confirmPassword: Joi.string().valid(Joi.ref("password")).required().messages({
"any.only": "Passwords must match",
}),
});
// Reference parent field
Joi.object({
level1: {
level2: Joi.string().valid(Joi.ref("...parentField")),
},
parentField: Joi.string(),
});
// Reference context
Joi.string().valid(Joi.ref("$contextValue"));
Conditional validation
// Simple conditional
Joi.object({
type: Joi.string().valid("user", "admin"),
privileges: Joi.array().when("type", {
is: "admin",
then: Joi.required(),
otherwise: Joi.forbidden(),
}),
});
// Multiple conditions
Joi.number().when("type", {
switch: [
{ is: "small", then: Joi.valid(1, 2, 3) },
{ is: "medium", then: Joi.valid(10, 20, 30) },
{ is: "large", then: Joi.valid(100, 200, 300) },
],
otherwise: Joi.forbidden(),
});
// Conditional based on reference
Joi.object({
a: Joi.number(),
b: Joi.number().when("a", {
is: Joi.number().greater(10),
then: Joi.required(),
otherwise: Joi.optional(),
}),
});
Object validation
Keys and patterns
// Define object keys
Joi.object().keys({
name: Joi.string(),
age: Joi.number(),
});
// Unknown keys
Joi.object({
name: Joi.string(),
}).unknown(true); // Allow any other keys
// Pattern matching keys
Joi.object().pattern(
/^[A-Z]+$/, // Key pattern
Joi.number().positive(), // Value schema
);
Dependencies
// Require both or neither
Joi.object({
username: Joi.string(),
password: Joi.string(),
}).and("username", "password");
// Forbid both
Joi.object({
a: Joi.any(),
b: Joi.any(),
}).nand("a", "b");
// Require at least one
Joi.object({
email: Joi.string(),
phone: Joi.string(),
}).or("email", "phone");
// Require exactly one
Joi.object({
sms: Joi.string(),
email: Joi.string(),
}).xor("sms", "email");
// If one present, require other
Joi.object({
firstName: Joi.string(),
lastName: Joi.string(),
}).with("firstName", "lastName");
// If one present, forbid other
Joi.object({
a: Joi.any(),
b: Joi.any(),
}).without("a", "b");
Object options
Joi.object({
name: Joi.string(),
})
.min(2) // Min number of keys
.max(10) // Max number of keys
.length(5) // Exact number of keys
.unknown(false); // Forbid unknown keys
Array validation
Items validation
// Array of specific type
Joi.array().items(Joi.string());
// Multiple allowed types
Joi.array().items(Joi.string(), Joi.number());
// Required items in order
Joi.array().ordered(
Joi.string().required(), // First item
Joi.number().required(), // Second item
Joi.boolean(), // Third item (optional)
);
// Mix ordered and unordered
Joi.array()
.ordered(Joi.string()) // First must be string
.items(Joi.number()); // Rest must be numbers
Array constraints
Joi.array()
.min(1) // Min length
.max(10) // Max length
.length(5) // Exact length
.unique() // No duplicates
.sparse() // Allow undefined/null items
.single() // Convert single value to array
.has(Joi.string().valid("required")); // Must contain
Unique validation
// Unique by value
Joi.array().unique();
// Unique by property
Joi.array()
.items(
Joi.object({
id: Joi.number(),
name: Joi.string(),
}),
)
.unique("id");
// Custom comparator
Joi.array().unique((a, b) => a.id === b.id);
Error handling
Checking errors
const { error, value } = schema.validate(data);
if (error) {
console.log(error.message);
console.log(error.details); // Array of error details
error.details.forEach((detail) => {
console.log(detail.message); // Error message
console.log(detail.path); // Path to field (array)
console.log(detail.type); // Error type
console.log(detail.context); // Error context
});
}
Custom messages
// Per-rule messages
Joi.string()
.min(3)
.message("Must be at least 3 characters")
.email()
.message("Must be a valid email");
// Multiple messages
Joi.string().messages({
"string.min": "Too short (min {{#limit}})",
"string.max": "Too long (max {{#limit}})",
"string.email": "Invalid email format",
});
// Template variables
Joi.string().min(5).messages({
"string.min": "{{#label}} must be at least {{#limit}} chars (got {{#value}})",
});
Validation methods
// Standard validation
const { error, value } = schema.validate(data);
// Throw on error
try {
const value = Joi.attempt(data, schema);
} catch (error) {
console.log(error.message);
}
// Async validation
const value = await schema.validateAsync(data);
// Assert (throws ValidationError)
Joi.assert(data, schema);
Examples
User registration
const userSchema = Joi.object({
username: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string()
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.min(8)
.required()
.messages({
"string.pattern.base":
"Password must contain uppercase, lowercase and number",
}),
confirmPassword: Joi.string().valid(Joi.ref("password")).required().messages({
"any.only": "Passwords must match",
}),
birthDate: Joi.date().max("now").required(),
agreeToTerms: Joi.boolean().valid(true).required(),
});
const { error, value } = userSchema.validate(userData);
API payload validation
const createPostSchema = Joi.object({
title: Joi.string().min(5).max(100).required(),
content: Joi.string().min(50).max(5000).required(),
tags: Joi.array().items(Joi.string().lowercase()).min(1).max(5).unique(),
published: Joi.boolean().default(false),
publishedAt: Joi.date().when("published", {
is: true,
then: Joi.required(),
otherwise: Joi.forbidden(),
}),
authorId: Joi.number().integer().positive().required(),
});
Nested object validation
const addressSchema = Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
state: Joi.string().length(2).uppercase(),
zipCode: Joi.string()
.pattern(/^\d{5}(-\d{4})?$/)
.required(),
});
const companySchema = Joi.object({
name: Joi.string().required(),
headquarters: addressSchema.required(),
offices: Joi.array().items(addressSchema).min(1),
employees: Joi.array().items(
Joi.object({
id: Joi.number().required(),
name: Joi.string().required(),
email: Joi.string().email().required(),
department: Joi.string().valid("engineering", "sales", "support"),
address: addressSchema,
}),
),
metadata: Joi.object().pattern(
Joi.string(),
Joi.alternatives().try(Joi.string(), Joi.number(), Joi.boolean()),
),
});
Express middleware
const express = require("express");
const Joi = require("joi");
// Validation middleware
const 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.validatedBody = value;
next();
};
};
// Usage
const app = express();
app.post("/users", validate(userSchema), (req, res) => {
const userData = req.validatedBody;
// Create user...
});
Query string validation
const querySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string()
.valid("createdAt", "updatedAt", "name")
.default("createdAt"),
order: Joi.string().valid("asc", "desc").default("desc"),
search: Joi.string().min(3).max(50).optional(),
filter: Joi.object({
status: Joi.string().valid("active", "inactive"),
category: Joi.array().items(Joi.string()),
}).optional(),
});
// Express usage
app.get("/api/posts", (req, res) => {
const { error, value } = querySchema.validate(req.query, {
convert: true,
stripUnknown: true,
});
if (error) {
return res.status(400).json({ error: error.message });
}
const { page, limit, sort, order, search, filter } = value;
// Query posts...
});
Advanced patterns
Schema composition
// Base schema
const baseSchema = Joi.object({
createdAt: Joi.date().default(Date.now),
updatedAt: Joi.date().default(Date.now),
});
// Extend base schema
const userSchema = baseSchema.keys({
username: Joi.string().required(),
email: Joi.string().email().required(),
});
// Concat schemas
const fullSchema = Joi.object({
id: Joi.number(),
}).concat(userSchema);
Custom validation
// Custom method
const customString = Joi.string().custom((value, helpers) => {
if (value.includes("forbidden")) {
return helpers.error("string.forbidden");
}
return value;
}, "custom validation");
// With custom error
Joi.string().custom((value, helpers) => {
if (!isValidUsername(value)) {
return helpers.message("Username is already taken");
}
return value;
});
Schema links
// Self-referencing schema
const schema = Joi.object({
name: Joi.string(),
children: Joi.array().items(Joi.link("#schema")),
}).id("schema");
// Validate tree structure
const tree = {
name: "root",
children: [
{ name: "child1", children: [] },
{ name: "child2", children: [{ name: "grandchild", children: [] }] },
],
};
External validation
// Async external validation
const schema = Joi.string().external(async (value) => {
const exists = await checkDatabase(value);
if (exists) {
throw new Error("Value already exists");
}
return value;
});
// Must use validateAsync
const value = await schema.validateAsync("test");
Common gotchas
Type coercion
By default, Joi performs type coercion:
// String '42' becomes number 42
Joi.number().validate("42");
// { value: 42, error: undefined }
// Disable coercion
Joi.number().validate("42", { convert: false });
// { error: ValidationError }
// Disable at schema level
const schema = Joi.number().prefs({ convert: false });
Unknown keys
By default, unknown object keys are allowed:
const schema = Joi.object({
name: Joi.string(),
});
schema.validate({ name: "John", age: 30 });
// { value: { name: 'John', age: 30 } }
// Forbid unknown keys
schema.validate(
{ name: "John", age: 30 },
{
allowUnknown: false,
},
);
// { error: ValidationError }
// Strip unknown keys
schema.validate(
{ name: "John", age: 30 },
{
stripUnknown: true,
},
);
// { value: { name: 'John' } }
Required vs presence
// Not the same!
Joi.string().required(); // Must be present
Joi.object().required(); // Object must exist
// Set default presence
const schema = Joi.object({
name: Joi.string(),
email: Joi.string(),
}).prefs({ presence: "required" });
// Both fields now required
// Override per field
const schema = Joi.object({
name: Joi.string().required(),
email: Joi.string().optional(),
}).prefs({ presence: "optional" });
Error handling
// Always check for errors
const { error, value } = schema.validate(data);
if (error) {
// Handle error
}
// Or use try/catch with attempt
try {
const value = Joi.attempt(data, schema);
} catch (error) {
// Handle error
}
// Get all errors (not just first)
const { error } = schema.validate(data, {
abortEarly: false,
});
Also see
- Joi Documentation - Official documentation
- Joi GitHub - Source code and examples
- Hapi.dev - Framework that uses Joi
- Joi NPM Package - Package details