Joi is a powerful schema description language and data validator for JavaScript. It allows you to describe data using simple, intuitive syntax and validate objects against that description.
Input validation is crucial for security and data integrity. It prevents invalid data from entering your system, protects against injection attacks, and ensures data conforms to expected formats.
Basic Usage
The core workflow consists of two steps: defining a schema and validating data.
Schemas are immutable objects where you chain methods to define rules. You create a schema using Joi.object() and define rules for each field.
const schema = Joi.object({
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required(),
birth_year: Joi.number()
.integer()
.min(1900)
.max(2023),
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
});
Use the validate() method to check your data against the schema. Joi does not throw errors by default for synchronous validation; it returns an object with value and error properties.
The value property contains the validated and potentially converted data. The error property is an error object if validation failed, or undefined if valid.
Handling Validation Results
You must check if the error property exists to determine if validation succeeded.
const { error, value } = schema.validate({ username: 'abc' });
if (error) {
// Validation failed
console.log(error.details[0].message);
} else {
// Validation success
// Use 'value' instead of raw input, as Joi might have converted types
createUser(value);
}
For async validation, use validateAsync. This method throws an error if validation fails, making it suitable for async/await patterns.
try {
const value = await schema.validateAsync(data);
// Success!
} catch (err) {
console.error(err.message);
}
Configuration Options
The third argument in validate() is the options object, which controls Joi’s behavior.
abortEarly stops at the first error by default. Set this to false to check all fields and return all errors at once. stripUnknown removes unknown keys from the validated output, providing sanitization. convert tries to cast types, like converting string “123” to number 123. The default is true.
const options = {
abortEarly: false,
stripUnknown: true
};
const { error, value } = schema.validate(data, options);
Common Data Types and Rules
String rules include alphanum for alphanumeric characters, trim to remove whitespace, lowercase to convert to lowercase, and pattern for custom regex validation.
Array validation allows you to specify item types, minimum and maximum lengths, and uniqueness constraints. You can require specific values to be present in arrays.
Number validation supports integer constraints, minimum and maximum values, and precision settings.
Advanced Features
Valid values and enums allow you to whitelist or blacklist specific values. Use valid() or allow() to define permitted values, and invalid() to forbid specific values.
By default, most Joi types do not allow null. You must explicitly allow it if needed using allow(null).
Conditional logic using when() allows you to adjust validation rules dynamically based on the value of other fields. This is useful for complex validation scenarios where rules depend on other field values.
Logical alternatives using alternatives().try() allow a field to match multiple schemas. This is useful when a field can be one of several types.
Exclusive relationships using xor() ensure one field appears but not both. This is useful for mutually exclusive options.
Reusable Middleware Pattern
Instead of manually checking for errors in every route, you can create a reusable middleware function to handle validation and error formatting automatically.
This keeps your controllers clean and ensures sanitization rules are applied consistently. The middleware validates the request body, formats errors, and replaces the body with the sanitized value.
const validate = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const errorMessage = error.details.map(d => d.message).join(', ');
return res.status(400).json({ status: 'error', message: errorMessage });
}
req.body = value; // Replace body with sanitized value
next();
};
};
Usage in routes is simple:
router.post('/register', validate(registerSchema), (req, res) => {
// req.body is already validated & sanitized!
res.send('Success');
});
Joi with TypeScript
Joi works well with TypeScript. You can make validation middleware type-safe by importing Express types and Joi’s Schema type.
Define interfaces first, then use them in schema definitions. This ensures your Joi validators match your TypeScript code and provides a single source of truth.
When using the middleware, TypeScript doesn’t automatically know that req.body has been validated. You can cast req.body to your interface to get autocomplete and type checking in your controller logic.
For authentication scenarios, you can extend the standard Express Request interface to include user information, providing type safety throughout your application.
Custom Error Messages
You can customize error messages users see for specific validation failures using messages(). This provides better user experience by giving clear, actionable error messages.
Joi.string().email().messages({
'string.email': 'Please enter a valid email address',
'string.empty': 'Email cannot be empty'
});
Testing with Joi
For unit testing or quick checks where you want to throw an error immediately if validation fails, use Joi.assert. This throws an error immediately if invalid.
Joi.attempt validates and returns the valid value if successful, or throws if not. This is useful for inline validation in code.
Best Practices
Define schemas in separate files to keep them organized and reusable. Use consistent validation rules across your application. Always sanitize input by using stripUnknown to remove unexpected fields.
Use abortEarly: false in development to see all validation errors at once, helping users fix multiple issues. In production, you might prefer abortEarly: true for faster failure.
Combine Joi validation with TypeScript interfaces for compile-time and runtime safety. This provides the best of both worlds: type checking during development and runtime validation in production.
Summary
Joi provides a powerful way to validate and sanitize input in Node.js applications. By understanding schemas, validation methods, configuration options, and advanced features, you can build robust validation layers that protect your application from invalid data.
The key is to use reusable middleware patterns, combine with TypeScript for type safety, and follow consistent validation practices throughout your application. This ensures data integrity and provides good developer and user experience.