Capítulo 57 de 61

Chapter 57: Migration Guide — Objects, Enums, Arrays, Functions & Refine

Core Idea

Zod 4 makes object/enum/array/function APIs more consistent and TypeScript-sound: defaults now apply inside optional fields, .strict()/.passthrough()/.merge() are superseded by clearer top-level equivalents, z.function() becomes a factory instead of a schema, and .refine() no longer narrows types via type predicates.

Reference Tables

AreaZod 3 behaviorZod 4 behavior
Defaults inside optional object fieldsnot applied — z.object({ a: z.string().default("tuna").optional() }).parse({}){}applied — same call → { a: "tuna" }
.strict() / .passthrough()instance methodsdeprecated (still work) in favor of z.strictObject() / z.looseObject()
.strip()instance methoddeprecated — was already the default; use z.object(A.shape) to convert
.nonstrict()deprecated alias for .strip()removed
.deepPartial()deprecatedremoved entirely, no direct replacement
z.any()/z.unknown() object keysinferred as optionalrequired (key must exist, value may be undefined); enforced at parse time as of v4.4.0
.merge()method for combining object schemasdeprecated in favor of .extend() or object spread (better tsc performance, no strictness ambiguity)
z.nativeEnum()separate API for TS enumsdeprecated — z.enum() now accepts TS enums directly; redundant .Enum/.Values aliases removed, .enum is canonical
z.array().nonempty()inferred type [T, ...T[]]inferred type is plain T[] (use z.tuple([T], T) for the old tuple-shaped type)
z.promise()usable schemadeprecated — await the value before parsing instead
z.function()a ZodFunction schema built via .args()/.returns()a factory taking { input, output } upfront, not itself a schema; adds .implementAsync()
.refine() with a type-predicate functioncould narrow the schema's inferred type (undocumented)no longer narrows — the schema's type stays as declared

Code Examples

// defaults now apply inside optional fields
z.object({ a: z.string().default("tuna").optional() }).parse({});
// Zod 4: { a: "tuna" }  (Zod 3: {})

// strict/loose object: prefer the top-level functions
z.strictObject({ name: z.string() });
z.looseObject({ name: z.string() });

// merge → extend or spread
const Extended = z.object({ ...BaseSchema.shape, ...AdditionalSchema.shape });

// nativeEnum → enum accepts TS enums directly
enum Color { Red = "red", Green = "green" }
const ColorSchema = z.enum(Color);
ColorSchema.enum.Red; // canonical accessor — .Enum/.Values removed

// z.function() is now a factory, not a schema
const myFunction = z.function({
  input: [z.object({ name: z.string(), age: z.number().int() })],
  output: z.string(),
});
myFunction.implement((input) => `Hello ${input.name}`);
  • What it demonstrates: the defaults-in-optional-fields fix, the enum/object API consolidation, and the new function-factory shape.

Anti-patterns

  • Relying on .deepPartial(): removed with no direct replacement — restructure the schema or write the recursive partial logic explicitly if truly needed.
  • Relying on .refine() type predicates to narrow a schema's type: no longer works — use z.custom<T>() or restructure the schema if type narrowing is actually required.

Key Takeaways

  1. The defaults-in-optional-fields change is subtle and can break code that depended on a key being absent rather than defaulted — audit object schemas with both .default() and .optional() on the same field after upgrading.
  2. z.function() no longer being a schema is a structural change, not just a rename — code treating its result as a ZodType (e.g. passing it to .optional()) needs rewriting.
  3. z.enum() fully subsumes z.nativeEnum() now — there's no remaining reason to reach for the deprecated API.

Connects To

  • Objects — Extending & Deriving: the current .extend()/.safeExtend() APIs that replace .merge().
  • Functions: the current z.function()/.implement() API described here.