Schema Validation with Joi
Joi is a schema-description language and validator for JavaScript objects. Instead of scattering if checks across your handlers, you declare the exact shape a request should have — types, required fields, ranges, formats — as a single schema object, then ask Joi to check incoming data against it. In Express this pairs naturally with middleware: validate req.body, req.query, or req.params before the controller runs, and reject anything that doesn’t fit with a clean 400. Joi also coerces and strips data, so what reaches your business logic is already normalized.
Installing and importing Joi
Joi ships as a standalone package with no Express dependency. Install it alongside Express:
npm install express joi
Modern Joi (v17+) exports a single object you compose schemas from. Each builder method (Joi.string(), Joi.number(), Joi.object()) returns a new immutable schema, so chaining never mutates shared state.
const Joi = require("joi");
const userSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(120),
role: Joi.string().valid("user", "admin").default("user"),
});
This schema says: name is a required string of 2–100 characters, email is a required valid address, age is an optional non-negative integer up to 120, and role must be one of two literals, defaulting to "user" when omitted.
Validating a request body
Call schema.validate(value) to check a value. It returns an object with two keys: error (a ValidationError, or undefined if valid) and value (the coerced, defaulted result). Joi never throws here — you branch on error yourself.
const express = require("express");
const app = express();
app.use(express.json());
app.post("/users", async (req, res) => {
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({
error: "Validation failed",
details: error.details.map((d) => ({
field: d.path.join("."),
message: d.message,
})),
});
}
// `value` is coerced and includes defaults — use it instead of req.body
const user = await db.users.insert(value);
res.status(201).json(user);
});
app.listen(3000);
Sending POST /users with { "name": "A", "email": "nope" } produces:
Output:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": "Validation failed",
"details": [
{ "field": "name", "message": "\"name\" length must be at least 2 characters long" }
]
}
Notice only one error came back. By default Joi stops at the first failure — see the next section to change that.
Controlling behavior: abortEarly and stripUnknown
validate() accepts an options object as a second argument. Two options matter most for HTTP APIs.
| Option | Default | Effect |
|---|---|---|
abortEarly | true | When false, Joi reports every failing field instead of stopping at the first. |
stripUnknown | false | When true, keys not declared in the schema are removed from value. |
convert | true | Coerces compatible types, e.g. the string "42" to the number 42. |
allowUnknown | false | When true, unknown keys are allowed through instead of erroring. |
For request validation you almost always want abortEarly: false (so the client sees all problems in one round trip) and stripUnknown: true (so extra fields can’t sneak into your model — a mass-assignment defense).
const { error, value } = userSchema.validate(req.body, {
abortEarly: false,
stripUnknown: true,
});
Tip:
convertdefaults totrue, which is exactly what you want for query strings — every value inreq.queryarrives as a string, andJoi.number()will coerce"25"into25for you. Disable it only when you need strict type checking.
A reusable validate(schema) middleware
Writing the validate-and-branch block in every handler gets repetitive. Factor it into a higher-order middleware that takes a schema and the request property to check, and returns an Express middleware function.
function validate(schema, property = "body") {
return (req, res, next) => {
const { error, value } = schema.validate(req[property], {
abortEarly: false,
stripUnknown: true,
});
if (error) {
return res.status(400).json({
error: "Validation failed",
details: error.details.map((d) => ({
field: d.path.join("."),
message: d.message,
})),
});
}
req[property] = value; // replace with the cleaned, coerced value
next();
};
}
Now routes read declaratively, with the schema as a gatekeeper before the controller:
const express = require("express");
const router = express.Router();
const listQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
});
router.post("/users", validate(userSchema), async (req, res) => {
const user = await db.users.insert(req.body);
res.status(201).json(user);
});
router.get("/users", validate(listQuerySchema, "query"), async (req, res) => {
const { page, limit } = req.query; // already numbers, with defaults applied
const users = await db.users.list({ page, limit });
res.json(users);
});
module.exports = router;
Warning: In Express 5,
req.queryis a read-only getter, soreq.query = valuethrows. Either mutate the existing object’s keys, or attach the cleaned result to a custom property likereq.validatedQueryand read from that downstream. Body and params remain writable.
Best Practices
- Define each route’s schema once as a named constant and reuse it — schemas are immutable, so they’re safe to share.
- Always pass
abortEarly: falsefor HTTP validation so clients receive every error in a single response. - Use
stripUnknown: trueto drop undeclared fields and prevent mass-assignment into your models. - Read from the returned
value(or the reassignedreq[property]), not the raw input — it carries coercions and defaults. - Validate
req.queryandreq.paramswith their own schemas, not justreq.body; remember query values arrive as strings. - Wrap validation in a generic
validate(schema, property)middleware so route definitions stay declarative and consistent. - Map
error.detailsto a stable{ field, message }shape so your API contract doesn’t leak Joi internals.