🦔

Zod 再帰型の新しい解決方法

に公開

はじめに

先日、Zodの創設者であるColin McDonnell氏が興味深いツイートを投稿しました。
(ツイートはこちら)

"whoa. I just found a way to properly infer recursive types in z.object() — no casting, no z.lazy(), no scopes/registries, no special syntax. i've been trying to do this for literally years"

つまり、z.object()で再帰型(recursive type)をより型安全(type-safe)に推論できる新しい方法を見つけたという内容です。従来は再帰型の型推論がうまくいかない問題がありましたが、今回その解決策が示されました。

問題状況:従来の再帰型の限界

この問題がすぐにはピンとこないかもしれませんが、例を使って説明します。

例えば、コメントとその返信、またはメニューとサブメニューのようなツリー構造(tree structure)をZodで検証すると仮定しましょう。

const Comment = z.object({
  id: z.string(),
  content: z.string(),
  replies: z.array(Comment) // Commentがまだ完成していない
});

上記のコードではrepliesフィールドがCommentを配列として再参照しています。しかし、この時点ではCommentがまだ完全に定義されていないため、次のような状況になります。

const Comment = z.object({
  id: z.string(),
  content: z.string(),
  replies: z.array(undefined) // Commentがまだ完成していないのでundefinedになる
});

従来の解決策:z.lazy()の登場

この問題を解決するために、一般的にはz.lazy()メソッドが使われてきました。

const Comment = z.object({
  id: z.string(),
  content: z.string(),
  replies: z.lazy(() => z.array(Comment))
});

この方法はz.array(Comment)をz.lazy()関数でラップし、実際に必要になるまで実行を遅延(遅延評価、lazy evaluation)させるものです。

z.lazy()の内部実装

z.lazy()の内部コードは以下のように定義されています(v4基準)。

export interface ZodLazy<T extends core.$ZodType = core.$ZodType> extends ZodType {
  _zod: core.$ZodLazyInternals<T>;
  unwrap(): T;
}
export const ZodLazy: core.$constructor<ZodLazy> = /*@__PURE__*/ core.$constructor("ZodLazy", (inst, def) => {
  core.$ZodLazy.init(inst, def);
  ZodType.init(inst, def);
  inst.unwrap = () => inst._zod.def.getter();
});

export function lazy<T extends core.$ZodType>(getter: () => T): ZodLazy<T> {
  return new ZodLazy({
    type: "lazy",
    getter,
  }) as ZodLazy<T>;
}

z.lazy()の動作概要

簡単に説明すると、lazy関数は渡されたgetter関数をすぐには実行せず、内部に保存します。この関数はZodLazyインスタンスを作成する際に{ type: "lazy", getter }オブジェクトでラップされて渡されます。

生成されたZodLazyインスタンス(instはinstanceの略)は、内部的に_zod.def.getterに元の関数を保持しておき、実際にスキーマが必要なタイミング(パースや検証時)でunwrap()メソッドを通じてgetter()関数を実行し、実際のスキーマを返します。

つまり、定義は先にしておき、実行は後回しにする構造で、遅延評価(lazy evaluation)によって再帰型の問題を回避しています。

z.lazy()の型推論の限界

しかし、ZodLazy方式には型推論の本質的な限界があります。getter関数が実際にどんなスキーマを返すかはランタイムで決まるため、TypeScriptはコンパイル時に正確な型を推論できません。そのため、ZodLazy<any>のようにany型になったり、明示的な型アノテーションが必要になる場合が多くなります。

新しい解決策:getterプロパティの活用

Colin氏のツイートで紹介された新しい方法は、JavaScriptのgetterプロパティ(get property)を活用するものです。例えば、

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

このようにオブジェクト内部にgetterを直接定義すると、getterの実行タイミングがプロパティにアクセスした瞬間に制御されます。つまり、Categoryオブジェクトが完全に生成された後でsubcategoriesを参照するため、循環参照の問題が自然に解決されます。同時にTypeScriptはgetterの返り値の型を正確に推論できます。

v4開発への影響:z.interface()との関係

この発見はv4で導入予定だったz.interface()にも影響を与えました。

z.interface()の実装を正確に見たわけではありませんが、Colin McDonnell氏の発言によると、もともとは循環型推論のために開発されたメソッドだったようです。

結局このメソッドは削除され、実際にgetterを活用する方式で大規模なアップデートが行われました。(関連PR

2025/05/28時点で、すでにこのPRはマージされています。

v3 vs v4:テストコード比較

内容をより明確に理解するため、それぞれの方式のテストコード例を比較してみましょう。

v3: z.lazy()使用例

// v3
// https://github.com/colinhacks/zod/blob/main/packages/zod/src/v3/tests/recursive.test.ts

test("recursion with z.lazy", () => {
  const Category: z.ZodType<Category> = z.lazy(() =>
    z.object({
      name: z.string(),
      subcategories: z.array(Category),
    })
  );
  Category.parse(testCategory);
});

v4: getterプロパティ使用例

// v4
// https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/tests/lazy.test.ts

test("recursive types using getter property", () => {
  const data = {
    name: "I",
    subcategories: [
      {
        name: "A",
        subcategories: [
          {
            name: "1",
            subcategories: [
              {
                name: "a",
                subcategories: [],
              },
            ],
          },
        ],
      },
    ],
  };

  const Category = z.object({
    name: z.string(),
    get subcategories() {
      return z.array(Category);
    },
  });
  Category.parse(data);
});

おわりに

上記のテストコードからも分かるように、今後は再帰型の型推論はgetterメソッドを活用することで、より正確かつ安全に行えるようになります。Zodでツリー構造を検証する際に型推論の限界を感じていた方には、特に嬉しい変化ではないでしょうか。

Discussion