Zodのunion型で最初にマッチしたスキーマによりプロパティが切り落とされる挙動について

Zodのunion型で最初にマッチしたスキーマによりプロパティが切り落とされる挙動について

Clock Icon2025.07.07

はじめに

最近、値のバリデーションにZodを活用しています。バリデーション処理を個別に書かなくても、スキーマを定義するだけで型安全な検証ができるため大変重宝しています。

そんな中、複雑な型を定義するためにZodのunion型を使用したところ、プロパティが切り落とされるという意図しない挙動に少しハマってしまいました。この記事ではその挙動と対処法について説明します。

想定シーン

この記事では以下のZodスキーマを基本とします。

const UserSchema = z.object({
  canRead: z.boolean(),
  canWrite: z.boolean(),
  approvalNotificationEmail: z.string()
});

const ManagerSchema = z.object({
  canApprove: z.boolean(),
  canDelete: z.boolean(),
  approvalRequestNotificationEmail: z.string(),
});

const AdminSchema = z.object({
  canRead: z.boolean(),
  canWrite: z.boolean(),
  canApprove: z.boolean(),
  canDelete: z.boolean(),
  approvalNotificationEmail: z.string(),
  approvalRequestNotificationEmail: z.string(),
});

// ユニオン型
const AllRolesSchema = z.union([UserSchema, ManagerSchema, AdminSchema]);

コンテンツ管理システムのようなアプリケーションの権限設定機能を想定します。ユーザに対しては「読む」「書く」「承認された場合の通知先メールアドレス」を、マネージャーに対しては「承認する」「削除する」「承認を要求された場合の通知先メールアドレス」を設定でき、システム管理者に対してはそのすべてを設定できるという仕様だとします。

ハマったポイント

システム管理者に対して設定をしたいと思い、以下のようにオブジェクトを検証します。

const setting = {
  canRead: true,
  canWrite: false,
  canApprove: false,
  canDelete: false,
  approvalNotificationEmail: "user@example.com",
  approvalRequestNotificationEmail: "manager@example.com",
}

// 検証
const result = AllRolesSchema.safeParse(setting);

このsettingオブジェクトはAdminSchemaに合致しているので、resultは以下のようになると予想されます。

{
  canRead: true,
  canWrite: false,
  canApprove: false,
  canDelete: false,
  approvalNotificationEmail: 'user@example.com',
  approvalRequestNotificationEmail: 'manager@example.com'
}

しかし、実際に実行してみると以下のようになります。

{
  canRead: true,
  canWrite: false,
  approvalNotificationEmail: 'user@example.com'
}

これは、Zodがデフォルトで以下のような挙動をするためです。

  • スキーマに対して余分なプロパティが追加されている場合は該当プロパティが切り落とされる
  • union型では定義されたスキーマを順番に検証し、合致した時点で終了する

これにより、settingオブジェクトは「余分なプロパティが追加されているUserSchemaである」と判定されてしまいました。

対処法

discriminatedUnionを使う

スキーマを変更可能な場合、スキーマを判別するためのプロパティを追加し、discriminatedUnionを使用することで対処できます。

const UserSchema = z.object({
  role: z.literal('user'),
  canRead: z.boolean(),
  canWrite: z.boolean(),
  approvalNotificationEmail: z.string()
});

const ManagerSchema = z.object({
  role: z.literal('manager'),
  canApprove: z.boolean(),
  canDelete: z.boolean(),
  approvalRequestNotificationEmail: z.string(),
});

const AdminSchema = z.object({
  role: z.literal('admin'),
  canRead: z.boolean(),
  canWrite: z.boolean(),
  canApprove: z.boolean(),
  canDelete: z.boolean(),
  approvalNotificationEmail: z.string(),
  approvalRequestNotificationEmail: z.string(),
});

// discriminatedUnionを使ったunion型の作成
const AllRolesSchemaWithDiscriminator = z.discriminatedUnion('role', [
  UserSchema,
  ManagerSchema,
  AdminSchema,
]);

const setting = {
  role: "admin",
  canRead: true,
  canWrite: false,
  canApprove: false,
  canDelete: false,
  approvalNotificationEmail: "user@example.com",
  approvalRequestNotificationEmail: "manager@example.com",
}

const result = AllRolesSchemaWithDiscriminator.safeParse(setting);

これにより、正しい検証結果が得られます。

{
  role: 'admin',
  canRead: true,
  canWrite: false,
  canApprove: false,
  canDelete: false,
  approvalNotificationEmail: 'user@example.com',
  approvalRequestNotificationEmail: 'manager@example.com'
}

また、判別用のプロパティがあることで型安全性を高められます。

type AllRoles = z.infer<typeof AllRolesSchemaWithDiscriminator>;

const handle = (data: AllRoles) => {
  if (data.role === "user") {
    console.log(data.canRead);
    console.log(data.canDelete); // エラー
  }
}

union型の順序を調整

スキーマが変更できない場合、順序を調整することで解決できる場合があります。前述の通り、Zodはスキーマを順番に検証し、最初に合致した時点で終了します。

そのため、より具体的なスキーマを前の方に定義することで、意図したスキーマに合致させることができます。

// ユニオン型
// より具体的なAdminSchemaを一番前にもってくる
const AllRolesSchema = z.union([AdminSchema, UserSchema, ManagerSchema]);

ただし、順序に依存した解決法は、経緯や仕様を知らない人がコードに手を加えたり、スキーマを変更したりした場合にバグのもとになる可能性があります。

strictObject(またはstrict)を使う

「余分なプロパティが追加されている場合に切り落とされる」という挙動を制限したい場合は、strictObjectまたはstrictを使用できます。

// strictObject
const UserSchema = z.strictObject({
  canRead: z.boolean(),
  canWrite: z.boolean(),
  approvalNotificationEmail: z.string()
});

// strict
const UserSchema = z.object({
  canRead: z.boolean(),
  canWrite: z.boolean(),
  approvalNotificationEmail: z.string()
}).strict();

strictはZod 4にて非推奨となるようです。

Migration guide | Zod

補足:パフォーマンス計測

これらの対処法について10万回処理を行い簡単なパフォーマンスを計測してみました。以下のそれぞれに対して3回ずつ計測しています。

  • スキーマにroleプロパティを追加し、discriminatedUnionを使用
  • スキーマを変更せず、unionの順序を変更
  • スキーマを変更せず、strictモードを使用

なお、簡易的な検証であり実際のプロダクション環境での動きを再現しているわけではありません。また、測定結果は環境によって異なる場合があります。この記事では以下の環境で測定しました。

  • Windows 11
  • CPU Intel(R) Core(TM) i7-1185G7
  • メモリ 32.0 GB
  • Node.js 22.9.0
  • Zod 3.25.74

検証に使用したコードはこちらです。

// パフォーマンステスト
const iterations = 100000;
const start = performance.now();

for (let i = 0; i < iterations; i++) {
  AllRolesSchema.safeParse(setting);
}

const end = performance.now();
console.log(`${iterations} iterations: ${(end - start).toFixed(2)}ms`);

最初のスキーマに合致する場合

  • discriminatedUnion
    • 28.78ms/40.16ms/27.36ms
  • 順序変更
    • 25.29ms/29.70ms/30.59ms
  • strictモード
    • 34.29ms/32.75ms/28.87ms

最後のスキーマに合致する場合

  • discriminatedUnion
    • 26.91ms/28.85ms/30.88ms
  • 順序変更
    • このケースは正しい検証結果が出ないため測定不能
  • strictモード
    • 90.66ms/82.42ms/74.77ms

どのスキーマにも合致しない場合

  • discriminatedUnion
    • 2239.39ms/2220.28ms/2274.88ms
  • 順序変更
    • 2556.06ms/2495.91ms/2715.27ms
  • strictモード
    • 2896.41ms/2952.89ms/2723.02ms

これらの結果から、最初のスキーマに合致する場合はどの方法でも大差はありませんが、判別用のプロパティを使ってスキーマを直接特定できるdiscriminatedUnionが最も安定したパフォーマンスを出せることがわかります。それに対して、strictモードは厳格な検証を行うため、検証スキーマ数が増えるとパフォーマンスに影響が出ることがわかります。

大量に処理することがないのであればどの方法でもそこまで影響がないかもしれませんが、もし判別用のプロパティを追加できるのであれば、型安全性の面からもdiscriminatedUnionを使うのが良さそうです。

おわりに

Zodスキーマのこの挙動はエラーにならないだけに気付きにくく、テストをしっかり書くことの重要性を教えてくれます。

この記事がどなたかの参考になれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.