Joi 17 and Yup 1.x for Legacy Systems
Legacy JavaScript services rarely ship a formal OpenAPI definition, yet they still pass JSON across boundaries that downstream code trusts implicitly. Joi 17 and Yup 1.x let you bolt a runtime contract onto those payloads without rewriting the service. This guide extends Schema Design & Validation Patterns by focusing on the two validators you actually reach for in pre-TypeScript codebases: how their schema APIs differ, how each handles async checks and custom rules, where their coercion behaviour will bite you, and how to plan an exit toward runtime validation with Zod once you adopt TypeScript.
The running example is a User payload that an older service accepts on a POST /users route. We harden it with both libraries side by side so the API differences are concrete rather than abstract.
When to Use This Approach
Reach for Joi or Yup — rather than a JSON Schema validator or Zod — when the constraints below hold:
- The codebase is JavaScript, not TypeScript. Joi and Yup give zero compile-time type inference. If you are on TypeScript, the duplicate “type plus schema” maintenance cost usually justifies Zod instead.
- You cannot introduce a build step. Both libraries run as plain CommonJS in Node 12+/14+ with no transpilation, which matters for services still on older toolchains.
- You need validation now, not a spec rewrite. Authoring an OpenAPI document and generating validators is the cleaner long-term path, but a Joi or Yup schema can wrap an undocumented endpoint in an afternoon.
- A form library already depends on Yup. Formik and many React Hook Form setups integrate with Yup natively, so client-side reuse is free.
- You want a single rule set for HTTP and queue messages. A schema object validates a request body and a Kafka message payload identically.
If you are starting fresh on TypeScript with no legacy constraint, skip this and go straight to Zod.
One more decision shapes everything that follows: where the validation runs. In a legacy system the safest insertion point is a single middleware layer at the HTTP boundary, because it lets you add a contract without touching the handlers that already mutate the payload. Validating deeper — inside service functions or ORM hooks — spreads the same rules across many call sites and makes the eventual Zod migration far harder. Keep one schema, one boundary, one place to change.
Prerequisites
Pin exact versions — validator behaviour changed meaningfully between Joi 16 and 17, and between Yup 0.32 and 1.x (Yup 1.0 was a breaking rewrite). Install one or both:
# Joi 17 (Node 12+). Pure JS, no peer deps.
npm install joi@17.13.3
# Yup 1.4 (Node 14+). Ships ESM + CJS builds.
npm install yup@1.4.0
For the Express middleware example you also need express@4. The TypeScript snippets assume typescript@5.4 with "strict": true; they run equally as plain .js if you strip the annotations.
A core discipline applies to everything below: disable coercion globally. Both libraries silently cast types by default, which is the single most common way a legacy contract leaks bad data.
Joi vs Yup: Feature Matrix
The matrix summarises the trade-offs explored below; the deeper API-by-API breakdown lives in comparing Joi and Yup validation APIs.
Step 1: Install Pinned Versions and a Shared Config
Centralise the coercion-off decision so it cannot be forgotten per schema. A single config module keeps both libraries strict by default.
// validation/config.js
const Joi = require('joi');
// One Joi instance with safe defaults baked in.
// convert:false -> no implicit casting ("123" stays a string failure)
// allowUnknown:false -> reject undocumented keys
// stripUnknown:true -> remove them from the returned value instead of erroring,
// when you deliberately want to tolerate legacy proxy metadata
const joi = Joi.defaults((schema) =>
schema.options({ convert: false, allowUnknown: false }),
);
module.exports = { joi };
Yup has no global defaults object; you enforce strictness per schema with .strict() and .noUnknown(), or pass { strict: true } at validate time. We apply it on the schema itself so callers cannot accidentally re-enable coercion.
Why pin exact versions: Joi 17 and Yup 1.x both changed validation semantics from their previous majors. A floating ^ range can silently upgrade a transitive copy and change how a borderline payload validates — exactly the kind of drift this whole exercise is meant to prevent.
Step 2: Author Strict Schemas from Real Payloads
Do not invent the schema from the route handler’s optimistic assumptions. Pull representative bodies from production logs or gateway traces, including the malformed ones, and model what the field actually receives. Legacy fields often accept mixed types — a status that is sometimes a string and sometimes a numeric flag — which you encode explicitly rather than coerce away.
// validation/userSchema.joi.js
const { joi } = require('./config');
// stripUnknown only takes effect when allowUnknown is also true; here we keep
// allowUnknown:false (from defaults) so unexpected keys hard-fail.
const joiUserSchema = joi.object({
id: joi.number().integer().positive().required(),
email: joi.string().email({ tlds: false }).allow(null).optional(),
// Polymorphic legacy field: a string enum OR a 0/1 flag.
status: joi
.alternatives()
.try(
joi.string().valid('active', 'inactive'),
joi.number().integer().valid(0, 1),
)
.required(),
createdAt: joi.string().isoDate().required(),
});
module.exports = { joiUserSchema };
// validation/userSchema.yup.ts
import * as yup from 'yup';
// .strict() refuses coercion; .noUnknown() strips/rejects extra keys.
export const yupUserSchema = yup
.object({
id: yup.number().integer().positive().required(),
email: yup.string().email().nullable().optional(),
// Yup oneOf does NOT auto-allow null — list every accepted literal.
status: yup
.mixed<'active' | 'inactive' | 0 | 1>()
.oneOf(['active', 'inactive', 0, 1])
.required(),
createdAt: yup.string().required(),
})
.strict()
.noUnknown();
Two differences already surface. Joi expresses “string enum or numeric flag” with alternatives().try(); Yup leans on mixed().oneOf() with an explicit literal list. And Yup’s oneOf will reject null unless you add it to the set — a frequent false negative when porting a Joi .valid() list across.
A subtler trap is the email validator. Joi 17 validates the domain’s top-level domain against a built-in list by default, so a perfectly valid internal address like svc@internal fails until you pass { tlds: false }. Legacy systems are full of such non-public hostnames, so disable TLD checking unless you genuinely need public-domain enforcement. Yup’s email() uses a permissive regex and does not perform TLD lookup, which is more forgiving but also weaker — if strict email shape matters, layer a .matches() rule on top.
Resist the temptation to start lenient and tighten later. A schema that accepts everything provides false confidence; downstream code reads it as a guarantee. Begin strict, capture the rejections CI surfaces against real fixtures, and relax only the specific rules that legitimate traffic violates. Each relaxation should be a deliberate, commented decision — allow(null) because the mobile client sends null for unset, not because tightening was inconvenient.
Step 3: Add Custom and Async Validators
Built-ins cover format; business rules need custom logic. The classic legacy case is a compact date like YYYYMMDD that predates ISO adoption, plus an async uniqueness check against the database.
// Joi: synchronous custom rule via .custom(), async via external() + validateAsync
const { joi } = require('./config');
const legacyDate = joi.string().custom((value, helpers) => {
// Reject anything that is not exactly 8 digits forming a real date.
if (!/^\d{8}$/.test(value)) return helpers.error('date.legacyFormat');
const y = +value.slice(0, 4), m = +value.slice(4, 6), d = +value.slice(6, 8);
const parsed = new Date(Date.UTC(y, m - 1, d));
if (parsed.getUTCMonth() !== m - 1) return helpers.error('date.legacyFormat');
return value; // value is unchanged; return a transformed value to coerce
}, 'legacy YYYYMMDD').messages({ 'date.legacyFormat': '{#label} must be YYYYMMDD' });
const joiUserSchemaAsync = joi.object({
email: joi
.string()
.email({ tlds: false })
.external(async (value) => {
// Runs only with schema.validateAsync(...). Throw to fail.
const taken = await userRepo.emailExists(value);
if (taken) throw new Error('email already registered');
}),
signupDate: legacyDate.required(),
});
// Yup: custom + async both flow through .test()
import * as yup from 'yup';
const legacyDate = yup.string().test(
'legacy-date',
'${path} must be YYYYMMDD',
(value) => !!value && /^\d{8}$/.test(value),
);
const yupUserSchemaAsync = yup.object({
email: yup
.string()
.email()
// async test: return a Promise<boolean>. Only honoured by validate(),
// NOT validateSync() — calling validateSync throws on an async test.
.test('unique-email', 'email already registered', async (value) =>
value ? !(await userRepo.emailExists(value)) : true,
),
signupDate: legacyDate.required(),
});
The async story is the sharpest divergence. Joi separates async rules into .external() and forces validateAsync(); Yup folds async into the same .test() API but silently fails if you call the synchronous entry point. Both pitfalls and their event-loop implications are covered in depth in handling async validation in Joi schemas.
Step 4: Wire Validation into Request Middleware
Validate at the boundary, once, and hand the handler a value you trust. Define the schema at module load — never inside the handler — so the parse cost is paid once. Map failures to a structured contract that aligns with designing robust error response contracts.
// middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { joiUserSchema } from '../validation/userSchema.joi';
export function validateUser(req: Request, res: Response, next: NextFunction) {
// abortEarly:false collects all errors; validateAsync if any .external() rules exist.
const { error, value } = joiUserSchema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(422).json({
error: 'VALIDATION_FAILED',
details: error.details.map((d) => ({
field: d.path.join('.'),
rule: d.type, // e.g. "number.base", "any.required"
message: d.message,
})),
});
}
req.body = value; // coercion-free, unknown-stripped payload
return next();
}
For schemas with async rules, switch to await joiUserSchema.validateAsync(req.body, { abortEarly: false }) inside a try/catch, and catch Joi.ValidationError to build the same 422 body. The Yup equivalent uses await yupUserSchema.validate(req.body, { abortEarly: false }) and catches yup.ValidationError, mapping err.inner to the details array.
Step 5: Gate the Contract in CI
A schema that only runs in production is documentation, not a gate. Validate a fixture directory of captured payloads in CI so any drift fails the build before merge.
# .github/workflows/schema-contract-gate.yml
name: Schema Contract Gate
on: [pull_request]
jobs:
validate-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
# Runs the schema suite against ./fixtures/legacy-payloads/*.json
- run: npm run test:contract -- --fail-fast
env:
STRICT_MODE: 'true'
Keep both “good” and “known-bad” fixtures: good payloads must pass, bad payloads must fail with the expected rule code. That two-sided assertion catches a schema that has quietly stopped rejecting anything.
Treat the fixture set as a living contract artefact. When a real production payload triggers an unexpected rejection, add it to the corpus before changing the schema — the fixture becomes a permanent regression guard, and the diff documents exactly which field tolerance changed and why. Over time this directory becomes a more honest specification of the endpoint than any prose document, because it is executable and cannot drift from the validator. For higher-volume suites, validate fixtures concurrently with Promise.allSettled() over chunked arrays so the job stays well under typical CI step timeouts even as the corpus grows into the thousands.
Step 6: Plan the Zod Migration Path
Joi and Yup force you to declare a type and a schema separately and keep them in sync by hand. The moment the project adopts TypeScript, that duplication becomes the dominant maintenance cost, and Zod — which infers the static type from the schema — pays off. Migrate one schema at a time behind the same middleware boundary so the cutover is invisible to handlers. The field-by-field rule mapping and a runnable codemod approach are in migrating Joi schemas to Zod for TypeScript projects.
Sequence the migration to minimise risk. Start with leaf schemas that have no async or custom rules — they map almost mechanically. Move shared and recursive schemas next, since they exercise Zod’s z.lazy() and discriminated unions and are where behaviour most often differs. Save async-heavy schemas for last, because Zod’s async validation (parseAsync) and Joi’s .external() differ enough that you want the easy wins banked first. Throughout, keep the existing fixture corpus pointed at the new schema: if the Zod version rejects a payload the Joi version accepted, that is a behaviour change to investigate, not a green light to delete the fixture. Run both validators in parallel on production traffic for a short shadow period and log only the disagreements — that comparison is the cheapest possible insurance against a silent contract regression.
// The same User contract, expressed in Zod 3.23 — type is inferred, not duplicated.
import { z } from 'zod';
export const userSchema = z.object({
id: z.number().int().positive(),
email: z.string().email().nullable().optional(),
status: z.union([z.enum(['active', 'inactive']), z.literal(0), z.literal(1)]),
createdAt: z.string().datetime(),
}).strict(); // .strict() rejects unknown keys; no coercion by default
export type User = z.infer<typeof userSchema>; // <- the payoff
Schema API Reference
| Concern | Joi 17 | Yup 1.x | Default behaviour |
|---|---|---|---|
| Object root | Joi.object({...}) |
yup.object({...}) |
both require explicit shape |
| Disable coercion | .options({ convert: false }) |
.strict() |
both coerce ON by default |
| Reject unknown keys | allowUnknown: false |
.noUnknown() |
Joi rejects, Yup strips, by default |
| Required | .required() |
.required() |
optional unless set |
| Nullable | .allow(null) |
.nullable() |
null rejected by default |
| Enum / literal set | .valid('a','b') |
.oneOf(['a','b']) |
Yup oneOf excludes null |
| Polymorphic field | .alternatives().try(...) |
.mixed().oneOf(...) |
— |
| Custom sync rule | .custom(fn, label) |
.test(name, msg, fn) |
— |
| Async rule | .external(fn) + validateAsync |
async .test() + validate |
Yup validateSync throws on async |
| Collect all errors | { abortEarly: false } |
{ abortEarly: false } |
both stop at first error |
| Error array | error.details |
err.inner |
shapes differ |
Verification
Confirm the contract behaves with a focused test that asserts both acceptance and rejection, including the coercion trap.
// userSchema.test.js — run with: npx jest userSchema
const { joiUserSchema } = require('./validation/userSchema.joi');
test('accepts a valid payload', () => {
const { error } = joiUserSchema.validate({
id: 42, email: 'a@b.co', status: 'active', createdAt: '2026-06-20T00:00:00Z',
});
expect(error).toBeUndefined();
});
test('rejects coerced string id (coercion is off)', () => {
const { error } = joiUserSchema.validate({
id: '42', email: null, status: 1, createdAt: '2026-06-20T00:00:00Z',
});
expect(error.details[0].type).toBe('number.base'); // not silently cast
});
A passing run prints Tests: 2 passed. The second test is the important one: if it fails because '42' was accepted, coercion is still on somewhere — re-check that convert: false reached the schema.
Troubleshooting
RangeError: Maximum call stack size exceeded on a self-referencing schema
A schema that references itself (a comment with nested replies) recurses forever at build time. Wrap the recursive branch in Joi.link('#self') with a schema .id('self'), or yup.lazy(() => schema), so the reference resolves lazily per value instead of eagerly at definition.
A string "123" passes a number field
Coercion is still enabled. For Joi, the schema did not receive convert: false — confirm it inherits from the Joi.defaults(...) instance, not the bare Joi import. For Yup, you called .validate() without .strict() on the schema or { strict: true } at the call site.
Cannot validate a Yup schema with an async test using validateSync
A .test() returns a Promise but the code path calls validateSync() (or Formik’s sync validation). Switch to await schema.validate(value). If a form forces synchronous validation, move the async check out of the schema into a separate submit-time call.
Joi .external() rule never runs
.external() only executes under validateAsync(). A plain .validate() skips it silently and returns “valid”. Audit every route that uses an async schema and ensure it awaits validateAsync.
Unknown keys leak through despite stripUnknown: true
stripUnknown only acts when allowUnknown is also true; with allowUnknown: false (the strict default here) unknown keys hard-fail instead. Decide which you want per endpoint — strip-and-pass for tolerant legacy proxies, hard-fail for new internal routes — and set the pair explicitly.
Frequently Asked Questions
Should I use Joi or Yup for a new legacy-codebase feature?
If the code is plain JavaScript or Node-only, prefer Joi 17 — it has richer built-in validators, first-class async support, and no peer-dependency surprises. If the feature touches a React form already using Formik, use Yup 1.x because the form library integrates with it natively.
Does Joi or Yup coerce types by default?
Both coerce by default. Joi 17 casts unless you pass convert: false in options. Yup 1.x casts during validate unless you call strict(true). Always disable coercion when validating API contracts so a string "123" never silently passes a number field.
Can I run Joi and Yup in older Node versions?
Joi 17 supports Node 12 and newer. Yup 1.x targets Node 14 and newer and ships ES module and CommonJS builds. Both run in the browser, though Joi’s bundle is larger, so Yup is the common choice for client-side form validation.
How do I do async validation, like a uniqueness database check?
In Joi use an external async function with schema.validateAsync, or register a custom rule with extend. In Yup use test with an async function and call schema.validate, not validateSync. Never block the event loop with synchronous I/O inside a validator.
Is it worth migrating from Joi or Yup to Zod?
If you are adopting TypeScript, yes. Zod infers static types from the schema, eliminating the duplicate type-plus-schema definitions Joi and Yup require. Migrate incrementally, schema by schema, behind the same validation middleware boundary.
Why does my Yup oneOf reject a valid null value?
oneOf does not implicitly allow null. Chain nullable and add null to the allowed set, or use when to relax the constraint. This is a frequent source of false negatives when porting Joi valid lists to Yup.