Zod v4でz.discriminatedUnionはどうなる予定なんだっけ?を見ておいた

「数日前に↓のIssueがクローズされたんだよねー。Zod v4が関係してそう。知らんけど」って会社の同僚と喋ったので、ちゃんと知っておくかーって気持ちになった。

※ 本記事はzod@4.0.0-beta.20250505T195954時点の挙動をもとにしています。正式版で変更される可能性があります。

最初にまとめ

  • z.switchが追加されるとか、z.discriminatedUnionが非推奨になるとかの話はなくなった様子
  • Zod v4 ではz.discriminatedUnionに識別プロパティを渡さないようになった(渡しても無視される)
  • 共通の識別プロパティを持たずに、各オプションがそれぞれリテラルかenum(ネスト可)のプロパティを持つだけでよくなった

Zod v3までのz.unionとz.discriminatedUnion

Issueの内容を説明する前に、現行であるZod v3のz.unionz.discriminatedUnionについておさらいしておく。

z.union

どちらかの型だよという定義。

const stringOrNumber = z.union([z.string(), z.number()]);

パースするときは、前から順番にチェックして最初に成功したものを返す。

パースに失敗した場合は、すべての型に対する失敗内容を返す。例えば、こういうスキーマの場合(あとでDiscriminated Unionと比較するため、わざとそういう定義にしてる)

import { z } from 'zod';

const MyUnion = z.union([
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    kind: z.literal('rectangle'),
    width: z.number(),
    height: z.number(),
  }),
]);

const result = MyUnion.safeParse({
  kind: 'circle',
  radius: 5,
});

console.log(result.error?.message);

実行すると↓こういうエラーメッセージになる(zod 3.24.4で確認)。つまり、全部の型をチェックして、全部のエラーを返す。

[
  {
    "code": "invalid_union",
    "unionErrors": [
      {
        "issues": [
          {
            "received": "circle",
            "code": "invalid_literal",
            "expected": "square",
            "path": [
              "kind"
            ],
            "message": "Invalid literal value, expected \"square\""
          },
          {
            "code": "invalid_type",
            "expected": "number",
            "received": "undefined",
            "path": [
              "size"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      },
      {
        "issues": [
          {
            "received": "circle",
            "code": "invalid_literal",
            "expected": "rectangle",
            "path": [
              "kind"
            ],
            "message": "Invalid literal value, expected \"rectangle\""
          },
          {
            "code": "invalid_type",
            "expected": "number",
            "received": "undefined",
            "path": [
              "width"
            ],
            "message": "Required"
          },
          {
            "code": "invalid_type",
            "expected": "number",
            "received": "undefined",
            "path": [
              "height"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      }
    ],
    "path": [],
    "message": "Invalid input"
  }
]

z.discriminatedUnion

TypeScriptには、共通の識別プロパティを用いることで、安全に型の判別が可能な「Discriminated Union」と呼ばれる型の仕組みがある。

type MyUnion =
  | { status: "success"; data: string }
  | { status: "failed"; error: Error };

このような型を表現するため、Zodにはz.discriminatedUnionメソッドがある。

何が良いかというと、z.discriminatedUnionには識別プロパティを指定するので、どのスキーマをチェックするべきかがすぐ分かる。それにエラーメッセージも分かりやすくなる。

さっきの例のz.unionの部分をz.discriminatedUnionに変えて、kindを識別プロパティとして指定すると

import { z } from 'zod';

const MyUnion = z.discriminatedUnion('kind', [
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    kind: z.literal('rectangle'),
    width: z.number(),
    height: z.number(),
  }),
]);

const result = MyUnion.safeParse({
  kind: 'circle',
  radius: 5,
});

console.log(result.error?.message);

エラーメッセージはこうなる↓

[
  {
    "code": "invalid_union_discriminator",
    "options": [
      "square",
      "rectangle"
    ],
    "path": [
      "kind"
    ],
    "message": "Invalid discriminator value. Expected 'square' | 'rectangle'"
  }
]

いいね。

だから、Discriminated Unionを扱うときは、Zodのスキーマをz.unionではなくz.discriminatedUnionで定義しておくと、パフォーマンスの点でも、エラーメッセージの点でも、良い。

それで、元のIssueは何?

もともとはここから

Deprecating `z.discriminatedUnion`? · Issue #2106 · colinhacks/zod · GitHub

  • Zodのz.unionはパースが成功するまでチェックしていくから、遅いし良くない。
  • Zodのz.discriminatedUnionは実装に2つの問題がある
    • 実装が複雑でそれがエッジケースの問題を引き起こしている
    • 他のスキーマと組み合わせづらい

だから、z.switchを考えた。z.switchができたらz.discriminatedUnionを非推奨にしようかな。というIssue。

そこから冒頭のIssueにつながる

RFC: Faster unions (vs `z.switch()`) · Issue #3407 · colinhacks/zod · GitHub

z.switchを考えてたけどz.unionをいい感じにする方法を思いついたよ!そうすると、ZodでもDiscriminated UnionはUnionの派生という形で扱えるようになるよ!というもの。

そして、Zod v4のベータ版がリリースされて、このIssueはクローズとなった。という流れ。ほぅ…。

Zod v4で結局どうなるの?

z.discriminatedUnionを非推奨にするとかz.switchを導入するとかそういう話はなさそう。

z.unionは識別プロパティをチェックしないUnion、z.discriminatedUnionは識別プロパティをチェックするUnionって棲み分けのままになった様子。ただ、z.discriminatedUnionの仕様は変更されている。

https://v4.zod.dev/v4#upgraded-zdiscriminatedunion

v4のz.discriminatedUnionのドキュメントを読むと、こう書いてある

  • 識別プロパティを指定しなくて良くなったよ。Zodが判別してくれるようになったよ
  • もしスキーマ間で共通の識別プロパティが見つからなかったら、スキーマの初期化時点でエラーを投げるようになったよ
  • 他のスキーマと組み合わせられるようになったよ

ということなので、試してみるか(zod 4.0.0-beta.20250505T195954で確認)

識別プロパティを指定しなくても分かってくれるようになった

import { z } from 'zod';

const MyUnion = z.discriminatedUnion([
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    kind: z.literal('rectangle'),
    width: z.number(),
    height: z.number(),
  }),
]);

const result = MyUnion.safeParse({
  kind: 'rectangle',
  width: 5,
  height: 10,
});

console.log(result.data);

ふむ。これは期待した結果が得られた。識別プロパティを指定しなくても分かってくれるってことなので、よきかな。

{ kind: 'rectangle', width: 5, height: 10 }

共通の識別プロパティが見つからなくてもエラーにならなかった・・・

rectangleの方のkindnameというプロパティに変更してみたけど、あれー?エラーにならない。

const MyUnion = z.discriminatedUnion([
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    name: z.literal('rectangle'),
    width: z.number(),
    height: z.number(),
  }),
]);

って、Cursorに聞きながらよくよく追いかけてみると「各オプションに最低1個の識別プロパティがあればOK。識別プロパティのないオプションがあるとエラーになる」って実装になってた。

↑の例だと、最初のオプションにはkindがリテラルで指定されているし、二番目のオプションにはnameがリテラルで指定されているから、エラーにならないってことか。じゃあ、nameをリテラルから文字列に変えてみようか。

const MyUnion = z.discriminatedUnion([
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    name: z.string(),
    width: z.number(),
    height: z.number(),
  }),
]);

ちゃんと実行時エラーになった。

Error: Invalid discriminated union option at index "1"

なるほど。これ、ドキュメントが間違えてるな(「共通の識別プロパティが見つからなかったら」の部分)。あとでIssue書いておこうかな。

あと、コンパイルエラーじゃなくて実行時エラーだから、UTはちゃんと書いておきたいね。

ところで識別プロパティを指定したらどうなるの?

「識別プロパティを指定しなくて良くなった」ってことは、指定してもよくて、指定した場合はそれが使われるのかな?と思って見てみたら、指定はしてもいいけど捨ててたwww

https://github.com/colinhacks/zod/blob/3d869addacbb204cca8f5556c90fe8bcc404b32a/packages/zod/src/schemas.ts#L1240

export function discriminatedUnion(...args: any[]): any {
  if (typeof args[0] === "string") args = args.slice(1);
  const [options, params] = args;
  return new ZodDiscriminatedUnion({
    type: "union",
    options,
    ...util.normalizeParams(params),
  });
}

他のスキーマと組み合わせられるようになったよ

MyUnionとして定義してあるものを、MyUnion2ではそのまま使ってるけど、ちゃんと定義されてた。よき。

import { z } from 'zod';

const MyUnion = z.discriminatedUnion([
  z.object({
    kind: z.literal('square'),
    size: z.number(),
  }),
  z.object({
    kind: z.literal('rectangle'),
    width: z.number(),
    height: z.number(),
  }),
]);

const MyUnion2 = z.discriminatedUnion([
  z.object({
    kind: z.literal('circle'),
    radius: z.number(),
  }),
  MyUnion,
]);

const result1 = MyUnion2.safeParse({
  kind: 'rectangle',
  width: 5,
  height: 10,
});
console.log(result1.data);

const result2 = MyUnion2.safeParse({
  kind: 'circle',
  radius: 5,
});
console.log(result2.data);

実行すると、パースできてる。

{ kind: 'rectangle', width: 5, height: 10 }
{ kind: 'circle', radius: 5 }

ところでエラーメッセージが変わった

パースできなかったときのエラーメッセージが変わってる。さっきv3で見たエラーはこうだった。

[
  {
    "code": "invalid_union_discriminator",
    "options": [
      "square",
      "rectangle"
    ],
    "path": [
      "kind"
    ],
    "message": "Invalid discriminator value. Expected 'square' | 'rectangle'"
  }
]

v4で同じようにチェックするとエラーメッセージはこうなってた

[
  {
    "code": "invalid_union",
    "errors": [],
    "note": "No matching discriminator",
    "path": [],
    "message": "Invalid input"
  }
]

ここまで見てきて、まぁ分からなくもないかという気持ち。共通の識別プロパティを見てるんじゃなくて、それぞれに別の識別プロパティを持って識別するようになってるからだろうな。

v3の方が分かりやすいけど、仕方がないか。

その他:unionFallbackオプション

Cursorとコードを読んでて面白いねってなったので書いておく。

z.discriminatedUnionにはunionFallbackというbooleanのオプションを指定できるようす。まだドキュメントには書かれていない。これをtrueにしておくと、識別プロパティを元にして複数マッチした場合や、一つもマッチしなかった場合に、Union(総当たり)のやり方でエラーメッセージを生成するみたい。必要な理由はいまいち分からないけど、デバッグ用かな?

https://github.com/colinhacks/zod/blob/3d869addacbb204cca8f5556c90fe8bcc404b32a/packages/core/src/schemas.ts#L1984-L1986

if (def.unionFallback) {
    return _super(payload, ctx);
}

興味がある場合は、このテストケースを見たら分かりやすい。

https://github.com/colinhacks/zod/blob/3d869addacbb204cca8f5556c90fe8bcc404b32a/packages/zod/tests/discriminated-unions.test.ts#L170

実際に手元で同じことをやってみたら、こうなった↓

const result = z
  .discriminatedUnion(
    [z.object({ type: z.literal('a'), a: z.string() }), z.object({ type: z.literal('b'), b: z.string() })],
    { unionFallback: true },
  )
  .safeParse({ type: 'x', a: 'abc' });

console.log(result.error?.message);

結果

[
  {
    "code": "invalid_union",
    "errors": [
      [
        {
          "code": "invalid_value",
          "values": [
            "a"
          ],
          "path": [
            "type"
          ],
          "message": "Invalid input: expected \"a\""
        }
      ],
      [
        {
          "code": "invalid_value",
          "values": [
            "b"
          ],
          "path": [
            "type"
          ],
          "message": "Invalid input: expected \"b\""
        },
        {
          "expected": "string",
          "code": "invalid_type",
          "path": [
            "b"
          ],
          "message": "Invalid input: expected string, received undefined"
        }
      ]
    ],
    "path": [],
    "message": "Invalid input"
  }
]

その他:optionsが設定されなくなった

v4: no longer able to extract discriminator options’ concrete values · Issue #4142 · colinhacks/zod · GitHub

z.discriminatedUnionのスキーマにはv3まではoptionsで要素にアクセスできたんだけど、v4ではundefinedが返されるようになってる。z.unionだと設定されてるっぽいから、これはバグなんじゃないかなぁ?Issueをwatchしといた。

おわり

なんか長くなった。ちょっと見てみた感じだと、んー、v4の正式版がリリースされてから、すぐバージョンアップするよりは、しばらく待っていろいろ落ち着いてからにしたいなって気持ち。

書いといた