mrkeyoor.com_
Thu 06 Aug 07:41 UTC
npmUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The chaining API has been essentially unchanged since v16 in 2019, and 17.x ran for six years. Version 18 was a platform bump rather than a rewrite: Node 20 minimum, Standard Schema support, an isAsync() helper, and a stricter GUID wrapper option. The cost is the support policy, where only the newest line gets attention.
Docs5/5API.md is a complete reference: every type, every rule, every option, and a catalogue of every error code with the context variables available in its message template. joi.dev adds tutorials and a version status page. The one gap is TypeScript, where the docs show almost nothing about typing schemas.
Maintenance3/5Releases are steady, with 18.2.3 in June 2026 and eight releases across the 18 line since August 2025, but the last push was that release and 176 issues are open (203 counting PRs) against a small maintainer group. The hapi policy of supporting only the newest version means an old Node runtime eventually strands you.
Ecosystem4/5Around 24.1M downloads a week, built into hapi, and wired into express-validation, celebrate, joi-to-json-schema and similar adapters. It is losing ground in new TypeScript projects to zod, and the Standard Schema adapter in v18 is a direct response to that shift.

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
Skip it if

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 input

value 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 number

Leave 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

PackageRegistryPick it when
zodnpmYou are in TypeScript and want the static type inferred from the schema instead of maintaining an interface alongside it
valibotnpmThe schema ships to the browser and bundle size matters: modular imports mean you only pay for the validators you use
ajvnpmYour contract is already JSON Schema, from OpenAPI or another service, and you want to validate against that document directly