Validation (Joi) — Theory
Alternatives — any-of / all-of / one-of
Section titled “Alternatives — any-of / all-of / one-of”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 mode | Semantics |
|---|---|
.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.
Gotchas
Section titled “Gotchas”[Joi.string(), Joi.number()]shorthand silently swallowsJoi.alternatives()features like.match('one'). Use the verbose form when needed.match: 'all'ignoresconvert, 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(...).
Joi.when() — conditional rules
Section titled “Joi.when() — conditional rules”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.
Reference paths
Section titled “Reference paths”Joi.ref('field')— sibling of the current valueJoi.ref('...root')— climb levels (each extra.= one ancestor up)Joi.ref('/root')— from the rootJoi.ref('$ctxKey')— from externalcontextoption
Gotchas
Section titled “Gotchas”is: 'A'actually meansis: Joi.valid(Joi.override, 'A')— it replaces the base. To append a literal useis: 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 includingnull/0/''. Default whenis/not/switchomitted isJoi.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); usealternatives.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.
Gotchas
Section titled “Gotchas”.custom()is sync only — returning a Promise will be treated as a truthy “valid” value..external()only viavalidateAsync(). Calling.validate()throws synchronously. (See Issue #2333.)- Throwing a plain
Errorfrom.custom()producesany.customerror — the thrown message is preserved aserr.context.error.messagebut user-facing string is “…failed custom validation”. Usehelpers.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 aJoi.ValidationErrorinstance constructed manually. - Multiple
.external()errors don’t aggregate the same way as core errors — first throw stops external processing for that schema branch.
Sanitization vs validation
Section titled “Sanitization vs validation”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.
Gotchas
Section titled “Gotchas”- If
convert: falseis 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: trueremoves unknown keys but does not protect against object-shape attacks if you thenObject.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:— enforcescheme: ['http','https']explicitly.
Performance
Section titled “Performance”Two performance facts dominate:
- Schema construction is expensive —
Joi.object({...})walks the chain, deep-clones, and freezes. Build it once at module load, not inside the request handler. .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.
Gotchas
Section titled “Gotchas”Joi.compile(plainObj)is convenient but is itself a cost; if you pass a plain object literal toJoi.assert/attemptper call, you re-compile every time. Assign to aconstonce.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 aValidationErrorwhose.messageis 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: truemutates the input object in-place if it’s a primitive wrapper; always read from the returnedvalue.
Joi vs Zod vs Yup vs class-validator
Section titled “Joi vs Zod vs Yup vs class-validator”| Library | TypeScript story | Style | Sweet spot |
|---|---|---|---|
| Joi 17 | Ships 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. |
| Zod | TypeScript-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. |
| Yup | TS support but weaker inference than Zod. | Fluent, similar API to Joi. | Frontend forms (Formik integration); lighter than Joi. |
| class-validator | Decorators 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.