Capítulo 17 de 61

Chapter 17: Recursive Objects

Core Idea

Self-referential and mutually-recursive schemas are defined using a JS getter on the recursive key, letting the reference resolve lazily at runtime instead of at schema-definition time.

Key Concepts

  • Getter-based recursion: define the recursive field as get fieldName() { return schema } instead of a plain property, so the reference to the (not-yet-fully-defined) schema resolves lazily.
  • Mutual recursion: two schemas can reference each other the same way, each via a getter pointing at the other.
  • Object-only limitation: recursive inference reliably works only when getters return object schemas that reference each other directly; mixing in non-object types or nested function calls (z.union([...]), z.optional(SomeArray)) often breaks TypeScript's inference.
  • Circularity type errors: TypeScript sometimes can't infer a getter's return type in a recursive schema (implicitly has return type 'any'); fix by adding an explicit return-type annotation on the getter.
  • Cyclical data is unsupported: the schema itself can be recursive, but parsing actual circular data (an object that references itself) will infinite-loop.

Code Examples

const Category = z.object({
  name: z.string(),
  get subcategories() {
    return z.array(Category);
  },
});
// type Category = { name: string; subcategories: Category[] }

// mutually recursive types
const User = z.object({
  email: z.email(),
  get posts() { return z.array(Post); },
});
const Post = z.object({
  title: z.string(),
  get author() { return User; },
});

// fixing a circularity type error with an explicit annotation
const Activity = z.object({
  name: z.string(),
  get subactivities(): z.ZodNullable<z.ZodArray<typeof Activity>> {
    return z.nullable(z.array(Activity));
  },
});
  • What it demonstrates: self-reference and mutual reference via getters, plus the type-annotation escape hatch for inference failures.

Anti-patterns

  • Parsing cyclical (self-referencing) data: a recursive schema is fine; recursive data passed into .parse() causes an infinite loop.
  • Nesting function calls inside a recursive getter: z.union([z.null(), Activity]) inside a getter is harder for TypeScript to infer than an equivalent method chain — prefer methods over standalone functions in recursive contexts, especially in Zod Mini.

Key Takeaways

  1. Getters are the mechanism, not a workaround — they're how Zod resolves the "schema references itself before it's finished being defined" problem.
  2. When TypeScript can't infer a recursive getter's return type, add the type annotation explicitly rather than fighting the inference.
  3. All standard object methods (.pick(), .omit(), .partial(), etc.) still work normally on recursive schemas.

Connects To

  • Objects — Definition, Strictness & Shape: recursive schemas are still regular object schemas underneath.
  • Unions: a common source of recursive-inference errors when mixed into a recursive getter.