joi
joi is a schema description language for JavaScript values. You build a schema by chaining constraints, Joi.string().min(3).email().required(), then call schema.validate(value) and get back an object with value and error. Schemas are immutable: every chained call returns a new schema, so you can share and extend them safely. Validation does two jobs at once. It checks the rules, and by default it also coerces, so the string '42' becomes the number 42 and defaults get filled in, which is why you read the returned value instead of the input you passed. Beyond simple types it handles relationships between keys: with, xor, and, or, and when let you say things like 'password requires repeat_password' or 'this field is required only when type is admin'. Version 18 requires Node 20 and adds a Standard Schema adapter, so joi schemas plug into tooling built for that spec and can emit JSON Schema.
Still one of the best rule sets in JavaScript for messy real-world input, with conditional field relationships and error messages that are genuinely better than its competitors'. Choose it for JavaScript services and hapi apps, and choose zod or valibot when TypeScript inference or bundle size is what you are optimising for.
Use it if
- You are validating untrusted input at a service boundary in JavaScript, not TypeScript, and want a mature rule set for emails, URIs, IPs, UUIDs, ISO dates, and credit cards without writing regexes
- Your rules involve relationships between fields: xor, with, without, nand, and when express conditional requirements that a per-field validator cannot
- You want coercion as part of validation: query strings and form bodies arrive as strings, and joi turns them into numbers, booleans, and dates while it checks them
- You are already on hapi, where joi is the built-in validation layer for route payloads, params, and query strings
- You need to serve human-readable error messages: the message template system with {{#label}} and {{#limit}} produces copy you can show to a user, per rule and per language
- You write TypeScript and want types from your schemas: Joi.object() is generic with a default of any, so you hand-write the interface and pass it in, and nothing stops the two drifting apart. zod and valibot infer the type from the schema, which is the main reason people migrate away
- You ship this to the browser: about 52 KB gzipped for the full build, because the IANA TLD list and the whole rule set come along whether you use them or not. valibot's modular imports land in single-digit kilobytes for comparable checks
- You are on Node 18 or older: joi 18 sets engines to Node 20 and up, so you are pinned to the 17.x line, which is on the latest-17 dist-tag and only gets occasional fixes
- You want compile-time safety on the schema itself: a typo like Joi.string().mn(3) is a runtime TypeError from a chain that no longer exists, and the schema is only exercised when a request arrives
- Your validation is one required field and a length check: joi is a large dependency to pull in for what an if statement does, and the coercion behaviour will surprise you when '0' quietly becomes 0
- You need predictable release cadence from a large team: joi is maintained under the hapi project, which supports the latest version only, so upgrading Node eventually means upgrading joi rather than getting a backport
Setup reality
npm install joi and there is no build step and no peer dependency, but it drags in seven @hapi scoped packages (address, formula, hoek, pinpoint, tlds, topo) plus @standard-schema/spec, and @hapi/tlds is a large data file of every registered TLD. TypeScript definitions ship in the package, so no @types install. The version floor moved in 18.0: engines is now node >= 20, and npm will install it anyway on older Node with only a warning before something breaks at runtime. The behaviour to internalise before you write a schema is that validate() returns a possibly different value from the one you gave it, because convert defaults to true and defaults are applied; ignoring the returned value is the most common joi bug. Two more defaults bite: abortEarly is true, so you get one error per request until you turn it off, and unknown keys on an object cause a failure rather than being dropped, so you decide between stripUnknown and .unknown(true) at every boundary. Any schema that uses .external() cannot be validated synchronously at all; validate() throws and tells you to call validateAsync().
Patterns
Define a schema and validate a payloadvalidate-an-object
const Joi = require('joi');
const schema = Joi.object({
name: Joi.string().min(3).max(30).required(),
email: Joi.string().email({ minDomainSegments: 2 }).required(),
age: Joi.number().integer().min(18),
});
const { error, value } = schema.validate(input);
if (error) return res.status(400).json({ error: error.message });
saveUser(value); // use value, not inputvalue is the coerced and defaulted result, so '25' arrives as 25. Passing input onward instead of value is the mistake that makes people think joi's coercion does not work.
Report every problem at oncecollect-all-errors
const { error } = schema.validate(input, { abortEarly: false });
if (error) {
const fields = error.details.map((d) => ({
path: d.path.join('.'),
type: d.type, // e.g. 'string.min'
message: d.message,
}));
return res.status(400).json({ fields });
}abortEarly defaults to true, which means a form with four bad fields makes the user submit four times. error.details is the array to build an API response from; error.message is only the first detail joined for humans.
Decide what to do with extra propertieshandle-unknown-keys
// Reject anything not declared (the default)
schema.validate(body);
// Silently drop extra keys
schema.validate(body, { stripUnknown: true });
// Allow and keep them
schema.unknown(true).validate(body);The default rejects with '"x" is not allowed', which is correct for a strict API but breaks clients that send extra metadata. stripUnknown is the safer choice for anything you then write to a database, because it keeps unexpected keys out of your storage layer.
Require a field only in some casesconditional-requirements
const schema = Joi.object({
type: Joi.string().valid('personal', 'business').required(),
companyName: Joi.when('type', {
is: 'business',
then: Joi.string().required(),
otherwise: Joi.forbidden(),
}),
});forbidden() in the otherwise branch is what makes the rule two-way: a personal account sending companyName gets '"companyName" is not allowed' rather than a silently ignored field. The is condition permits undefined by default, so use Joi.number().required() inside is when the referenced key must actually exist.
Express relationships between keyscross-field-rules
const schema = Joi.object({
password: Joi.string().min(12),
repeatPassword: Joi.any().valid(Joi.ref('password')).required()
.messages({ 'any.only': 'Passwords must match' }),
apiKey: Joi.string(),
})
.xor('password', 'apiKey')
.with('password', 'repeatPassword');xor means exactly one of the two, or means at least one, and nand means not both. Joi.ref reads a sibling key at validation time, and the default any.only message leaks the referenced value into the text, which is why the override matters here.
Write messages a user can act oncustom-error-messages
const password = Joi.string().min(12).pattern(/[0-9]/, 'a digit')
.label('Password')
.messages({
'string.min': '{{#label}} must be at least {{#limit}} characters',
'string.pattern.name': '{{#label}} must contain {{#name}}',
'any.required': '{{#label}} is required',
});Keys are joi error codes, listed exhaustively in API.md, and each code exposes its own context variables. Naming a pattern gives you string.pattern.name instead of string.pattern.base, which is the difference between a usable message and one that prints the raw regex at the user.
Validate arrays of objectsarrays-and-nesting
const schema = Joi.object({
items: Joi.array()
.items(Joi.object({
sku: Joi.string().pattern(/^[A-Z]{3}-\d{4}$/).required(),
qty: Joi.number().integer().min(1).max(99).required(),
}))
.min(1).max(50).required(),
tags: Joi.array().items(Joi.string()).unique().default([]),
});unique() compares with deep equality and reports the index of the duplicate. Errors inside arrays come back with a numeric path segment, so error.details[0].path is ['items', 3, 'qty'], which is what you join to point the client at the offending row.
Share a base schema across routesreuse-and-extend-schemas
const user = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
role: Joi.string().valid('admin', 'member').default('member'),
});
const createUser = user;
const updateUser = user.fork(['name', 'email'], (s) => s.optional());
const publicUser = user.keys({ id: Joi.string().uuid().required() });Schemas are immutable, so fork and keys return new schemas and leave the original alone. fork is how you build a PATCH schema from a POST schema without duplicating the rules or letting them drift.
Turn casting off when types must be exactcontrol-coercion
// Default: strings become numbers
Joi.object({ n: Joi.number() }).validate({ n: '42' });
// -> { value: { n: 42 } }
// Strict: the input type has to be right
Joi.object({ n: Joi.number() }).validate({ n: '42' }, { convert: false });
// -> error: "n" must be a numberLeave convert on for query strings and form bodies, where everything arrives as text. Turn it off for JSON bodies where a string in a numeric field means the client has a bug you would rather hear about now. Note that convert: false also stops trim(), lowercase(), and defaults from being applied.
Run a database check as part of validationasync-external-checks
const schema = Joi.object({
email: Joi.string().email().required().external(async (value, helpers) => {
if (await users.existsByEmail(value)) {
throw new Error('is already registered');
}
return value.toLowerCase();
}),
});
const value = await schema.validateAsync(input);Any schema containing external() throws if you call validate(); it tells you to use validateAsync(). External rules run only after every other rule passes, so you never hit the database for input that was malformed anyway. Returning a value from the function replaces it.
Add your own type with joi.extendcustom-type
const CustomJoi = Joi.extend((joi) => ({
type: 'objectId',
base: joi.string(),
messages: { 'objectId.base': '{{#label}} must be a 24-character hex id' },
validate(value, helpers) {
if (!/^[0-9a-f]{24}$/.test(value)) {
return { value, errors: helpers.error('objectId.base') };
}
},
}));
CustomJoi.objectId().validate('nope');extend returns a new joi instance; the original is untouched, so export the extended one and use it everywhere. For a one-off check, any.custom(fn) is far less ceremony and does not need a new type.
Interoperate through the Standard Schema adapterstandard-schema-and-json-schema
const schema = Joi.object({ a: Joi.number() });
const std = schema['~standard'];
std.validate({ a: '5' }); // { value: { a: 5 } }
std.validate({ a: 'x' }); // { issues: [{ message, path }] }
std.jsonSchema.input();
// { type: 'object', properties: { a: { type: 'number' } }, additionalProperties: false }New in the 18 line, and the reason joi now depends on @standard-schema/spec. It lets joi schemas be consumed by form libraries and routers that accept any Standard Schema validator. jsonSchema targets draft-2020-12 only, and input() and output() differ once you use coercion or defaults.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zod | npm | You are in TypeScript and want the static type inferred from the schema instead of maintaining an interface alongside it |
| valibot | npm | The schema ships to the browser and bundle size matters: modular imports mean you only pay for the validators you use |
| ajv | npm | Your contract is already JSON Schema, from OpenAPI or another service, and you want to validate against that document directly |