Migrating Joi Schemas to Zod for TypeScript
CI pipelines start failing the moment you swap Joi for Zod — payloads that validated cleanly for years suddenly produce ZodError: Expected string, received number. This guide is part of the Joi and Yup for Legacy Systems coverage and walks through the exact technical differences between Joi 17 and Zod 3.23, how to map constructs one for one, and how to execute the migration incrementally without breaking consumers.
Root Cause: Coercion by Default vs. Strict Types
The core incompatibility is not syntax — it is how each library interprets a type declaration.
Joi 17 coerces by default. Joi.number() converts the string "123" to 123; Joi.boolean() converts "true" to true; Joi.date() parses ISO strings to Date objects. This behavior is inherited from Joi’s origins as a form-validation library where HTTP input arrives as strings. Legacy services built on Joi often accept payloads that are loosely typed at the wire level because Joi silently normalizes them.
Zod 3.23 enforces strict type parity. z.number() rejects a string, full stop. z.boolean() rejects "true". z.date() rejects an ISO string. Coercion is opt-in via the z.coerce.* namespace, and it behaves differently from Joi’s coercion in edge cases.
The exact runtime failure you will see on unmodified schemas:
ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["user_id"],
"message": "Expected string, received number"
},
{
"code": "invalid_type",
"expected": "boolean",
"received": "string",
"path": ["is_active"],
"message": "Expected boolean, received string"
},
{
"code": "invalid_type",
"expected": "date",
"received": "string",
"path": ["created_at"],
"message": "Expected date, received string"
}
]
The second mismatch is that Joi’s unknown() behavior and Zod’s default behavior are opposite: Joi strips unknown keys only when you call .unknown(false) — by default unknown keys pass through. Zod strips unknown keys by default (equivalent to Joi’s strip mode). Neither library rejects unknown keys unless you explicitly opt in to strict mode: Joi.object().options({ allowUnknown: false }) vs. z.object({}).strict(). Knowing this prevents a class of silent data loss on migration.
Step 1: Audit Coercion Dependencies Before Writing Any Code
Before touching a schema, understand what the upstream payloads actually send. Zod will reject what Joi silently accepted, so you need a complete picture of the wire types, not the Joi schema types.
// Instrument the existing Joi validation to log coercion events.
// Run this in staging for 24-48 hours before migrating.
import Joi from 'joi';
const legacySchema = Joi.object({
user_id: Joi.number(), // upstream sends "12345" — coerced
is_active: Joi.boolean(), // upstream sends "true" — coerced
created_at: Joi.date(), // upstream sends ISO string — coerced
email: Joi.string().email(), // already a string — no coercion
});
// Wrap validation to capture before/after types:
function auditedValidate(payload: unknown) {
const original = structuredClone(payload);
const { error, value } = legacySchema.validate(payload, { convert: true });
if (!error) {
const coercionFields = Object.keys(value as object).filter(
(k) => typeof (original as Record<string, unknown>)[k] !==
typeof (value as Record<string, unknown>)[k]
);
if (coercionFields.length) {
console.warn('[joi-audit] coercion on fields:', coercionFields, {
before: original,
after: value,
});
}
}
return { error, value };
}
Run this for one full traffic cycle in staging. The log output tells you exactly which fields need z.coerce.* in the Zod schema and which are already type-correct at the wire level.
Step 2: Map Joi Constructs to Zod Equivalents
The table below covers the Joi 17 API surface you are most likely to encounter. Where coercion semantics differ, the “Notes” column is precise.
| Joi 17 | Zod 3.23 | Notes |
|---|---|---|
Joi.string() |
z.string() |
Identical — rejects non-strings |
Joi.number() |
z.number() |
Joi coerces strings; use z.coerce.number() for legacy string inputs |
Joi.boolean() |
z.boolean() |
Joi accepts "true"/"false"; z.coerce.boolean() runs Boolean() — see edge cases |
Joi.date() |
z.coerce.date() |
Joi parses ISO strings; z.date() does not — always use z.coerce.date() here |
Joi.string().uuid() |
z.string().uuid() |
Identical |
Joi.string().email() |
z.string().email() |
Joi defaults to tlds: { allow: true } — Zod skips TLD check; behavior close enough for most cases |
Joi.string().uri() |
z.string().url() |
Zod requires a scheme; bare hostnames fail |
Joi.string().min(n) |
z.string().min(n) |
Identical |
Joi.string().max(n) |
z.string().max(n) |
Identical |
Joi.number().integer() |
z.number().int() |
Identical |
Joi.number().min(n) |
z.number().min(n) |
Identical |
Joi.number().max(n) |
z.number().max(n) |
Identical |
Joi.string().optional() |
z.string().optional() |
Zod .optional() allows the key to be absent; .nullable() allows null — Joi conflates these with .allow(null) |
Joi.string().allow(null) |
z.string().nullable() |
Distinct from .optional() in Zod |
Joi.string().allow(null).optional() |
z.string().nullish() |
.nullish() = nullable + optional |
Joi.any() |
z.unknown() |
Prefer z.unknown() — it requires a type assertion before use, surfacing unsafe access |
Joi.array().items(Joi.string()) |
z.array(z.string()) |
Identical |
Joi.object({ ... }) |
z.object({ ... }) |
Default behavior differs — see unknown keys note above |
Joi.object().unknown(true) |
z.object({}).passthrough() |
Preserves unknown keys on output |
Joi.object().unknown(false) |
z.object({}).strict() |
Rejects unknown keys with an error |
Joi.alternatives().try(A, B) |
z.union([A, B]) |
For tagged variants prefer z.discriminatedUnion() |
Joi.when('field', { is: x, then: y }) |
z.discriminatedUnion() or .superRefine() |
No direct equivalent; refactor to discriminated union first |
Joi.string().default('v') |
z.string().default('v') |
Identical |
Joi.object().strip() |
z.object({}) (default) |
Zod strips by default |
Joi.custom((val, helpers) => ...) |
z.string().refine((val) => ...) |
Use .refine() for predicate, .transform() for reshaping |
Step 3: Migrate Incrementally by Validation Boundary
A big-bang replacement risks silent regressions across all consumers simultaneously. The safer pattern is to migrate one validation boundary at a time: the HTTP route handler, the message-queue consumer, the webhook ingestion endpoint. Keep Joi running elsewhere while the new boundary runs Zod.
The dual-validation bridge runs both parsers and surfaces divergence without blocking traffic:
// dual-validate.ts — run in staging until divergence rate reaches zero
import Joi from 'joi';
import { z } from 'zod';
import type { ZodTypeAny } from 'zod';
export function dualValidate<T extends ZodTypeAny>(
joiSchema: Joi.Schema,
zodSchema: T,
payload: unknown,
label: string,
): z.infer<T> {
// Joi result — the authoritative one during transition
const { error: joiError, value: joiValue } = joiSchema.validate(payload, {
convert: true,
abortEarly: false,
});
if (joiError) {
// Joi rejected — log and rethrow Joi error; Zod check skipped
throw new Error(`[${label}] Joi validation failed: ${joiError.message}`);
}
// Zod result against the already-coerced Joi output (safer during migration)
const zodResult = zodSchema.safeParse(joiValue);
if (!zodResult.success) {
// Log divergence for analysis; do NOT throw — Joi accepted, Joi wins for now
console.warn(`[${label}] Zod divergence detected:`, {
payload,
joiValue,
zodErrors: zodResult.error.flatten(),
});
return joiValue as z.infer<T>;
}
return zodResult.data;
}
Once the divergence log for a boundary goes quiet for 48 hours, remove the Joi dependency for that boundary and let Zod be authoritative.
Step 4: Handle Coercion Differences Precisely
Three coercion cases where Zod and Joi behave differently enough to cause production incidents:
Boolean coercion. z.coerce.boolean() runs JavaScript’s Boolean() constructor. Any non-empty string — including "false" — becomes true. Joi’s boolean coercion only accepts "true", "false", "yes", "no", "on", "off" and rejects anything else. Use z.preprocess() for Joi-compatible boolean parsing:
// Joi-compatible boolean: accepts "true"/"false", rejects all else
const joiCompatibleBoolean = z.preprocess((val) => {
if (val === 'true' || val === true) return true;
if (val === 'false' || val === false) return false;
return val; // let z.boolean() reject non-boolean input
}, z.boolean());
Date coercion. Joi’s Joi.date() accepts ISO 8601 strings, Unix timestamps (numbers), and Date objects. z.coerce.date() runs new Date(value), which is close but accepts malformed strings like "not-a-date" (produces Invalid Date). Add a refinement to guard:
const strictIsoDate = z.coerce.date().refine(
(d) => !isNaN(d.getTime()),
{ message: 'Invalid date — must be a valid ISO 8601 string or timestamp' }
);
Number from string with parseInt vs. Number(). z.coerce.number() runs Number(value), so "123abc" becomes NaN and fails the number check. Joi coerces "123abc" similarly. But "0x1F" (hex string) succeeds Number() as 31 — a potential security issue if you validate untrusted IDs this way. Add .int() and range checks:
const safeId = z.coerce.number().int().positive().max(2_147_483_647); // INT4 range
Before and After
A representative schema showing Joi 17 and the migrated Zod 3.23 version side by side:
// ── BEFORE: Joi 17 ────────────────────────────────────────────────────────────
import Joi from 'joi';
const CreateOrderSchema = Joi.object({
order_id: Joi.number().integer().positive().required(),
customer: Joi.string().email().required(),
is_express: Joi.boolean().required(),
placed_at: Joi.date().required(),
tags: Joi.array().items(Joi.string()).default([]),
metadata: Joi.object().unknown(true).optional(),
}).options({ stripUnknown: true });
// No inferred TypeScript type — must be maintained separately.
interface CreateOrder {
order_id: number;
customer: string;
is_express: boolean;
placed_at: Date;
tags: string[];
metadata?: Record<string, unknown>;
}
// ── AFTER: Zod 3.23 ───────────────────────────────────────────────────────────
import { z } from 'zod';
const CreateOrderSchema = z.object({
// upstream sends order_id as a numeric string — coerce it
order_id: z.coerce.number().int().positive(),
customer: z.string().email(),
// upstream sends "true"/"false" — use preprocess for Joi-compatible behavior
is_express: z.preprocess(
(v) => v === 'true' ? true : v === 'false' ? false : v,
z.boolean()
),
// Joi parsed ISO strings; z.coerce.date() does too — add safety refinement
placed_at: z.coerce.date().refine((d) => !isNaN(d.getTime()), {
message: 'placed_at must be a valid date',
}),
tags: z.array(z.string()).default([]),
// metadata passes through unknown keys
metadata: z.record(z.string(), z.unknown()).optional(),
});
// Default Zod behavior strips unknown keys — equivalent to stripUnknown: true
// TypeScript type inferred automatically — stays in sync with validation.
export type CreateOrder = z.infer<typeof CreateOrderSchema>;
The inferred type is no longer a hand-maintained duplicate. Zod derives it from the schema, so they cannot drift. That is the structural improvement that justifies the migration overhead.
Verification
Run these two checks for every migrated boundary before removing the Joi dependency:
1. Type-level verification. TypeScript must agree on the inferred shape:
npx tsc --noEmit
Any Type '...' is not assignable to type '...' error means a field’s Zod coercion changed the output type relative to what downstream code expects.
2. Behavioral parity tests. Write fixtures covering known-good payloads (including the coerced forms your legacy Joi accepted) and known-bad payloads:
import { describe, it, expect } from 'vitest';
import { CreateOrderSchema } from './schemas/order';
describe('CreateOrderSchema — Zod 3.23', () => {
// Known-good: coerced inputs Joi used to accept silently
it('accepts order_id as numeric string (legacy coercion)', () => {
const r = CreateOrderSchema.safeParse({
order_id: '42', // was a number in Joi, now coerced by z.coerce
customer: 'a@b.com',
is_express: 'false', // was boolean string in Joi
placed_at: '2024-01-15T10:00:00Z',
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.order_id).toBe(42); // coerced to number
expect(r.data.is_express).toBe(false); // coerced to boolean
expect(r.data.placed_at).toBeInstanceOf(Date);
expect(r.data.tags).toEqual([]); // default applied
}
});
// Known-bad: type errors that Joi would have caught
it('rejects negative order_id', () => {
const r = CreateOrderSchema.safeParse({ order_id: -1, customer: 'a@b.com',
is_express: true, placed_at: new Date() });
expect(r.success).toBe(false);
});
// Known-bad: malformed date
it('rejects invalid date string', () => {
const r = CreateOrderSchema.safeParse({ order_id: 1, customer: 'a@b.com',
is_express: false, placed_at: 'not-a-date' });
expect(r.success).toBe(false);
});
});
3. OpenAPI contract diff. After migration, regenerate the JSON Schema from Zod and diff it against your existing OpenAPI component:
# Generate JSON Schema from the migrated Zod schema
npx ts-node -e "
import { zodToJsonSchema } from 'zod-to-json-schema';
import { CreateOrderSchema } from './src/schemas/order';
console.log(JSON.stringify(zodToJsonSchema(CreateOrderSchema), null, 2));
" > /tmp/order-zod.json
# Diff against the existing OpenAPI component definition
diff /tmp/order-zod.json openapi/components/schemas/CreateOrder.json
Any diff other than formatting differences signals a breaking contract change. Resolve it before merging. The full treatment of keeping Zod schemas synchronized with OpenAPI is in Runtime Validation with Zod.
Edge Cases and Caveats
Joi.when() conditional schemas have no direct Zod counterpart. Joi’s .when('field', { is: x, then: schemaA, otherwise: schemaB }) is a conditional branch on a sibling field’s value. Zod has no equivalent. The correct refactor is to split the parent object into a discriminated union keyed on the conditional field, which is both cleaner and more type-safe. Where a full refactor is not feasible in one pass, use .superRefine() to assert the conditional constraint and document it clearly.
Joi.extend() custom validators cannot be translated automatically. Custom Joi extensions that call external services or implement domain logic must be manually rewritten as Zod .refine() (synchronous) or async .refine() (asynchronous) methods. Async refinements force safeParseAsync at every call site, which matters on high-throughput routes.
Joi’s abortEarly: false default vs. Zod’s all-errors default. Zod always reports all errors from a parse attempt (abortEarly is effectively always false). If your existing error handling assumes a single-error array (Joi’s default abortEarly: true behavior), your error formatters need updating. error.flatten() returns a structured object with fieldErrors; error.issues gives the flat array. Review how the comparing Joi and Yup validation APIs article maps error formats if you are also considering Yup in this migration.
Frequently Asked Questions
Why does Zod reject data that Joi 17 accepted without errors?
Joi coerces types by default — it converts string '123' to the number 123 for Joi.number(), and 'true' to true for Joi.boolean(). Zod performs no coercion unless you explicitly use z.coerce.*. Any upstream payload relying on silent casting will fail Zod’s strict type check.
Should I replace all Joi schemas at once or file by file?
Migrate incrementally by validation boundary, not by file. Keep Joi running at the service edge while adding Zod schemas to internal layers, verify parity with dual-validation in staging, then cut over boundary by boundary. A big-bang replacement risks silent coercion regressions across all consumers simultaneously.
How do I handle Joi’s .unknown(true) behavior in Zod?
By default Zod strips unknown keys — it is equivalent to Joi’s .unknown(false) plus strip mode. For Joi’s .unknown(true) (passthrough), use z.object({...}).passthrough(). For .unknown(false) strict rejection, use .strict(). Prefer .strict() at API boundaries so payload drift surfaces immediately.
Can I generate Zod schemas automatically from existing Joi schemas?
No stable automated converter exists for the full Joi 17 API surface. The joi-to-zod community tools cover basic primitives but miss custom extensions, .when() conditions, and Joi.alternatives(). Manual migration with a mapping table and dual-validation in tests is the reliable path.
Does z.coerce.boolean() behave like Joi’s boolean coercion?
Not identically. z.coerce.boolean() runs Boolean() on the value, so any truthy string — including 'false' — becomes true. Joi’s boolean coercion only accepts 'true'/'false'/'yes'/'no' strings by default. Use z.preprocess() with explicit string comparison for Joi-compatible boolean parsing.
How do I keep OpenAPI types aligned after the migration?
Run zod-to-json-schema after updating each schema and diff the output against your existing OpenAPI component definitions. Wire this diff into your CI pipeline so schema drift blocks merges before consumers are affected.