
【Zod】union/discriminatedUnionとrefine/superRefineの使い分け
はじめに
この記事の概要
こんにちは、株式会社TOKOSのスギタです!
今回は動的フォーム実装の際に使用するであろうunion/discriminatedUnionとrefine/superRefineの各々の使い分けの判断をまとめてみました!
この記事では、以下の内容について詳しく解説します:
union/discriminatedUnionとrefine/superRefineの基本概念- それぞれの手法が適している具体的なユースケース
Zodを使ったことがある方はもちろん、これから使ってみようと考えている方にとっても、この記事が動的フォーム実装の指針となれば幸いです。
ぜひ最後までお付き合いください!
対象読者
- フロントエンドエンジニアの方
- Zodを使い始めたばかりの方
この記事で扱う内容、扱わない内容
この記事で扱う内容
union/discriminated unionの使い分けの基準refine/superRefineの使い分けの基準
この記事で扱わない内容
- Zodの基本的な使用方法
- React Hook Formの基本的な使用方法
union/discriminatedUnionとrefine/superRefineの使い分け
Zodを使用して動的フォームのバリデーションを行う際、union/discriminatedUnionとrefine/superRefineは有用です。
まずはunion/discriminatedUnionとrefine/superRefineの各々の使い分けを解説します。
また、TypeScriptのdiscriminated union型を知っているとスムーズに理解できると思いますので、わからない方は下記を先に読んでもらえると良いかと思います!
判別可能なユニオン型 (discriminated union) | TypeScript入門『サバイバルTypeScript』
TypeScriptの判別可能なユニオン型は、ユニオンに属する各オブジェクトの型を区別するための「しるし」がついた特別なユニオン型です。オブジェクトの型からなるユニオン型を絞り込む際に、分岐ロジックが複雑になる場合は、判別可能なユニオン型を使うとコードの可読性と保守性がよくなります。
typescriptbook.jpunion/discriminatedUnionの使い分け
unionは、一言で言うと複数の異なるスキーマを1つのスキーマにまとめてくれる機能です。
基本的な構文は下記です。
const stringOrNumberSchema = z.union([z.string(), z.number()])上記はstringまたはnumberを許容するスキーマの例です。
このように複数のスキーマを1つのスキーマにまとめてくれます。
では少し複雑にしてみましょう。
これは、userTypeの値によって動的にスキーマを変更する例です。
const baseUserSchema = z.object({
isActive: z.boolean(),
})
const studentSchema = baseUserSchema.extend({
userType: z.literal("student"),
studentId: z.string(),
grade: z.number(),
})
const teacherSchema = baseUserSchema.extend({
userType: z.literal("teacher"),
teacherId: z.string(),
subject: z.string(),
})
const userSchema = z.union([studentSchema, teacherSchema])
type User = z.infer<typeof userSchema>userTypeとの関係性は下記になります。
| userTypeの値 | isActive | studentId | teacherId | grade | subject |
|---|---|---|---|---|---|
| student | ◯ | ◯ | ✕ | ◯ | ✕ |
| teacher | ◯ | ✕ | ◯ | ✕ | ◯ |
これによりuserTypeの値が"student"か"teacher"かによって動的にスキーマを変えることができます。
ですが上記例のように判別対象(上記であればuserTypeの値)がはっきりしている場合はdiscriminatedUnionを使用しましょう!
下記は上記で定義したスキーマをdiscriminatedUnionで記述したものです。
import { z } from "zod"
const userSchema = z.discriminatedUnion("userType", [
z.object({
userType: z.literal("student"),
isActive: z.boolean(),
studentId: z.string(),
grade: z.number(),
}),
z.object({
userType: z.literal("teacher"),
isActive: z.boolean(),
teacherId: z.string(),
subject: z.string(),
}),
])
type User = z.infer<typeof userSchema>第一引数に判定対象を指定し、第二引数では判定対象の値ごとのスキーマを配列形式で記述していきます!
上記2つの例のようなスキーマを動的に変更する場合はなるべくdiscriminatedUnionを使用しましょう!
unionの代わりにdiscriminatedUnionを使用するメリットは下記になります。
型の安全性
discriminatedUnionは、userTypeフィールドが必ず存在し、指定された値のいずれかであることを保証してくれます。
unionではこの保証はありません。
パフォーマンス
discriminatedUnionは、提供されたオブジェクトが各スキーマと一致するかどうかをチェックする際に、userTypeフィールドを使用して早期に絞り込みを行うため、パフォーマンスが良いとされています。
エラーメッセージの最適化
unionに比べdiscriminatedUnionを使用した場合、エラーメッセージが最適化され、エラー特定が容易になります。
実例を見たい方は下記を参照してください。
2-4. 複数のスキーマのいずれかを許可したい (or / union)
zenn.devコードの見通しの向上
unionに比べコードの見通しが向上します。
下記はunionで記述した例です。
判定対象が記述されないため、コードの見通しが悪くなります。
// unionの例
const baseUserSchema = z.object({
isActive: z.boolean(),
})
const userUnionSchema = z.union([
baseUserSchema.extend({
userType: z.literal("student"),
studentId: z.string(),
grade: z.number().min(1).max(12),
major: z.string().optional(),
gpa: z.number().min(0).max(4).optional(),
}),
baseUserSchema.extend({
userType: z.literal("teacher"),
teacherId: z.string(),
subjects: z.array(z.string()).nonempty(),
yearsOfExperience: z.number().nonnegative(),
certifications: z.array(z.string()).optional(),
}),
baseUserSchema.extend({
userType: z.literal("admin"),
adminId: z.string(),
department: z.string(),
accessLevel: z.enum(["low", "medium", "high"]),
canEditUsers: z.boolean(),
}),
])discriminatedUnionに変えた例です。
第一引数で判定対象を明記するので、コードの見通しが良いです。
// discriminatedUnionの例
const baseUserSchema = z.object({
isActive: z.boolean(),
})
const userDiscriminatedUnionSchema = z.discriminatedUnion("userType", [
baseUserSchema.extend({
userType: z.literal("student"),
studentId: z.string(),
grade: z.number().min(1).max(12),
major: z.string().optional(),
gpa: z.number().min(0).max(4).optional(),
}),
baseUserSchema.extend({
userType: z.literal("teacher"),
teacherId: z.string(),
subjects: z.array(z.string()).nonempty(),
yearsOfExperience: z.number().nonnegative(),
certifications: z.array(z.string()).optional(),
}),
baseUserSchema.extend({
userType: z.literal("admin"),
adminId: z.string(),
department: z.string(),
accessLevel: z.enum(["low", "medium", "high"]),
canEditUsers: z.boolean(),
}),
])refine/superRefineの使い分け
refine/superRefineは、一言で言うとスキーマに対してバリデーションロジックを追加できる機能です。
基本的な構文は下記です。
import { z } from "zod"
const evenNumberSchema = z.number().refine((n) => n % 2 === 0, {
message: "数値は偶数でなければなりません",
})
type EvenNumber = z.infer<typeof evenNumberSchema>上記のようにnumber型で偶数でないといけない時などにバリデーションロジックを追加できます。
またrefineをチェーンすることも可能です。
import { z } from "zod"
const evenNumberSchema = z
.number()
.refine((n) => n % 2 === 0, {
message: "数値は偶数でなければなりません",
})
.refine((n) => n > 10, {
message: "数値は10より大きくなければなりません",
})
type EvenNumber = z.infer<typeof evenNumberSchema>Parse時にrefineで記述したカスタムバリデーションの判定が実行されます。
また値同士が依存している場合のバリデーションロジックを記述する際に適しています。
下記はパスワードと確認用のパスワードが一致しているかのバリデーションロジックの例です。
import { z } from "zod"
const passwordSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "パスワードと確認用パスワードが一致しません",
path: ["confirmPassword"],
})
type PasswordInput = z.infer<typeof passwordSchema>このように2つの値が依存関係にある場合にとても有用です。
refineの第二引数にはエラーメッセージを記述できます。
上記例では、エラーメッセージを「パスワードと確認用パスワードが一致しません」でエラーフィールドを「confirmPassword」にしています。
さらに複雑なバリデーションロジックを実装したい場合にsuperRefineは有用になります。
具体的には依存する値が複数の場合や複雑なバリデーションロジックを追加する場合です。
下記はユーザー登録フォームのスキーマです。
import { z } from "zod"
const USER_ROLES = ["user", "admin", "superadmin"] as const
const formSchema = z.object({
name: z.string().min(3).max(20), // 名前
email: z.string().email(), // メールアドレス
age: z.number().int().positive(), // 年齢
role: z.enum(USER_ROLES), // 役割
password: z.string().min(8), // パスワード
confirmPassword: z.string(), // 確認用パスワード
agreeToTerms: z.boolean(), // 利用規約に同意
})ここから条件を追加します。
条件は下記です。
- パスワードと確認用パスワードの一致判定
passwordとconfirmPasswordが一致しているか
- 年齢と役割の関連判定
roleがadminの場合はageが25歳以上である必要がある
- スーパー管理者の登録条件判定
superadminは30歳以上である必要があるemailの@以降がcompany.comになっているか
- 利用規約の同意判定
agreeToTermsに値があるか
- パスワードのバリデーション判定
- 指定のフォーマットに沿っているか
では先ほど定義したスキーマにバリデーションロジックを追加します。
import { z } from "zod"
const USER_ROLES = ["user", "admin", "superadmin"] as const
const formSchema = z
.object({
name: z.string().min(3).max(20), // 名前
email: z.string().email(), // メールアドレス
age: z.number().int().positive(), // 年齢
role: z.enum(USER_ROLES), // 役割
password: z.string().min(8), // パスワード
confirmPassword: z.string(), // 確認用パスワード
agreeToTerms: z.boolean(), // 利用規約に同意
})
.superRefine((data, ctx) => {
// パスワードと確認用パスワードの一致判定
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "パスワードと確認用パスワードが一致しません",
path: ["confirmPassword"],
})
}
// 年齢と役割の関連判定
if (data.role === "admin" && data.age < 25) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "管理者は25歳以上である必要があります",
path: ["age"],
})
}
// スーパー管理者の登録条件判定
if (data.role === "superadmin") {
if (data.age < 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "スーパー管理者は30歳以上である必要があります",
path: ["age"],
})
}
if (!data.email.endsWith("@company.com")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "スーパー管理者は会社のメールアドレスを使用する必要があります",
path: ["email"],
})
}
}
// 利用規約同意のチェック
if (!data.agreeToTerms) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "利用規約に同意する必要があります",
path: ["agreeToTerms"],
})
}
// パスワードのバリデーション判定
const passwordStrengthRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
if (!passwordStrengthRegex.test(data.password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "パスワードは少なくとも1つの大文字、小文字、数字、特殊文字を含む必要があります",
path: ["password"],
})
}
})
type Form = z.infer<typeof formSchema>細かいバリデーションロジックの解説はしませんが、複数の値が依存関係にある場合や値の結果によってバリデーションを変更したい場合にsuperRefineが適しています。
refineでできないことやコードが複雑になる場合はsuperRefineを使用しましょう!
さいごに
今回はZodのunion/discriminatedUnionとrefine/superRefineの各々の使い分けに関して解説いたしました!
いつ使えばいいのかなかなか分かりづらいところだと思うので解説してみました!
Zodでも使用頻度が高いと思いますので適切に使い分けられるようになりたいところですね。




