本記事は、【初級者編】の続編です。初級者編では判別可能ユニオンの基礎と型安全性を実現する手法を学びました。
しかし、実際に大規模プロジェクトで運用してみると、基礎的なパターンだけでは対処できない複雑な課題に直面します:
- 共通プロパティの重複による保守性の低下
- UIとビジネスロジックで異なる網羅性チェックの必要性
- 「注文の中の支払いの中の配送」のような深いネスト構造の複雑化
- ローディング、エラー、リトライなど非同期状態の統一的な管理
- 「下書きから配送済み」のような不正な状態遷移の防止
初級者編ではECサイトの注文状態管理を例に、判別可能ユニオンの基本を学びました。
本記事では、その実装を大規模プロジェクトに拡張した際に直面した「共通プロパティの重複」という課題と、TypeScriptの交差型を使った解決策を紹介します。
上級者編は3部作で構成されています:
- 【本記事】共通プロパティの重複解決と交差型の活用
- 【次回】網羅性チェックとネスト構造の解決策
- 【最終回】非同期処理と型安全な状態遷移
今回は実際にリニューアルプロジェクトで直面したコード例を使って解説します。
共通プロパティの重複問題とその解決
実際に直面した問題:注文データの重複
ECサイトのリニューアルプロジェクトで、以下のように、各状態で同一のプロパティを繰り返し定義することが必要となり、保守性に課題を抱えることになりました。
type Order =
| {
status: 'pending'
orderId: string
customerId: string
amount: number
createdAt: Date
updatedAt: Date
metadata: Recordstring, unknown>
paymentDeadline: Date
}
| {
status: 'shipped'
orderId: string
customerId: string
amount: number
createdAt: Date
updatedAt: Date
metadata: Recordstring, unknown>
trackingNumber: string
carrier: string
}
注目すべきは、orderId
、customerId
、amount
、createdAt
、updatedAt
、metadata
といった共通プロパティが、すべての状態定義で重複していることです。
重複がもたらす具体的な課題
- 共通フィールドの追加時にすべての状態定義への修正が必要(7箇所以上)
- プロパティ名変更時の修正コスト増大
- バリデーションルール変更時の広範囲な影響
TypeScriptでの解決策:交差型を使ったベース型パターン
ベース型の定義
type OrderBase = {
orderId: string
customerId: string
amount: number
createdAt: Date
updatedAt: Date
metadata: Recordstring, unknown>
}
交差型による型合成
交差型(&)は複数の型を合成して単一の型を生成します。OrderBase & { status: 'pending'; ... }
は、「OrderBase
のすべてのプロパティおよびstatus
を含む固有プロパティを持つ」ことを意味します。
TypeScriptコンパイラは以下のような型合成を行います:
{
orderId: string
customerId: string
amount: number
status: 'pending'
paymentDeadline: Date
}
type Order = OrderBase & (
| { status: 'pending'; paymentDeadline: Date }
| { status: 'shipped'; trackingNumber: string; carrier: string }
| { status: 'delivered'; deliveryDate: Date; signedBy: string }
| { status: 'cancelled'; cancelReason: string; cancelledAt: Date }
| { status: 'refunded'; refundAmount: number; refundReason: string }
| { status: 'processing'; estimatedShipDate: Date }
| { status: 'failed'; failureReason: string; canRetry: boolean }
)
交差型の分配法則
この設計の背後には、TypeScriptの**交差型の分配法則(Distributive Property of Intersection Types over Union Types)**という言語仕様があります。
分配法則のメカニズム
以下の型定義を考えてみましょう:
type Order = OrderBase & (
| { status: 'pending'; paymentDeadline: Date }
| { status: 'shipped'; trackingNumber: string; carrier: string }
)
TypeScriptコンパイラは、この型を以下のように展開します:
type Order =
| (OrderBase & { status: 'pending'; paymentDeadline: Date })
| (OrderBase & { status: 'shipped'; trackingNumber: string; carrier: string })
この展開が分配法則です。数学の分配法則「A × (B + C) = A × B + A × C」と同様に、交差型がユニオン型に対して分配されます。
分配法則がもたらす型安全性
この分配法則により、以下の型安全性が保証されます:
-
完全な型情報の保持:
-
OrderBase & { status: 'pending'; paymentDeadline: Date }
は、OrderBaseのすべてのプロパティとstatus
、paymentDeadline
を持つことが保証される - 型推論とインテリセンスが正確に機能
-
-
判別可能ユニオンとしての適格性:
- 各メンバーが
status
プロパティを持つため、型の絞り込みが正確に機能
- 各メンバーが
-
構造的部分型の活用:
-
OrderBase
を引数に取る関数に、任意のOrder
を型安全に渡せる
-
ベース型パターンのメリット
- 共通フィールドの管理が単一箇所に集約
- 各状態固有プロパティの明確な分離
- TypeScript型安全性の完全な保持
- 交差型の分配によって生成される型構造の予測可能性
開発効率の向上
ベース型パターンは、単なるコードの整理に留まらず、開発効率を大幅に向上させます。型定義のDRY原則を徹底することで、大規模なコードベースでも一貫性のある型構造を維持でき、長期的な保守性が確保されます。
Zodでの実装:スキーマ合成のテクニック
TypeScriptで型定義を整理できたので、次はZodでのバリデーションスキーマの実装に取り組みます。
TypeScriptの&
がZodで動作しない理由
const orderSchema = orderBaseSchema & z.discriminatedUnion('status', [
])
この書き方をすると、TypeScriptで以下のようなコンパイルエラーが発生します:
Type 'ZodObject<...>' is not assignable to type 'number'.
The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
コンパイルエラーの原因
TypeScriptの&
は型レベルの操作で、コンパイル時に型を合成します。一方、Zodのスキーマは実行時のバリデーションロジックを持つJavaScriptオブジェクトです。JavaScriptの&
演算子はビット演算なので、オブジェクト同士には使えません。
仮に型エラーを無理に回避しても、バリデーションロジックは合成されず、期待する動作をしません。
そのため、Zodでは専用のメソッドを使ってスキーマを合成します。
スキーマ合成手法1: .and()
メソッド
ベーススキーマの定義
import { z } from 'zod'
const orderBaseSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
amount: z.number().positive(),
createdAt: z.date(),
updatedAt: z.date(),
metadata: z.record(z.unknown()).default({}),
})
.and()
を使ったスキーマ合成
const orderSchema = z.discriminatedUnion('status', [
orderBaseSchema.and(z.object({
status: z.literal('pending'),
paymentDeadline: z.date(),
})),
orderBaseSchema.and(z.object({
status: z.literal('shipped'),
trackingNumber: z.string(),
carrier: z.enum(['yamato', 'sagawa', 'japan_post']),
})),
orderBaseSchema.and(z.object({
status: z.literal('delivered'),
deliveryDate: z.date(),
signedBy: z.string(),
})),
])
z.infer
によるTypeScript型の生成
type Order = z.infertypeof orderSchema>
.and()
メソッドのメリット
- シンプルで分かりやすい
- TypeScriptの
&
に近い感覚で書ける - バリデーションと型安全性が両立
スキーマ合成手法2: .extend()
メソッド
.and()
と.extend()
の違い
これら2つのメソッドは、単なる記述スタイルの違いではなく、根本的に異なるバリデーション戦略を採用しています。この違いを理解しなければ、予期しないバリデーション結果や実行時エラーを引き起こすリスクがあります。
.and()
:制約の交差
.and()
は2つのスキーマを合成し、両方の条件を同時に満たすことを要求します:
const stringSchema = z.object({ id: z.string() })
const numberSchema = z.object({ id: z.number() })
const conflicted = stringSchema.and(numberSchema)
重複するプロパティがある場合、両方のバリデーションが適用されます:
const minLength = z.object({ name: z.string().min(5) })
const maxLength = z.object({ name: z.string().max(10) })
const combined = minLength.and(maxLength)
.extend()
:プロパティの上書き
.extend()
は既存のスキーマをベースとして、新しいプロパティを追加または既存プロパティを上書きします:
const baseSchema = z.object({
id: z.string(),
name: z.string()
})
const extendedSchema = baseSchema.extend({
id: z.string().uuid(),
age: z.number()
})
.extend()
を推奨する理由
-
予測可能な挙動:
- プロパティの上書きという意図が明確
- 矛盾したスキーマを作成するリスクが低い
-
「継承」の概念との一致:
-
OrderBase
を「基底クラス」、各状態を「派生クラス」として扱える - オブジェクト指向設計の直感と合致
-
-
デバッグの容易さ:
- どのプロパティがどこから来ているかが明確
- バリデーションエラー時の原因特定が容易
安全性の比較
const riskySchema = orderBaseSchema.and(z.object({
status: z.literal('pending'),
}))
const safeSchema = orderBaseSchema.extend({
status: z.literal('pending'),
})
このため、スキーマ合成においては.extend()
を基本とし、明示的に複数の制約を重ねたい場合にのみ.and()
を使用することを強く推奨します。
各状態のスキーマを個別に定義することで、何を持っているか一目瞭然になります:
const pendingOrderSchema = orderBaseSchema.extend({
status: z.literal('pending'),
paymentDeadline: z.date(),
reminderSentAt: z.date().optional(),
})
const shippedOrderSchema = orderBaseSchema.extend({
status: z.literal('shipped'),
trackingNumber: z.string(),
carrier: z.enum(['yamato', 'sagawa', 'japan_post']),
estimatedDelivery: z.date(),
})
const deliveredOrderSchema = orderBaseSchema.extend({
status: z.literal('delivered'),
deliveryDate: z.date(),
signedBy: z.string(),
feedbackRequested: z.boolean().default(false),
})
最後にシンプルに組み合わせます:
const orderSchema = z.discriminatedUnion('status', [
pendingOrderSchema,
shippedOrderSchema,
deliveredOrderSchema,
])
スキーマの再利用性
.extend()
パターンの最大の利点は、各状態のスキーマが独立したモジュールとして再利用可能になることです。これは大規模アプリケーションにおいて、以下のようなアーキテクチャ上の価値を提供します。
モジュール間での選択的インポート
各状態スキーマをexportすることで、特定の責務を持つモジュールが必要な状態のみを扱えます:
export const pendingOrderSchema = orderBaseSchema.extend({ })
export const shippedOrderSchema = orderBaseSchema.extend({ })
export const deliveredOrderSchema = orderBaseSchema.extend({ })
import { pendingOrderSchema } from '../schemas/order'
import { shippedOrderSchema } from '../schemas/order'
import { deliveredOrderSchema } from '../schemas/order'
このアプローチのメリット
- 各状態が何を持っているか一目瞭然
- 各状態のスキーマが独立して読みやすい
- モジュール間の依存関係が明確になる
- 新しい状態の追加が既存コードに与える影響を最小化
まとめ
本記事では、判別可能ユニオンにおける「共通プロパティの重複」という実践的課題に対する解決策を紹介しました。
本設計の本質的な価値
長期的な保守性の向上
共通プロパティをOrderBase
に集約することで、仕様変更時の修正箇所が単一化され、修正漏れのリスクを根絶します。.extend()
パターンにより、各状態の責務が明確になり、コードの可読性と変更容易性が飛躍的に向上します。
交差型の分配法則という言語仕様を理解することで、「なぜこの設計が機能するのか」という根本的なメカニズムを把握でき、応用範囲が格段に広がります。
型定義と実行時検証の一元化
Zodスキーマから型をinfer
することで、TypeScriptの静的型定義と実行時バリデーションの乖離を原理的に防ぎます。これにより、データの信頼性がアプリケーションの入り口で保証され、後続のビジネスロジックは型安全性を前提として構築できます。
構造化されたエラーハンドリングにより、バリデーション失敗は単なる例外ではなく、アプリケーション全体で一貫して処理できる情報となります。
スケーラブルなアーキテクチャ構築
新しい状態の追加は、独立したスキーマを定義し、discriminatedUnion
の配列に一つ追加するだけで完了します。既存コードへの影響を最小限に抑え、大規模かつ長期的な開発においてもシステムの複雑性をコントロール可能にします。
各状態スキーマが独立したモジュールとして機能することで、モジュール間の依存関係が明確になり、テスト戦略の最適化と状態遷移の型安全性が実現されます。
最後に
今回のテクニックは、判別可能ユニオン活用の基盤となるものです。次回は、より複雑な実装課題に取り組みます:
- コンテキスト別の網羅性チェック戦略
- 深いネスト構造に対する効果的なアプローチ
これらの手法を組み合わせることで、大規模TypeScriptプロジェクトにおいても型安全な設計と長期的な保守性を両立できます。
Views: 0