Open3

Zod v4のRecursive Objectでz.unionを使う方法

TechondoriusTechondorius

これまで

Zodで再帰的なオブジェクトスキーマ(Recursive Object)を定義しようとするとき、v3まではz.lazy()を使用しないとエラーが発生していた

import { z } from "zod";

const objA = z.object({
  a: z.string(),
});

const objB = z.object({
  b: z.string(),
});

const recursiveNg = z.object({
  value: z.union([objA, objB, recursive]),
  // ブロック スコープの変数 'recursive' が、宣言の前に使用されています。ts(2448)
});

ビルドが通るようにするにはこうする必要があった

import { z } from "zod";

const objA = z.object({
  a: z.string(),
});
+ type ObjA = z.infer<typeof objA>;

const objB = z.object({
  b: z.string(),
});
+ type ObjB = z.infer<typeof objB>;

+ type RecursiveOk = {
+   value: ObjA | ObjB | RecursiveOk;
+ };

- const recursiveNg = z.object({
-   value: z.union([objA, objB, recursive]),
- });
+ const recursiveOk: z.ZodType<RecursiveOk> = z.object({
+   value: z.lazy(() => z.union([objA, objB, recursiveOk])),
+ });

個人的にはtype RecursiveOkを定義しないと型推論として使えないのが非常に腹立たしかった

TechondoriusTechondorius

v4でどうなったのか(失敗例)

Introducind Zod 4によれば、 Recursive objects が簡単に書けるようになったらしい

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

先程のケースのを書き換えてみるとこうなる

import { z } from "zod/v4";

const objA = z.object({
  a: z.string(),
});

const objB = z.object({
  b: z.string(),
});

const recursiveV4Ng = z.object({
  get value() {
    return z.union([objA, objB, recursiveV4Ng]);
    // マップされた型 '{ -readonly [P in keyof { readonly value: ZodUnion<readonly [ZodObject<{ a: ZodString; }, $strip>, ZodObject<{ b: ZodString; }, $strip>, ZodObject<..., $strip>]>; }]: { ...; }[P]; }' で、プロパティ 'value' の型によってそれ自体が循環参照されています。ts(2615)
  },
});

マップされた型 '{ -readonly [P in keyof { readonly value: ZodUnion<readonly [ZodObject<{ a: ZodString; }, $strip>, ZodObject<{ b: ZodString; }, $strip>, ZodObject<..., $strip>]>; }]: { ...; }[P]; }' で、プロパティ 'value' の型によってそれ自体が循環参照されています。ts(2615)

どうやらz.union()はひと手間加える必要があるらしい

TechondoriusTechondorius

v4でどうなったのか(結論)

返り値の型を定義してあげればいいらしい

import { z } from "zod/v4";

const objA = z.object({
  a: z.string(),
});

const objB = z.object({
  b: z.string(),
});

const recursiveV4Ok = z.object({
-   get value() {
+   get value(): z.ZodUnion<readonly [typeof objA, typeof objB, typeof recursiveV4Ok]> {
    return z.union([objA, objB, recursiveV4Ok]);
  },
});