Capítulo 58 de 61

Chapter 58: Migration Guide — Refine, Record & Intersection Changes

Core Idea

Several smaller-surface-area APIs changed shape in Zod 4: ctx.path was dropped from refinement context for performance reasons, z.record() now always requires an explicit key schema, enum-keyed records became exhaustive by default, and intersection merge conflicts throw a plain Error instead of a ZodError.

Reference Tables

AreaZod 3 behaviorZod 4 behavior
ctx.path in .superRefine()availableremoved — eager path evaluation was incompatible with Zod 4's performance architecture
.refine(fn, fnReturningMessage)second-argument-as-function overload supportedremoved — use the error param instead
z.ostring(), z.onumber(), etc.undocumented optional-schema shortcutsremoved
z.literal(someSymbol)accepted symbolsrejected — symbols aren't literal-comparable
SomeZodClass.create()static factory method on every classremoved — use the standalone z.xxx() factory functions
z.record(valueSchema)single-argument form (implicit string key)requires two arguments: z.record(keySchema, valueSchema)
z.record(z.enum([...]), valueSchema)inferred type was partial ({ a?: number })exhaustive by default ({ a: number }); use z.partialRecord() for the old partial behavior
Intersection merge conflictthrew ZodError with "invalid_intersection_types"throws a plain Error — treated as a schema design problem, not a validation failure

Code Examples

// z.record() now always needs both key and value schemas
z.record(z.string());               // Zod 3 only — no longer valid
z.record(z.string(), z.string());   // Zod 4

// enum-keyed records are exhaustive by default now
const myRecord = z.record(z.enum(["a", "b", "c"]), z.number());
// Zod 4 inferred type: { a: number; b: number; c: number } — all keys required

// use partialRecord to get the old "maybe has these keys" behavior
const partial = z.partialRecord(z.enum(["a", "b", "c"]), z.number());
// { a?: number; b?: number; c?: number }
  • What it demonstrates: the mandatory two-argument z.record() form and the exhaustive-by-default enum-key behavior, with z.partialRecord() as the escape hatch.

Anti-patterns

  • Relying on .refine()'s second-argument-function overload for dynamic messages: removed — use { error: (iss) => ... } instead.
  • Passing a single argument to z.record(): no longer valid; always supply both key and value schemas.

Key Takeaways

  1. Any code using z.record() with one argument, or expecting enum-keyed records to be partial, needs updating for Zod 4's stricter, more TypeScript-faithful defaults.
  2. Intersection merge failures now signal a genuine schema design problem (incompatible types being intersected) — catching them as ZodError no longer works; catch a plain Error instead.
  3. ctx.path removal is a performance trade-off, not an oversight — don't expect it to come back; restructure refinements that depended on it to use the schema structure itself for path context.

Connects To

  • Records: the current, stricter z.record()/z.partialRecord()/z.looseRecord() API this section migrates toward.
  • Refinements: the current .refine() API and its error/abort/path params.