Zenn
🏷️

TypeScriptの判別可能ユニオンで状態管理が楽になった話【初級者編】

に公開
3
32

TypeScriptの判別可能ユニオンで状態管理が楽になった話【初級者編】

ECサイトやSaaSアプリケーションでは、注文状態、ユーザーステータス、決済フローなど、様々な「状態」を扱います。これらの状態管理で、ネストされたif文や存在しないプロパティへのアクセスによるランタイムエラーに悩まされた経験はありませんか?

本記事では、TypeScriptの判別可能ユニオン(Discriminated Union)を活用することで、以下の課題を解決する方法を解説します:

  • 状態ごとに必要なプロパティが異なる複雑なデータ構造の型安全な管理
  • 新しい状態を追加した際の修正漏れの防止
  • APIレスポンスの実行時検証とTypeScriptの型システムの統合

実際のECサイトプロジェクトでの改善事例を交えながら、判別可能ユニオンの基本から実践的な活用方法まで順を追って説明します。

1. 判別可能ユニオンの基礎知識

1.1 判別可能ユニオンとは?

判別可能ユニオンは、複数の型を「ユニオン型」として組み合わせたときに、特定のプロパティで型を判別できるようにしたパターンです。

// これが判別可能ユニオン!
type Order = 
  | { status: 'pending'; paymentDeadline: Date }    // statusが'pending'なら、paymentDeadlineがある
  | { status: 'shipped'; trackingNumber: string }   // statusが'shipped'なら、trackingNumberがある
  | { status: 'delivered'; deliveryDate: Date }     // statusが'delivered'なら、deliveryDateがある

1.2 関連用語

  • ユニオン型(Union Type): | で複数の型を繋げたもの('pending' | 'shipped' | 'delivered'
  • 判別プロパティ(Discriminator): 型を識別するための共通プロパティ(上の例では status
  • リテラル型(Literal Type): 具体的な値そのものを型として扱う('pending''shipped' など)
  • 型の絞り込み(Type Narrowing): TypeScriptがコントロールフロー解析によって型を特定すること

2. なぜ判別可能ユニオンが必要なのか?

2.1 実際に直面した問題:注文状態の管理

例えばECサイトの例では、注文(Order)は以下のように色々な状態を持ちます:

// 支払い待ち、処理中、発送済み、配達完了、キャンセル済み、返金済み...

そしてそれぞれの状態で必要な情報が違ってきます:

  • pending(支払い待ち)では支払い期限が必要
  • shipped(発送済み)では追跡番号が必要
  • refunded(返金済み)では返金額と理由が必要

2.2 プロジェクトで見つけたよくある問題パターン

例えばこんなコードがあったとします

interface Order {
  status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'
  paymentDeadline?: Date
  trackingNumber?: string
  deliveryDate?: Date
  refundAmount?: number
  refundReason?: string
  cancelReason?: string
}

この型定義の問題点としては

  • すべてのプロパティがオプショナル(?)になっている
  • pendingの時だけ必要なpaymentDeadlineも、他の状態で存在するかもしれない
  • shippedの時だけ必要なtrackingNumberも、他の状態で存在するかもしれない

実際の処理でこんな問題が発生します:

function processOrder(order: Order) {
  if (order.status === 'shipped') {
    if (!order.trackingNumber) {
      throw new Error('追跡番号がないのに発送済み??')
    }
    sendTrackingEmail(order.trackingNumber)
  }
}

これだとtrackingNumberundefinedかもしれないので、毎回存在チェックが必要になります。

2.3 判別可能ユニオンで解決!

そこで判別可能ユニオンの登場です :

type Order = 
  | { status: 'pending'; orderId: string; amount: number; paymentDeadline: Date }
  | { status: 'processing'; orderId: string; amount: number; estimatedShipDate: Date }
  | { status: 'shipped'; orderId: string; amount: number; trackingNumber: string; carrier: string }
  | { status: 'delivered'; orderId: string; amount: number; deliveryDate: Date; signedBy: string }
  | { status: 'cancelled'; orderId: string; amount: number; cancelReason: string; cancelledAt: Date }
  | { status: 'refunded'; orderId: string; amount: number; refundAmount: number; refundReason: string }

判別可能ユニオンを使うと、各状態に必要なプロパティだけを定義できます。

処理関数も劇的にシンプルになります:

function processOrder(order: Order) {
  switch (order.status) {
    case 'shipped':
      console.log(`発送通知を送信: ${order.trackingNumber}`)
      return sendTrackingEmail(order.trackingNumber, order.carrier)
      
    case 'pending':
      if (order.paymentDeadline < new Date()) {
        return cancelExpiredOrder(order.orderId)
      }
      return sendPaymentReminder(order.orderId, order.paymentDeadline)
      
    default:
      const _exhaustive: never = order
      throw new Error(`未対応の注文ステータス`)
  }
}

ただし、TypeScriptの型チェックはコンパイル時のみです。上記のコードは、orderパラメータに正しいOrder型の値が渡されることを前提としています。

もし外部から不正なデータ(例:{ status: 'shipped' }だけでtrackingNumberがない)が渡された場合、実行時エラーになります。そのため、外部データを扱う場合は、後述するZodなどによる実行時検証が必要です。

defaultケースの_exhaustive: neverは、新しいステータスを追加した時にコンパイルエラーを発生させる網羅性チェックです。

3. 実行時の型安全性を確保する - Zodとの連携

3.1 なぜZodが必要なのか?

TypeScriptの判別可能ユニオンは素晴らしい機能ですが、コンパイル時のみの保証です。実際の開発では、以下のような場面で実行時の検証が必要になります:

  • 外部APIからのレスポンス
  • フォームからのユーザー入力
  • JSONファイルの読み込み
  • WebSocketやイベントからのデータ

これらのデータは TypeScript の型システムの管理外から来るため、実行時に検証しないと予期しないエラーが発生します。

3.2 Zodと判別可能ユニオンを組み合わせる

Zodは実行時のスキーマ検証ライブラリで、TypeScriptの型を自動的に推論してくれます。APIレスポンスの検証でも、ZodのdiscriminatedUnionを使って型の安全性を高めることができます。

TypeScriptとZodの書き方の違い

実は、TypeScriptとZodでは判別可能ユニオンの書き方が少し違うんです。

TypeScriptの場合:

type OrderUpdate = 
  | { action: 'order_created'; orderId: string; timestamp: string }
  | { action: 'payment_received'; orderId: string; amount: number }
  | { action: 'order_shipped'; orderId: string; trackingNumber: string }

TypeScriptは共通のプロパティが互いに排他的なリテラル型'order_created'など)を持っていれば、判別可能ユニオンとして扱います。

Zodの場合:

ZodのdiscriminatedUnionを使用すると、判別プロパティを明示的に指定できます。この例では'action'を判別プロパティとして宣言しています:

import { z } from 'zod'

const orderUpdateSchema = z.discriminatedUnion('action', [
  z.object({ 
    action: z.literal('order_created'),
    orderId: z.string(), 
    timestamp: z.string() 
  }),
  z.object({ 
    action: z.literal('payment_received'), 
    orderId: z.string(), 
    amount: z.number() 
  }),
  // ...
])

type OrderUpdate = z.infer<typeof orderUpdateSchema>

Zodのスキーマから型を生成するz.inferを使用することで、スキーマ定義と型定義を一元化できます。これはモダンなTypeScript開発における推奨パターンです。

Zodが判別プロパティの明示的な指定を要求するのは、実行時の効率性のためです。通常のz.union()だと全てのパターンを試す必要がありますが、discriminatedUnionなら判別プロパティを見て一発で該当するスキーマを特定できます。

3.3 実際のAPIレスポンス処理での活用

問題のあるコードの例

const processOrderUpdate = (data: any) => {
  switch (data.action) {
    case 'order_created':
      return `注文 ${data.orderId.toUpperCase()} を作成しました`
    case 'payment_received':
      return `${data.amount * 1.1}円(税込)の支払いを受領しました`
    case 'order_shipped':
      return `追跡番号: ${data.trackingNumber}`
  }
}

このコードの問題点としては:

  • dataの形式を何もチェックしていない
  • data.orderIdが存在しない場合、undefined.toUpperCase()でクラッシュ
  • data.amountが文字列の場合、計算結果がNaNになる
  • data.trackingNumberが配列だったら、意図しない表示になる

Zodを使った安全な実装

import { z } from 'zod'

export const orderHistorySchema = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('order_created'),
    orderId: z.string().regex(/^ORD-[A-Z0-9]+$/),
    timestamp: z.string().datetime(),
  }),
  z.object({
    action: z.literal('payment_received'),
    orderId: z.string(),
    amount: z.number().positive(),
    paymentMethod: z.enum(['credit_card', 'bank_transfer', 'cash_on_delivery']),
  }),
  z.object({
    action: z.literal('order_shipped'),
    orderId: z.string(),
    trackingNumber: z.string().min(1),
    carrier: z.enum(['yamato', 'sagawa', 'japan_post']),
  }),
  z.object({
    action: z.literal('order_delivered'),
    orderId: z.string(),
    deliveredAt: z.string().datetime(),
    receivedBy: z.string().optional(),
  }),
])

// スキーマから型を推論
export type OrderHistory = z.infer<typeof orderHistorySchema>

実際の使用例

const processOrderUpdateSafe = (data: unknown) => {
  try {
    // ここがポイント!parse()で実行時検証を行う
    const validated = orderHistorySchema.parse(data)
    
    // ここから先は TypeScript の型システムが働く
    // validated は正しい OrderHistory 型であることが保証されている
    switch (validated.action) {
      case 'order_created':
        return `注文 ${validated.orderId} を作成しました`
      case 'payment_received':
        return `${(validated.amount * 1.1).toFixed(0)}円(税込)の支払いを受領しました`
      case 'order_shipped':
        // ここでは validated.trackingNumber の存在チェックは不要!
        // なぜなら、parse() で検証済みだから
        return `${validated.carrier}で発送しました。追跡番号: ${validated.trackingNumber}`
      case 'order_delivered':
        const receiver = validated.receivedBy || 'ご本人'
        return `${receiver}が受け取りました`
    }
  } catch (error) {
    if (error instanceof z.ZodError) {
      // どのフィールドでエラーが起きたか具体的に取得
      const fieldErrors = error.flatten().fieldErrors
      // 例: { orderId: ["String must match pattern"], amount: ["Number must be positive"] }
      
      console.error('Validation failed:', fieldErrors)
      
      // UIに渡せる形式でエラー情報を返す
      return {
        success: false,
        message: 'データ形式エラー',
        errors: fieldErrors  // 各フィールドのエラーメッセージ
      }
    }
    throw error
  }
}

Zodのエラー情報の活用:

error.flatten().fieldErrorsを使うと、どのフィールドがどんなルールに違反したかを具体的に取得できます。これをUIに渡せば、各入力フォームの下に適切なエラーメッセージを表示できます。

3.4 TypeScriptとZodの役割分担を理解する

Zodを使って良かったポイント:

  1. APIレスポンスの検証が楽々: 外部APIの予期しないレスポンスも怖くない!
  2. 型の推論: スキーマから型を推論してくれる
  3. 親切なエラーメッセージ: どこが間違ってるか一目瞭然

整理すると:

  • TypeScript: コンパイル時の型チェック。開発時のミスを防ぐ
  • Zod: 実行時の検証。外部データの信頼性を保証する

つまり、orderHistorySchema.parse(data) で実行時検証を行った後は、TypeScriptの型システムが if (!order.trackingNumber) のようなチェックを不要にしてくれます。両方を組み合わせることで、真の型安全性を実現できるのです。

4. よく見かけるアンチパターンと改善方法

4.1 アンチパターン1:判別プロパティを複数作っちゃう

// ❌ ダメな例
type Notification = 
  | { channel: 'email'; priority: 'high'; to: string; subject: string }
  | { channel: 'sms'; priority: 'urgent'; to: string; message: string }
  | { channel: 'push'; priority: 'high'; to: string; title: string }

function sendNotification(notification: Notification) {
  if (notification.channel === 'email') {
    console.log(notification.subject) // エラー!
  }
}

なぜダメなのか?コントロールフローベースの型絞り込みの限界

TypeScriptのコントロールフロー解析は、個別のプロパティごとに独立して型を推論します。
つまり、channel'email'だとわかっても、priority'high' | 'urgent'のユニオン型のままです。TypeScriptは「channelが'email'なら必ずpriorityが'high'」という相関関係を理解できないのです。

function explainTypeNarrowing(notification: Notification) {
  if (notification.channel === 'email') {
    console.log(notification.subject) // エラー!
  }
  
  if (notification.channel === 'email' && notification.priority === 'high') {
    console.log(notification.subject) // OK
  }
}

判別可能ユニオンが特別な理由は、1つのプロパティの値でオブジェクト全体の型が確定するからです。

改善方法:判別プロパティを1つに統一

// ✅ 良い例
type Notification = 
  | { type: 'email_high'; to: string; subject: string; body: string }
  | { type: 'email_normal'; to: string; subject: string; body: string }
  | { type: 'sms_urgent'; to: string; message: string }
  | { type: 'push_high'; to: string; title: string; body: string }

function sendNotificationBetter(notification: Notification) {
  switch (notification.type) {
    case 'email_high':
      sendHighPriorityEmail(notification.to, notification.subject)
      break
    case 'sms_urgent':
      sendUrgentSMS(notification.to, notification.message)
      break
  }
}

4.2 アンチパターン2:判別値を動的に作ろうとする

// ❌ ダメな例
const createOrder = (type: string, status: string) => {
  const orderType = `${type}_${status}`  // これは string 型になっちゃう!
  return { 
    type: orderType,
    orderId: generateId(),
  }
}

なぜダメなのか?リテラル型の本質を理解する

リテラル型とは「値そのものが型である」という性質を持ちます。これはコンパイル時に値が確定している必要があることを意味します。

const literal = 'pending'              // 型: 'pending' 
let variable = 'pending'               // 型: string
const combined = `${type}_${status}`   // 型: string

constで宣言した文字列は'pending'という具体的な値の型になりますが、文字列連結の結果は常にstring型になります。これは、連結結果が実行時に決まるためです。

テンプレートリテラル型との違い

TypeScript 4.1以降では型レベルでテンプレートリテラルを使えますが、これは型定義の話で、実行時の値生成とは別物です:

// 型レベル(コンパイル時)
type Combined = `${OrderType}_${StatusType}`  

// 値レベル(実行時)
const makeType = (order: string, status: string) => `${order}_${status}`
// 返り値は常にstring型

判別可能ユニオンには、コンパイル時に確定したリテラル型が必要なので、動的な文字列生成は使えません。

改善方法:素直にリテラル型を使う

// ✅ 良い例
type OrderType = 'online_pending' | 'online_confirmed' | 'store_pending' | 'store_ready'

const createOrder = (type: OrderType): Order => {
  switch (type) {
    case 'online_pending':
      return { 
        type,
        orderId: generateId(),
        paymentDeadline: addHours(new Date(), 24)
      }
    case 'store_ready':
      return {
        type,
        orderId: generateId(),
        pickupCode: generatePickupCode()
      }
    // ...
  }
}

4.3 アンチパターン3:exhaustivenessチェックをサボる

// ❌ ダメな例:新しいステータスを追加しても気づけない
function getOrderIcon(order: Order): string {
  switch (order.status) {
    case 'pending': return '⏳'
    case 'shipped': return '📦'
    case 'delivered': return '✅'
    default: return '❓'  // 新しいステータスが全部ここに...
  }
}

なぜダメなのか?never型と網羅性チェックの仕組み

never型は「到達不可能」を表す特殊な型です。全てのケースを処理すれば、defaultに到達する値は存在しないはず = never型になります。

function processOrder(order: Order) {
  switch (order.status) {
    case 'pending': return 'A'
    case 'shipped': return 'B'
    case 'delivered': return 'C'
    default:
      const _exhaustive: never = order
      throw new Error('Unreachable')
  }
}

全てのケースを処理したので、defaultブロックのorderの型はneverになります。never型には何も代入できないので、新しいケースを追加し忘れるとコンパイルエラーが発生します。

新しい状態を追加した時の挙動

// cancelledを追加したが、処理を忘れた場合
type OrderV2 = Order | { status: 'cancelled' }

// エラー: Type '{ status: "cancelled" }' is not assignable to type 'never'

一方、default: return '❓'にしてしまうと、半年後に10個の状態があるシステムで、どれが新しく追加されたか誰も覚えていません。全部 '❓' で表示されているアイコンが3つあっても、どれが処理漏れなのか分からなくなります。

改善方法:TypeScriptでコンパイルエラーを発生させる

// ✅ サーバーサイドロジックの例:継続不能なエラーとして扱う
function processOrderCritical(order: Order): string {
  switch (order.status) {
    case 'pending': return 'Processing payment...'
    case 'shipped': return 'Updating inventory...'
    case 'delivered': return 'Closing transaction...'
    case 'cancelled': return 'Reverting changes...'
    case 'refunded': return 'Processing refund...'
    case 'processing': return 'Validating order...'
    default: {
      const _exhaustive: never = order
      throw new Error(`Unknown order status: ${(order as any).status}`)
    }
  }
}

注意:exhaustivenessチェックの使い分け

上記はサーバーサイドのビジネスロジックなど、継続不能なエラーを扱う場合の例です。UIコンポーネントのアイコン表示など、アプリ全体をクラッシュさせたくない場合は、エラーを投げずにデフォルト値を返す方が適切な場合もあります:

// UIコンポーネントでの例:フォールバックを返す
function getOrderIcon(order: Order): string {
  switch (order.status) {
    case 'pending': return '⏳'
    case 'shipped': return '📦'
    case 'delivered': return '✅'
    default: {
      // 開発環境では警告を出す
      if (process.env.NODE_ENV === 'development') {
        console.warn(`Missing icon for status: ${(order as any).status}`)
      }
      return '📋' // デフォルトアイコンを返す
    }
  }
}

状況に応じて適切なエラーハンドリング戦略を選ぶことが重要です。詳しくは上級者編で解説します。

5. まとめ

判別可能ユニオンを使い始めてから開発効率が向上しました。特に良かったのは:

  1. 型の絞り込みが自動: if文での存在チェックが不要になった
  2. 漏れがなくなる: 新しいケースを追加時、TypeScriptが修正箇所を教えてくれる
  3. Zodとの相性: APIの型安全性も同時に実現できる

最初は手間に感じるかもしれませんが、プロジェクトの保守性向上に大きく貢献します。

TypeScriptの判別可能ユニオンは、状態管理の複雑さを型システムで解決する強力な機能です。ぜひ皆さんのプロジェクトでも活用してみてください!

次回の上級者編では、より高度な使い方(共通プロパティの管理、exhaustivenessチェックの使い分け、複雑なネスト構造への対処など)を解説します。

もしこの記事が面白かったら、いいね・拡散お願いします!!

32
codeciaoテックブログ

Discussion

Ohkubo KOHEIOhkubo KOHEI
if (order.status === 'shipped') {
    if (!order.trackingNumber) {
      throw new Error('追跡番号がないのに発送済み??')
    }

この trackingNumber のチェックが「判別可能ユニオン」によって不要になる、という説明だと、typescript が実行時の型を保証してくれるように読めます。

事前に orderHistorySchema.parse(data) によって実行時型チェックをすれば if (!order.trackingNumber) が不要になる、という説明であれば正しいですね。

田原翼田原翼

おっしゃる通り、記事の表現が誤解を招きますね。
TypeScriptの型チェックはコンパイル時のみで、実行時には何も保証しないという重要な点が不明確でした。

従って記事を以下のように修正しました:

  1. TypeScriptの型チェックが「コンパイル時のみ」であることを明記
  2. 外部データを扱う場合は実行時検証が必要であることを追記
  3. TypeScriptとZodの役割分担を明確に整理

ご指摘いただいた通り、orderHistorySchema.parse(data) による実行時検証があって初めて if (!order.trackingNumber) が不要になるという説明が正確ですね。

建設的なフィードバックをいただけて、記事の品質向上につながりました。ありがとうございます!

Honey32Honey32

失礼します。

以下の章ですが、channel で条件分岐をしたら priority は一意に決まるのが明らかなので、コード例は全く問題ないのですが、それについて存在しない問題を指摘していて、著しい違和感を覚えます。

4.1 アンチパターン1:判別プロパティを複数作っちゃう

// ❌ ダメな例
type Notification = 
 | { channel: 'email'; priority: 'high'; to: string; subject: string }
 | { channel: 'sms'; priority: 'urgent'; to: string; message: string }
 | { channel: 'push'; priority: 'high'; to: string; title: string }

function sendNotification(notification: Notification) {
 if (notification.channel === 'email') {
   console.log(notification.subject) // エラー!
 }
}

なぜダメなのか?コントロールフローベースの型絞り込みの限界
TypeScriptのコントロールフロー解析は、個別のプロパティごとに独立して型を推論します。
つまり、channelが'email'だとわかっても、priorityは'high' | 'urgent'のユニオン型のままです。 TypeScriptは「channelが'email'なら必ずpriorityが'high'」という相関関係を理解できないのです。

不愉快であったらすみませんが、生成 AI を使って生成した記事を、チェックせずに投稿されていますか?

コミュニティガイドライン にあるように、出力された内容を鵜呑みにせず、きちんと精査して投稿することをお勧めします。 今回の記事であれば、 https://www.typescriptlang.org/play/ で試したりすれば検証できます。

Zennは、情報発信を通じてエンジニア自身とコミュニティ全体の価値向上を目指しています。コミュニティに投稿する内容は、**どんなに小さくても良いのでコミュニティに貢献する内容にしましょう。 ** 自分本位な投稿、特に広告や宣伝を主な目的とした投稿や著作権を侵害する行為や、生成AIを利用した記事の安易な乱造などは避けてください。Zennはあなたの個人的なメモではなく、常に読み手がいることを忘れないでください。スクラップ機能やコメントにおいても、公開の場であることを忘れないでください。常に他者への敬意を払い、建設的なコミュニケーションを意識することが、より良いコミュニティを築くことにつながります。

生成AIを活用して執筆することは禁止していません。 著者の皆さまには、より質の高い記事を執筆するために生成AIを活用してほしいと考えています。ただし、下記のようにコンテンツを乱造する行為は控えてください。

  • 内容の正確性を確認せずに記事を投稿すること
  • 製品やサービスの宣伝を主な目的として記事を投稿すること
  • Zennサービス内や外部SNSでのフォロワー獲得、転職サービスなどでのスコア上昇を主な目的として記事を乱造すること
  • 外部サイトへ流入させることを主な目的として記事を投稿すること
ログインするとコメントできます
32