Skip to content

Validation (Joi) — Theory

Joi.alternatives().try(s1, s2, ...) builds a union: matches if value satisfies at least one listed schema (default match: 'any'). The shorthand [s1, s2] compiles to the same thing.

Match modeSemantics
.match('any') (default)First match wins
.match('one')XOR — exactly one schema matches
.match('all')Conjunction — returns raw value (bypasses conversion since branches could disagree)

.conditional(condition, { is, then, otherwise }) is the alternatives-flavor of .when(): picks the first matching branch and stops, whereas any.when() composes all matching conditions onto the base schema.

Rule of thumb: reach for alternatives when the type itself changes (e.g. string | { url, alt }), and reach for .when() when the constraints change on a fixed type.

  • [Joi.string(), Joi.number()] shorthand silently swallows Joi.alternatives() features like .match('one'). Use the verbose form when needed.
  • match: 'all' ignores convert, so a query-string number coming in as "5" won’t be cast.
  • Numeric strings coerced to numbers under default convert: true. Joi.alternatives().try(Joi.number(), Joi.string()) validates "42" as a number. Add .strict() to disable per-branch coercion.
  • alternatives.conditional() only adds branches; does not make the parent required.
  • Error messages from a failed union are noisy. Use .messages({...}) or .error(...).

any.when([condition], options) mutates the schema at validation time based on (a) a sibling/ancestor key, (b) a Joi.ref(), or (c) a peeking schema. The shape is { is, then, otherwise } (or not for inverse, or switch: [...] for a chain).

When is is a literal, it compiles to Joi.valid(Joi.override, value) — meaning the literal replaces any base allowed-values list (a frequent surprise). Cross-field references use Joi.ref('siblingKey'), ancestor refs use leading dots ('...root'), and external context via Joi.ref('$tenantId') resolved from the context option passed to validate().

Joi.expression() (alias Joi.x) lets you compute a value with arithmetic and if(...) over refs — useful for min/max derived from siblings. Multiple .when() calls compose: the schema that comes out of the first .when() is the input to the second.

  • Joi.ref('field') — sibling of the current value
  • Joi.ref('...root') — climb levels (each extra . = one ancestor up)
  • Joi.ref('/root') — from the root
  • Joi.ref('$ctxKey') — from external context option
  • is: 'A' actually means is: Joi.valid(Joi.override, 'A') — it replaces the base. To append a literal use is: Joi.valid('A') explicitly.
  • .when('field', { is: Joi.exist(), then: Joi.required() }) does NOT mean “required if field truthy” — Joi.exist() matches anything not undefined including null/0/''. Default when is/not/switch omitted is Joi.invalid(null, false, 0, '').required() (truthy).
  • Schema rebuilt on every validation when conditions hit — runtime cost. Joi caches generated schemas, but the first call pays the build cost.
  • when() cannot change the type (e.g. number → string); use alternatives.conditional() for that.

Custom validators — .custom() vs .external()

Section titled “Custom validators — .custom() vs .external()”

.custom(fn, [description]) runs synchronously during the main validation pass with (value, helpers). The function may: return the value (possibly transformed), throw an Error (becomes any.custom error), return helpers.error('error.code', { localContext }) to emit a typed error, or return undefined to unset the value.

.external(asyncFn) runs after the entire schema has otherwise succeeded — perfect for I/O like “is this email already in the DB?”. External rules are skipped on prior failure (no wasted DB roundtrip) and only run via validateAsync(); calling validate() synchronously while externals are present throws unless you pass { externals: false }.

Custom error messages live via .messages({ 'any.custom': '{#label} failed business rule' }) or schema-wide .prefs({ messages: {...} }). To override Joi’s error object entirely (e.g. throw a Boom.badRequest), use .error(new Error(...)) — but prefer .messages() if you only want to change wording.

  • .custom() is sync only — returning a Promise will be treated as a truthy “valid” value.
  • .external() only via validateAsync(). Calling .validate() throws synchronously. (See Issue #2333.)
  • Throwing a plain Error from .custom() produces any.custom error — the thrown message is preserved as err.context.error.message but user-facing string is “…failed custom validation”. Use helpers.error('your.code') + .messages() for clean output.
  • Custom error messages on .external() have known gaps in some 17.x versions (see Issue #3047). Workaround: throw a Joi.ValidationError instance constructed manually.
  • Multiple .external() errors don’t aggregate the same way as core errors — first throw stops external processing for that schema branch.

Joi blurs the line on purpose: with default convert: true, calling .trim(), .lowercase()/.uppercase(), .normalize() (Unicode NFC/NFD/NFKC/NFKD), .replace(regex, str), .truncate() and date/number coercions all mutate the output value while the rules still pass. Great for normalizing user input (" Foo@Bar.com ""foo@bar.com") before it hits your service layer.

But Joi is explicitly not a security sanitizer: it does not strip HTML/JS, escape SQL, normalize URL schemes against an allowlist, or guard against prototype pollution beyond rejecting unknown keys. There is no Joi.string().xssSafe().

Treat Joi as the shape and normalization layer; layer DOMPurify / sanitize-html for any field destined for HTML rendering, and use parameterized queries / an ORM for SQL — never rely on .pattern() to prevent injection.

  • If convert: false is passed, every transformer (trim, lowercase, normalize) reverts to a checker — input that isn’t already trimmed will fail, not be trimmed.
  • .normalize() defaults to NFC; mixing NFC and NFD in same DB column produces strings that compare unequal but render identically.
  • .replace() runs in convert mode and changes values silently — a regex bug can mangle data without an error.
  • stripUnknown: true removes unknown keys but does not protect against object-shape attacks if you then Object.assign(model, value). Pluck whitelisted fields when persisting.
  • Joi’s URI/email/domain validators are RFC-shaped, not security checks. A valid URL can still point to javascript: — enforce scheme: ['http','https'] explicitly.

Two performance facts dominate:

  1. Schema construction is expensiveJoi.object({...}) walks the chain, deep-clones, and freezes. Build it once at module load, not inside the request handler.
  2. .when() regenerates the runtime schema per call (cached by argument identity, but the first hit per unique condition path is paid).

Joi 17 added an internal cache enabled per-schema via .cache(), but it only memoizes on simple primitive inputs.

Joi.attempt() validates and throws on failure (handy in scripts/CLIs, dangerous in handlers because it bypasses your error formatter). Joi.assert() is the same but discards the value.

The default abortEarly: true is great for high-throughput health-checks (stop on first error) but wrong for forms — every API should use abortEarly: false.

  • Joi.compile(plainObj) is convenient but is itself a cost; if you pass a plain object literal to Joi.assert/attempt per call, you re-compile every time. Assign to a const once.
  • abortEarly: true (the default) means a successful-but-noisy field can mask a more important error elsewhere — order of keys then matters.
  • Joi.attempt() throws a ValidationError whose .message is human-readable; if you let it bubble through Express it serializes to [object Object] unless you have a real error middleware.
  • .cache() only helps when the same value is validated repeatedly against the same schema — useless for unique request bodies. Mostly relevant for config/env validation.
  • convert: true mutates the input object in-place if it’s a primitive wrapper; always read from the returned value.
LibraryTypeScript storyStyleSweet spot
Joi 17Ships its own .d.ts types; no schema → type inference. External tools like joi-to-typescript exist.Fluent builder, runtime-only, no decorators or codegen.Server-side Node: rich rules, .when/.external, hapi/Express ecosystems, government/enterprise APIs where stability matters more than DX.
ZodTypeScript-first; z.infer<typeof schema> derives the type.Fluent, runtime + compile-time.Greenfield TS apps; full-stack TS where one schema feeds API + form + DB types.
YupTS support but weaker inference than Zod.Fluent, similar API to Joi.Frontend forms (Formik integration); lighter than Joi.
class-validatorDecorators on TS classes; couples to class-transformer.OOP, decorator-driven.NestJS / Angular DI projects where DTOs are classes anyway.

Why pick Joi:

  • Hapi heritage — many regulated/enterprise Node shops standardized on hapi 5–10 years ago and never migrated. Joi is the in-house default.
  • Maturity & API stability — Joi 17 has been the major line for years; breaking changes are rare and well-documented. Procurement/security teams favor low-churn dependencies.
  • Runtime-only, no codegen, no decorators — works with vanilla JS and any TS config (no experimentalDecorators, no build-time type generation pipeline).
  • Rich conditional/cross-field validation (.when(), Joi.ref(), $context) more expressive out-of-the-box than Yup or class-validator.
  • .describe() introspection — schemas are reflectable, useful for auto-generating OpenAPI / admin UIs.

Honest tradeoff: for a brand-new TypeScript service, Zod is usually the better pick for the inferred types. For an established Node/Express codebase — especially one with hapi roots — Joi is the safer, more idiomatic choice. Validation semantics are equivalent; it’s mostly a DX question.