Skip to main content

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.