Skip to main content

Migrating Joi schemas to Zod for TypeScript projects

Symptom & Error Signature

CI/CD contract tests fail immediately upon schema migration. Pact consumer-driven contracts break due to silent type coercion differences between legacy validation layers and the new runtime parser. The validation pipeline throws the following exact error output:

ZodError: [ { "code": "invalid_type", "expected": "string", "received": "number", "path": ["user_id"], "message": "Expected string, received number" } ]

Secondary failures manifest as ZodError: Invalid input on date or UUID fields that previously passed Joi validation.

Root Cause Analysis

Joi implicitly coerces types by default (e.g., "12345"12345, 12345"12345"). Zod enforces strict type parity out-of-the-box. Legacy upstream payloads frequently transmit numeric identifiers, booleans, or ISO dates as JSON strings. When Zod parses these payloads without explicit coercion directives, it rejects them at runtime. This breaks API contract testing pipelines that rely on exact schema parity. Understanding how Schema Design & Validation Patterns handle implicit type casting reveals why Zod’s zero-coercion default causes immediate runtime failures during migration.

Minimal Reproducible Config & Resolution Steps

Legacy Payload (Upstream Service)

{
 "user_id": "12345",
 "is_active": "true",
 "created_at": "2023-10-25T14:00:00Z"
}

Failing Zod Schema

import { z } from "zod";

const UserSchema = z.object({
 user_id: z.string().uuid(), // Fails: receives string, expects strict UUID format
 is_active: z.boolean(), // Fails: receives "true" (string)
 created_at: z.date() // Fails: receives ISO string
});

Step-by-Step Fix

  1. Audit Existing Joi Schemas for Implicit Coercion Identify fields relying on Joi’s automatic casting (Joi.number(), Joi.string().uuid(), Joi.date(), Joi.boolean()). Map these to Zod equivalents and flag upstream payload contracts that deviate from strict typing.

  2. Replace Strict Primitives with .coerce or z.preprocess() Apply coercion-aware schemas only for known legacy fields. Avoid blanket .coerce usage to preserve type safety.

const UserSchema = z.object({
user_id: z.coerce.string().uuid(),
is_active: z.coerce.boolean(),
created_at: z.coerce.date()
});

For complex transformation logic, use explicit preprocessing:

const legacyIdSchema = z.preprocess(
(val) => String(val),
z.string().uuid()
);
  1. Update CI Validation Runner Replace .parse() with schema.safeParseAsync(payload) in your test harness. Intercept !result.success states to log coercion deltas and field paths before hard-failing the pipeline. This isolates contract drift from validation logic.

  2. Verify OpenAPI Parity Run npx zod-to-json-schema against the patched schema. Diff the generated JSON against the existing Pact contract. Ensure type definitions align before merging to main.

Prevention & Contract Governance

Enforce explicit type declarations in OpenAPI specifications. Run automated schema diff checks in pre-commit hooks to catch drift. Mandate .strict() mode across all Zod schemas, applying explicit .coerce overrides only where upstream payloads are immutable and cannot be refactored. Document coercion boundaries and maintain a legacy migration playbook by referencing Joi and Yup for Legacy Systems to ensure platform teams maintain consistent validation contracts across service boundaries.