Validating Data with Joi: Schemas, Conditional and Async Rules, abortEarly and stripUnknown
Key takeaways
Joi is the schema description language and data validator that grew out of the hapi framework ecosystem. This guide covers why its fluent chainable API exists, the real difference between .validate() and .validateAsync(), common .custom()/.external() gotchas, schema reuse with .keys() and .concat(), and the production security tradeoffs behind abortEarly and stripUnknown.
Introduction
Joi is a schema description language and data validator for JavaScript, originally built for the hapi framework at Walmart Labs before becoming a standalone library that works with any Node.js backend. It predates the current generation of TypeScript-first validators like Zod: Joi’s design goal was never “infer a TypeScript type from a schema” — it was “describe a JavaScript object’s shape and constraints as precisely and readably as possible, and fail with an error message a human can act on.” That history explains a lot about how the library reads today. Its fluent, chainable API (Joi.string().email().required()) exists because schemas are meant to be readable as English-ish sentences first and machine-checked second; TypeScript type inference came later, retrofitted through community type definitions, and it still lags behind what Zod does natively.
This guide assumes you already know what schema validation is for — the Yup guide on this site covers that comparison in detail. Here the focus is Joi specifically: why its API is shaped the way it is, where its validation model differs from .validate()-only libraries, and the production tradeoffs hiding inside options most tutorials gloss over — abortEarly, stripUnknown, and the synchronous/asynchronous split between .custom() and .external().
Why Joi?
Manual validation code accumulates the same shape of bug over and over: a check gets added for one field but forgotten for a similar field elsewhere, error messages are inconsistent, and nested objects turn into pyramids of if statements.
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);
The difference isn’t just brevity. schema.validate(data) returns a value that has already gone through Joi’s coercion and defaulting pipeline — string numbers get converted, defaults get applied, and the returned value is the object you should actually use downstream, not the raw input. Manual validation functions typically only check the input; they don’t produce a normalized, safe-to-use replacement. That distinction matters more than it looks: if you validate data but continue using the original data object instead of the returned value, you silently lose coercion, defaulting, and any stripping behavior you configured — a subtle bug that shows up as “why isn’t my default applying” reports weeks later.
Installation
npm install joi
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: '[email protected]',
age: 25,
password: 'secret123',
};
const { error, value } = schema.validate(data);
if (error) {
console.error(error.details);
} else {
console.log('Valid:', value);
}
schema.validate() is synchronous and returns a plain { error, value } result object rather than throwing — this is a deliberate design choice, not an accident. Throwing on every validation failure would force every call site into a try/catch, and validation failures in a web backend are expected, routine events (a client sent a malformed request), not exceptional program states. Joi treats them as a return value you inspect, which composes more naturally with Express-style middleware where you want to turn an error into a structured HTTP response rather than unwind a stack.
Note that error here is a single ValidationError object, not an array — the individual problems live in error.details, an array of per-field issues. New Joi users often write if (error.length) expecting an array and get undefined back; always check if (error) and iterate error.details for the individual messages.
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}$/);
That chain at the top is not something you’d actually write as-is — .lowercase() and .uppercase() are mutually exclusive transforms, .length(10) conflicts with .min(2).max(100) on most inputs, and it exists purely as a catalogue of available string rules. The important thing to understand about .trim(), .lowercase(), and .uppercase() specifically is that they are transforms, not just checks — by default Joi mutates the value into the transformed form as part of validation, so the value you get back from schema.validate() may not equal the raw input even when there’s no error. If you need to preserve the exact original string alongside a normalized one, keep a separate copy before validating; don’t assume value === data for string schemas that use these modifiers.
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();
By default Joi.number() performs type coercion: a request body arriving as { age: "25" } (a string, as is common with query parameters or multipart/form-data) is accepted and coerced to the number 25 in the returned value, rather than rejected outright. This is convenient for HTTP inputs, where everything technically arrives as strings, but it’s worth knowing it’s happening — if you need strict type checking with no coercion (for example, validating an internal service-to-service JSON payload where a string in a numeric field indicates a bug upstream), pass { convert: false } in the validation options rather than assuming Joi.number() alone enforces the JS number type.
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: '[email protected]',
address: {
street: '123 Main St',
city: 'New York',
zipCode: '10001',
country: 'USA',
},
});
Nested Joi.object() schemas are where Joi’s design pays off most visibly compared to writing manual validation: the address and settings sub-schemas are validated recursively, and a failure three levels deep still produces a single error.details array with a path array (e.g. ['address', 'zipCode']) telling you exactly where the problem is. Manual validation code tends to flatten this information or lose it entirely once you’re several if blocks deep into nested checks. One easy-to-miss behavior: by default, an object schema rejects keys that aren’t declared in it (address.unit, say, would fail) unless you explicitly allow unknown keys — this default-deny behavior is a feature, not friction, and it’s the reason Joi schemas tend to catch typos in field names during development rather than silently ignoring them.
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']);
.items() versus .ordered() is a distinction worth internalizing: .items() describes what each element of the array is allowed to be (position-independent — every element in the array must match one of the given item schemas), while .ordered() pins specific schemas to specific positions, which is what you want for tuple-like data such as [latitude, longitude] where element order carries meaning. Using .items() for a coordinate pair would validate the types correctly but wouldn’t enforce that the first number is a valid latitude and the second a valid longitude if those ranges differ — for that you still need .ordered() with distinct per-position constraints.
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(),
});
.when() is where Joi’s validation model diverges most from a plain TypeScript-inferred schema: the shape of the validation logic depends on runtime data, not just static structure. Notice the otherwise: Joi.forbidden() branch — this is easy to skip when copying examples, but it matters: without it, a personal account that includes a companyName field simply passes through as an optional string, silently accepting data that shouldn’t exist for that account type. Joi.forbidden() turns “this field shouldn’t be here in this branch” into an explicit validation failure instead of a quiet no-op, which is usually what you actually want for state-dependent fields.
The flow below shows how a single .when() condition is resolved during validation — the condition schema is checked first, and only then does Joi pick which branch’s rules apply to the target field.
flowchart TD
A["schema.validate(data)"] --> B{"Evaluate condition:<br/>accountType === 'business'?"}
B -->|"true"| C["Apply 'then' branch<br/>companyName: required()"]
B -->|"false"| D["Apply 'otherwise' branch<br/>companyName: forbidden()"]
C --> E{"companyName present<br/>and valid?"}
D --> F{"companyName<br/>absent?"}
E -->|"yes"| G["Field passes"]
E -->|"no"| H["error.details:<br/>'companyName' is required"]
F -->|"yes"| G
F -->|"no"| I["error.details:<br/>'companyName' is not allowed"]
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',
});
.custom() is deliberately synchronous-only, and this is the single most common gotcha for developers coming from other validation libraries: passing an async function to .custom() doesn’t throw an error — it silently doesn’t await the promise, so the function’s return value (a Promise object) gets treated as the validated value, which is almost never what you want. If your custom logic needs to await anything — a database call, an external API — you must use .external() instead (covered next), not .custom().
The second gotcha is the return contract: a .custom() function must return either the (possibly transformed) value on success, or the result of helpers.error(...) on failure — it must never return undefined implicitly by falling off the end of the function without a return statement, which happens easily if you add an early-return branch during a refactor and forget the final return value;. An implicit undefined return replaces the field’s value with undefined in the output, which then may or may not trip a .required() check depending on where it sits in the chain — a confusing failure mode to debug because the code “looks” correct.
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');
.external() exists as a separate mechanism from .custom() specifically because Joi’s synchronous validation pass (.validate()) and its asynchronous pass (.validateAsync()) are architecturally different: .validate() never returns a Promise and can’t wait on I/O, full stop. .external() validators are collected during the synchronous structural validation and then run — awaited, in registration order — only when you call .validateAsync(). Calling .validate() (not .validateAsync()) on a schema that has .external() rules attached will simply skip those rules rather than error, which is a silent, easy-to-miss bug: if a schema has any .external() validator anywhere in its tree, every call site validating that schema must use .validateAsync() and await it, or the external check never actually runs.
This has direct security implications for something like the username-uniqueness check above: if a request-validation middleware calls schema.validate(req.body) (synchronous) on a schema borrowed from elsewhere that happens to include an .external() uniqueness check, the duplicate-username check silently never executes, and the bug won’t surface until two users manage to register the same username. The sequence below makes the timing explicit — note that the external DB round-trip only happens on the async path.
sequenceDiagram
participant C as Client
participant M as Express middleware
participant J as Joi schema
participant DB as Database
C->>M: POST /users { username }
M->>J: schema.validateAsync(req.body)
J->>J: Run synchronous rules\n(type, format, required)
alt sync rules pass
J->>DB: external() awaits db.users.findOne(username)
DB-->>J: exists: true/false
J-->>M: { value } or throws ValidationError
else sync rules fail
J-->>M: throws ValidationError immediately\n(DB never queried)
end
M-->>C: 201 Created or 400 Bad Request
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 });
});
Look closely at the two options passed here — abortEarly: false and stripUnknown: true — because both carry production tradeoffs that don’t show up until the middleware is live.
abortEarly: false tells Joi to collect every validation failure instead of stopping at the first one (the default). This is almost always what you want for a form-submission endpoint, because returning one error at a time forces the client through a frustrating fix-one-resubmit-see-the-next-error loop. But it has a quieter downside: returning every failing field and its exact constraint in one response gives an attacker probing your API a much more detailed map of your validation rules (exact min/max lengths, allowed enum values, regex-driven format requirements) than a single generic “invalid input” message would. For public-facing authentication endpoints in particular, some teams deliberately use abortEarly: true (the default) or a generic error message to avoid handing out a schema fingerprint.
stripUnknown: true is the option that deserves the most scrutiny. It tells Joi to silently remove any field in the payload that isn’t declared in the schema, rather than rejecting the request outright. That’s convenient when a legitimate client sends harmless extra fields (stale mobile app versions, browser extensions injecting form fields), but it also means a client that mistakenly — or deliberately — sends fields your schema doesn’t expect gets no feedback that anything was wrong; the extra data just vanishes. The security-relevant case is mass assignment: if req.body is later spread directly into a database write (User.create({ ...req.body, role: 'user' })) without stripUnknown catching a field like role: 'admin' first, the outcome depends entirely on whether that field is declared in your schema — if it is declared and simply unvalidated in scope, stripUnknown won’t save you at all, since stripping only removes undeclared keys. The safer default for sensitive write endpoints is often to leave stripUnknown and allowUnknown both off, so any unexpected key fails the request loudly and visibly instead of being quietly discarded or quietly accepted.
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 });
});
This pattern validates three independent request sources (body, query, params) with three separate schemas rather than one combined schema — deliberately, because the sources have different lifecycles and different coercion needs. req.params and req.query arrive as strings no matter what (Express doesn’t parse route or query parameters into numbers), so relying on Joi’s default coercion for page/limit/id is doing real work here, not just being lenient. One subtlety worth noting: this implementation collects error.details from all three validators but discards the corresponding value objects — it never reassigns req.query or req.params with the coerced, defaulted output. That’s fine if downstream code re-reads from req.query expecting strings, but if a handler expects req.query.page to already be a number (because a default of 1 was applied), you need to explicitly assign the returned value back onto the request object, the same way the earlier single-schema middleware does for req.body.
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' }
// }
// ]
}
The type field on each detail ('string.email', 'number.min') is the stable identifier you should branch on programmatically — never pattern-match on message, since the default English message text is meant for humans and can change between Joi versions without being considered a breaking change. The context object is what lets you build genuinely useful client-side error UI: context.limit and context.value give you the exact constraint that failed and the value that failed it, so a frontend can render “Age must be at least 18 (you entered 15)” instead of relaying Joi’s generic message verbatim.
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',
}),
});
.messages() maps directly onto the type strings from error.details shown above — 'string.email' and 'any.required' are the same identifiers, which is why understanding the type taxonomy matters even if you never inspect it directly: it’s the vocabulary you use to override messages field-by-field and rule-by-rule.
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);
Three of these options interact in ways that aren’t obvious from the list alone. allowUnknown and stripUnknown are not opposites of each other — they’re independent switches that together define four distinct behaviors for an unrecognized key: reject it (both off, the default and generally the safest for write endpoints), accept it as-is (allowUnknown: true, stripUnknown: false), silently drop it (stripUnknown: true, which implies allowing it structurally but removing it from value), or — the combination worth double-checking in your own code — setting stripUnknown: true while allowUnknown is left false still results in stripping rather than rejection, because stripUnknown takes precedence for whether an unknown key becomes an error at all. Read your options object as one policy decision, not a checklist of independent toggles.
escapeHtml: true is worth calling out for anyone treating Joi as a defense against injection attacks: it HTML-entity-encodes string values in the output value, which helps if that value is later interpolated directly into server-rendered HTML, but it is not a substitute for parameterized queries against SQL/NoSQL injection, nor does it sanitize values used in contexts other than HTML (shell commands, regex construction, template literals evaluated as code). Treat it as one layer of output encoding, not a general-purpose sanitizer.
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(),
});
.keys() and .concat() both let you build on an existing object schema, but they solve different problems and mixing them up produces confusing results. .keys({...}) adds or overrides individual keys onto an existing object schema — as used above, adminSchema is userSchema plus role and permissions, and if userSchema already had a role key, .keys() would overwrite just that key’s rules while leaving everything else from userSchema untouched. .concat(otherSchema) instead merges two entire schema objects together, including their shared-level options (like .unknown() settings) — it’s the right tool when you’re combining two schemas that were each built independently (for instance, a baseEntitySchema with id/createdAt/updatedAt concatenated onto several different domain schemas), rather than incrementally adding a handful of keys to one schema you already have a handle on. Reach for .keys() when extending one schema you own; reach for .concat() when composing two schemas that started life separately.
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);
This example ties several earlier patterns together in a shape you’ll actually ship: publishedAt uses the same .when() + .forbidden() pattern from section 7 to enforce that a publish date can only exist on a published post, and Joi.alternatives().try(...) on tags handles the very common real-world case of a query parameter that a client might send as either a single string (?tags=react) or an array (?tags=react&tags=node, which many query-string parsers turn into an array) — trying to write that check by hand means branching on Array.isArray() yourself at every call site, whereas .alternatives() folds that branching into the schema definition once.
Strict mode, sanitizing transforms and custom messages
Strict mode
const schema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
}).strict(); // Disables type coercion for this schema
Sanitizing transforms
const schema = Joi.object({
name: Joi.string().trim().lowercase(),
email: Joi.string().email().lowercase().trim(),
});
Custom 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',
}),
});
.strict() deserves a note: it doesn’t just reject unknown keys (that’s the default object behavior already) — it disables type coercion across the whole schema, so a request where age arrives as the string "25" fails outright instead of being converted to the number 25. That’s the right choice for internal service-to-service APIs exchanging well-typed JSON, but it’s usually the wrong choice for browser-facing form or query-string validation, where string-typed input is the norm rather than a bug. It also changes what the sanitizing rules do: under strict mode .trim() and .lowercase() stop being transforms and become checks, so ' Alice ' fails validation instead of coming back trimmed. Don’t reach for .strict() by default on every schema — decide per-endpoint based on how trustworthy the input’s typing already is.
Where Joi Fits: Origins and Niche
It’s worth being explicit about what makes Joi worth reaching for over newer alternatives, rather than treating it as simply “the older one.” Joi grew up inside hapi, a framework that (unlike Express) treats request validation as a first-class routing concern — hapi route definitions can attach a Joi schema directly to options.validate and have the framework enforce it automatically, no separate middleware required. If your team already runs hapi, Joi isn’t really a choice you make; it’s the validator the framework expects. Outside hapi, Joi’s continuing niche is backend-only, non-TypeScript-first codebases (or mixed JS/TS codebases where the validation layer doesn’t need to drive type inference) that want the richest available vocabulary of built-in rules — .creditCard(), .domain(), .dataUri(), .port(), .ip(), and the .when()-driven conditional system covered above go further out of the box than most competitors. Where Zod and (to a lesser extent) Yup optimize for “the schema is the type,” Joi optimizes for “the schema is the most expressive description of valid data I can write” — a genuinely different design center, not just a legacy one. For a full side-by-side comparison against Zod’s TypeScript-first approach, the Yup guide’s comparison section on this site covers that tradeoff in depth.
The Joi details that cause real bugs
.validate() is synchronous and does not run .external() rules; those only run under .validateAsync(), so a route that checks uniqueness with .external() must use the async call. abortEarly and stripUnknown are security decisions rather than convenience flags, because they decide how much of a bad payload you report back and what reaches your handler. And .keys() extends a schema you own while .concat() merges two independently built ones; mixing them up is how shared base schemas end up with rules nobody intended.
Frequently Asked Questions (FAQ)
Q. Why does .validate() sometimes skip a validation rule I definitely wrote?
A. The most common cause is an .external() rule inside a schema validated with .validate() instead of .validateAsync(). .external() validators only execute when the validation call is awaited via .validateAsync() — the synchronous .validate() path skips them entirely without warning. If a schema in your codebase includes any async check (database uniqueness, external API lookups), every caller of that schema needs to use .validateAsync().
Q. Should abortEarly be true or false in production?
A. For form-style endpoints where you want to show a user every problem with their submission at once, use abortEarly: false. For public authentication or security-sensitive endpoints, consider the tradeoff the other way: returning every failing rule and its exact constraint gives an attacker a detailed map of your validation logic, so a generic error message or the default abortEarly: true behavior can be the safer choice there.