T-CREATOR

【実践】Zod の union・discriminatedUnion を使った柔軟な型定義

【実践】Zod の union・discriminatedUnion を使った柔軟な型定義

TypeScriptの開発において、複数の型の可能性を持つデータを扱うことは日常茶飯事です。APIレスポンスが成功時と失敗時で異なる形式を持ったり、フォームの入力内容によって必要な項目が変わったりと、柔軟な型定義が求められる場面は数多く存在します。

そんな時に頼りになるのが、Zodのunion()discriminatedUnion()という2つの強力な機能です。これらを使いこなすことで、実行時の型検証と静的型チェックの両方を満たす、堅牢で柔軟なアプリケーションを構築できるでしょう。

本記事では、基本的な使い方から実際の開発現場で活用できる応用例まで、段階的に解説していきます。コードを書きながら学べるよう、豊富な実例とともにお届けしますので、ぜひ最後までお付き合いください。

背景

TypeScriptの型システムとZodの関係

TypeScriptは静的型チェックによって開発時の安全性を高めてくれますが、実行時のデータ検証までは行えません。外部APIから取得したデータや、ユーザーからの入力データが期待する型と一致するかは、実行時に確認する必要があります。

ここでZodが威力を発揮します。Zodはスキーマ定義から TypeScript の型を自動生成し、同時に実行時のバリデーションも提供する優れたライブラリです。

以下の図で、TypeScriptとZodがどのように連携するかを確認しましょう。

mermaidflowchart TB
  schema[Zodスキーマ定義] -->|型生成| ts_type[TypeScript型]
  schema -->|実行時検証| validation[バリデーション]
  
  external_data[外部データ] -->|検証| validation
  validation -->|成功| safe_data[型安全なデータ]
  validation -->|失敗| error[バリデーションエラー]
  
  safe_data --> app[アプリケーション]
  ts_type --> app

この連携により、開発時の型安全性と実行時のデータ検証を一つのスキーマで実現できるのです。

union型が必要になる実際のユースケース

実際の開発では、単一の型では表現できない複雑なデータ構造に遭遇することがよくあります。以下のような場面でunion型の威力を実感できるでしょう。

#ユースケース説明
1APIレスポンスの状態管理成功・失敗・ローディング状態で異なる構造
2フォームの条件分岐入力タイプによって必要な項目が変化
3設定データの環境別管理開発・本番環境で異なる設定値
4イベント処理の多様性イベントタイプごとに異なるペイロード

例えば、ECサイトの商品データを考えてみましょう。物理商品とデジタル商品では、必要な情報が大きく異なります。

typescript// 物理商品の場合
interface PhysicalProduct {
  type: 'physical'
  name: string
  price: number
  weight: number
  shippingCost: number
}

// デジタル商品の場合
interface DigitalProduct {
  type: 'digital'
  name: string
  price: number
  downloadUrl: string
  fileSize: number
}

このような場合に、union型を使って柔軟に型定義を行うことで、コードの可読性と安全性を両立できます。

課題

通常のunion型の限界

TypeScriptの標準的なunion型だけでは、いくつかの課題に直面することがあります。特に実行時の型判定において、思わぬ落とし穴が待っているのです。

まず、基本的なunion型の問題を見てみましょう。

typescript// 基本的なunion型の定義
type Product = PhysicalProduct | DigitalProduct

// 実行時の型判定が複雑
function processProduct(product: Product) {
  // どちらの型か判定するのが困難
  if ('weight' in product) {
    // PhysicalProductと推論される
    console.log(`重量: ${product.weight}kg`)
  } else {
    // DigitalProductと推論される
    console.log(`ファイルサイズ: ${product.fileSize}MB`)
  }
}

この方法では、プロパティの存在確認による型の絞り込みに頼らざるを得ません。

型判定の複雑さと実行時エラーの課題

実際の開発では、以下のような問題が頻繁に発生します。

typescript// 外部APIからのデータ(型が不明)
const apiResponse: unknown = {
  type: 'physical',
  name: 'ノートパソコン',
  price: 80000,
  weight: 2.5,
  // shippingCostが欠落している可能性
}

// 型アサーションは危険
const product = apiResponse as Product
// 実行時エラーの可能性が高い
console.log(product.shippingCost) // undefined になる可能性

以下の図で、従来の型判定手法における問題点を整理します。

mermaidflowchart TD
  unknown_data[外部データ] --> type_guard{型ガード}
  
  type_guard -->|プロパティ存在チェック| complex_logic[複雑な判定ロジック]
  type_guard -->|型アサーション| unsafe_cast[危険な型変換]
  
  complex_logic --> maintenance_cost[保守性の低下]
  complex_logic --> bug_risk[バグの温床]
  
  unsafe_cast --> runtime_error[実行時エラー]
  unsafe_cast --> type_mismatch[型不整合]
  
  maintenance_cost --> problem[開発効率の低下]
  bug_risk --> problem
  runtime_error --> problem
  type_mismatch --> problem

従来の手法では、型安全性と開発効率のトレードオフに悩まされることが多かったのです。

図で理解できる要点

  • プロパティ存在チェックによる型判定は複雑で保守性が低い
  • 型アサーションは危険で実行時エラーの原因となる
  • 結果として開発効率と安全性の両方が犠牲になってしまう

解決策

これらの課題を解決するために、Zodは2つの強力な機能を提供しています。まずは基本的なunion()から見ていきましょう。

union()の基本的な使い方

Zodのunion()は、複数のスキーマのいずれかにマッチするデータを検証する機能です。TypeScriptのunion型と同様の概念ですが、実行時検証が加わることで格段に安全性が向上します。

基本構文と型定義

まずはZodでのスキーマ定義方法を確認しましょう。

typescriptimport { z } from 'zod'

// 物理商品のスキーマ
const physicalProductSchema = z.object({
  type: z.literal('physical'),
  name: z.string(),
  price: z.number().positive(),
  weight: z.number().positive(),
  shippingCost: z.number().nonnegative()
})

// デジタル商品のスキーマ
const digitalProductSchema = z.object({
  type: z.literal('digital'),
  name: z.string(),
  price: z.number().positive(),
  downloadUrl: z.string().url(),
  fileSize: z.number().positive()
})

次に、これらをunionで組み合わせます。

typescript// union()を使用した型定義
const productSchema = z.union([
  physicalProductSchema,
  digitalProductSchema
])

// TypeScript型の自動生成
type Product = z.infer<typeof productSchema>

簡単な例での実装

実際にバリデーションを行ってみましょう。

typescript// 安全なデータ検証
function validateAndProcessProduct(data: unknown) {
  try {
    const product = productSchema.parse(data)
    
    // 型安全にアクセス可能
    console.log(`商品名: ${product.name}`)
    console.log(`価格: ${product.price}円`)
    
    return { success: true, product }
  } catch (error) {
    console.error('バリデーションエラー:', error)
    return { success: false, error }
  }
}

使用例を見てみましょう。

typescript// 正常なデータの場合
const physicalProductData = {
  type: 'physical',
  name: 'ノートパソコン',
  price: 80000,
  weight: 2.5,
  shippingCost: 1500
}

const result1 = validateAndProcessProduct(physicalProductData)
// { success: true, product: {...} }

// 不正なデータの場合
const invalidData = {
  type: 'physical',
  name: 'ノートパソコン',
  price: -1000, // 負の値は不正
  weight: 2.5
  // shippingCostが欠落
}

const result2 = validateAndProcessProduct(invalidData)
// { success: false, error: ZodError }

これにより、外部データを安全に検証し、型安全にアクセスできるようになります。

discriminatedUnion()の威力

union()でも十分強力ですが、さらに効率的で型安全な方法がdiscriminatedUnion()です。この機能は、「判別子(discriminator)」と呼ばれる共通のプロパティを使って、より確実で高速な型判定を実現します。

discriminatedUnionの概念と利点

discriminatedUnionは、各スキーマが共通の判別用プロパティを持っている場合に最適化されたunion型です。

typescript// discriminatedUnion()の基本構文
const productSchema = z.discriminatedUnion('type', [
  physicalProductSchema,
  digitalProductSchema
])

第一引数の'type'が判別子プロパティです。各スキーマがこのプロパティを持ち、異なるリテラル値を持つ必要があります。

判別プロパティの活用

discriminatedUnionを使った場合の型絞り込みは非常にスマートです。

typescriptfunction processProduct(product: Product) {
  switch (product.type) {
    case 'physical':
      // TypeScriptが自動的にPhysicalProductと推論
      console.log(`重量: ${product.weight}kg`)
      console.log(`送料: ${product.shippingCost}円`)
      break
      
    case 'digital':
      // TypeScriptが自動的にDigitalProductと推論  
      console.log(`ファイルサイズ: ${product.fileSize}MB`)
      console.log(`ダウンロードURL: ${product.downloadUrl}`)
      break
      
    default:
      // 型安全性: 未処理のケースをコンパイル時に検出
      const _exhaustive: never = product
      throw new Error('未処理の商品タイプです')
  }
}

パフォーマンス面でも大きな利点があります。

#機能union()discriminatedUnion()
1型判定速度順次チェック判別子による高速判定
2エラーメッセージ汎用的より具体的
3TypeScript推論複雑明確で確実
4パフォーマンス中程度高速

discriminatedUnionは、最初に判別子をチェックして適切なスキーマを選択するため、全てのスキーマを順次試行するunion()よりも効率的です。

実用的な使用例

イベント処理システムでの活用例を見てみましょう。

typescript// イベントスキーマの定義
const userClickEventSchema = z.object({
  eventType: z.literal('click'),
  timestamp: z.date(),
  elementId: z.string(),
  coordinates: z.object({
    x: z.number(),
    y: z.number()
  })
})

const pageViewEventSchema = z.object({
  eventType: z.literal('pageView'),
  timestamp: z.date(),
  url: z.string().url(),
  referrer: z.string().optional()
})

const formSubmitEventSchema = z.object({
  eventType: z.literal('formSubmit'),
  timestamp: z.date(),
  formId: z.string(),
  formData: z.record(z.unknown())
})

これらをdiscriminatedUnionで統合します。

typescript// イベントスキーマの統合
const eventSchema = z.discriminatedUnion('eventType', [
  userClickEventSchema,
  pageViewEventSchema,
  formSubmitEventSchema
])

type Event = z.infer<typeof eventSchema>

イベント処理関数では、型安全で効率的な分岐処理が可能になります。

typescriptfunction handleEvent(event: Event) {
  switch (event.eventType) {
    case 'click':
      // クリックイベント固有のプロパティに安全にアクセス
      trackClick(event.elementId, event.coordinates)
      break
      
    case 'pageView':
      // ページビューイベント固有のプロパティに安全にアクセス
      trackPageView(event.url, event.referrer)
      break
      
    case 'formSubmit':
      // フォーム送信イベント固有のプロパティに安全にアクセス
      processFormSubmission(event.formId, event.formData)
      break
  }
}

これにより、実行時の型安全性とコンパイル時の型推論の両方を最高レベルで実現できます。

具体例

理論だけでは実感が湧かないかもしれません。ここからは、実際の開発現場でよく遭遇する2つのケースを題材に、union型とdiscriminatedUnion型の実用的な活用方法を詳しく見ていきましょう。

APIレスポンスの型定義

成功・失敗レスポンスの型分岐

Web APIを設計する際、成功時と失敗時で全く異なる構造のレスポンスを返すことが一般的です。従来の方法では型安全性に課題がありましたが、discriminatedUnionを使うことで優雅に解決できます。

まず、APIレスポンスのスキーマを定義しましょう。

typescriptimport { z } from 'zod'

// 成功レスポンスのスキーマ
const successResponseSchema = z.object({
  status: z.literal('success'),
  data: z.object({
    id: z.string(),
    username: z.string(),
    email: z.string().email(),
    createdAt: z.string().datetime()
  }),
  message: z.string()
})

// エラーレスポンスのスキーマ
const errorResponseSchema = z.object({
  status: z.literal('error'),
  error: z.object({
    code: z.string(),
    message: z.string(),
    details: z.array(z.string()).optional()
  })
})

次に、これらをdiscriminatedUnionで統合します。

typescript// APIレスポンススキーマの統合
const apiResponseSchema = z.discriminatedUnion('status', [
  successResponseSchema,
  errorResponseSchema
])

type ApiResponse = z.infer<typeof apiResponseSchema>

リアルなAPI設計での活用

実際のAPI呼び出し関数を実装してみましょう。

typescript// ユーザー作成API関数
async function createUser(userData: {
  username: string
  email: string
  password: string
}): Promise<ApiResponse> {
  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(userData)
    })
    
    const rawData = await response.json()
    
    // Zodによる実行時バリデーション
    return apiResponseSchema.parse(rawData)
  } catch (error) {
    // ネットワークエラーなどの場合のフォールバック
    return {
      status: 'error',
      error: {
        code: 'NETWORK_ERROR',
        message: 'ネットワークエラーが発生しました'
      }
    }
  }
}

呼び出し側では、型安全にレスポンスを処理できます。

typescript// API呼び出しと結果処理
async function handleUserCreation(userData: any) {
  const result = await createUser(userData)
  
  switch (result.status) {
    case 'success':
      // 成功時の処理:dataプロパティに安全にアクセス
      console.log(`ユーザー作成成功: ${result.data.username}`)
      console.log(`ユーザーID: ${result.data.id}`)
      
      // UIの更新
      showSuccessMessage(result.message)
      redirectToUserProfile(result.data.id)
      break
      
    case 'error':
      // エラー時の処理:errorプロパティに安全にアクセス
      console.error(`エラーコード: ${result.error.code}`)
      console.error(`エラーメッセージ: ${result.error.message}`)
      
      // 詳細なエラー情報の表示
      if (result.error.details) {
        result.error.details.forEach(detail => {
          console.error(`詳細: ${detail}`)
        })
      }
      
      // UIの更新
      showErrorMessage(result.error.message)
      break
  }
}

以下の図で、APIレスポンス処理のフローを可視化します。

mermaidflowchart TD
  api_call[API呼び出し] --> raw_response[生レスポンス]
  raw_response --> zod_validation{Zod バリデーション}
  
  zod_validation -->|成功| validated_data[検証済みデータ]
  zod_validation -->|失敗| validation_error[バリデーションエラー]
  
  validated_data --> status_check{ステータス確認}
  
  status_check -->|success| success_handling[成功時処理]
  status_check -->|error| error_handling[エラー時処理]
  
  success_handling --> ui_update_success[成功UI更新]
  error_handling --> ui_update_error[エラーUI更新]
  
  validation_error --> fallback[フォールバック処理]
  fallback --> ui_update_error

この実装により、以下のメリットを享受できます:

図で理解できる要点

  • APIレスポンスの型安全性が実行時まで保証される
  • 成功・失敗の分岐処理がコンパイル時にチェックされる
  • 未処理のケースやプロパティアクセスエラーを防げる

フォームデータの動的型定義

入力タイプによる型切り替え

現代のWebアプリケーションでは、ユーザーの選択によってフォームの内容が動的に変化することが一般的です。例えば、支払い方法の選択によって必要な入力項目が変わるケースを考えてみましょう。

まず、各支払い方法のスキーマを定義します。

typescript// クレジットカード支払いのスキーマ
const creditCardPaymentSchema = z.object({
  paymentMethod: z.literal('credit_card'),
  cardNumber: z.string().regex(/^\d{16}$/, 'カード番号は16桁で入力してください'),
  expiryMonth: z.number().min(1).max(12),
  expiryYear: z.number().min(2024).max(2034),
  cvv: z.string().regex(/^\d{3,4}$/, 'CVVは3-4桁で入力してください'),
  holderName: z.string().min(1, 'カード名義人を入力してください')
})

// 銀行振込のスキーマ
const bankTransferSchema = z.object({
  paymentMethod: z.literal('bank_transfer'),
  bankName: z.string().min(1, '銀行名を入力してください'),
  accountNumber: z.string().regex(/^\d{7,8}$/, '口座番号は7-8桁で入力してください'),
  accountHolderName: z.string().min(1, '口座名義人を入力してください'),
  transferDate: z.string().datetime()
})

// 電子マネー決済のスキーマ
const digitalWalletSchema = z.object({
  paymentMethod: z.literal('digital_wallet'),
  walletType: z.enum(['paypal', 'apple_pay', 'google_pay']),
  walletId: z.string().min(1, 'ウォレットIDを入力してください'),
  authToken: z.string().optional()
})

条件付きフィールドの実装

これらをdiscriminatedUnionで統合して、動的なフォームバリデーションシステムを構築します。

typescript// 支払い情報スキーマの統合
const paymentInfoSchema = z.discriminatedUnion('paymentMethod', [
  creditCardPaymentSchema,
  bankTransferSchema,
  digitalWalletSchema
])

type PaymentInfo = z.infer<typeof paymentInfoSchema>

React コンポーネントでの実装例を見てみましょう。

typescriptimport React, { useState } from 'react'
import { z } from 'zod'

// フォームコンポーネント
function PaymentForm() {
  const [paymentMethod, setPaymentMethod] = useState<string>('')
  const [formData, setFormData] = useState<any>({})
  const [errors, setErrors] = useState<string[]>([])
  
  // フォーム送信処理
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    
    try {
      // 選択された支払い方法に応じたバリデーション
      const validatedData = paymentInfoSchema.parse({
        paymentMethod,
        ...formData
      })
      
      console.log('バリデーション成功:', validatedData)
      processPayment(validatedData)
      
    } catch (error) {
      if (error instanceof z.ZodError) {
        const errorMessages = error.errors.map(err => err.message)
        setErrors(errorMessages)
      }
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 支払い方法選択 */}
      <div>
        <label>支払い方法</label>
        <select 
          value={paymentMethod} 
          onChange={(e) => setPaymentMethod(e.target.value)}
        >
          <option value="">選択してください</option>
          <option value="credit_card">クレジットカード</option>
          <option value="bank_transfer">銀行振込</option>
          <option value="digital_wallet">電子マネー</option>
        </select>
      </div>
      
      {/* 条件付きフォームフィールド */}
      {renderConditionalFields(paymentMethod, formData, setFormData)}
      
      {/* エラー表示 */}
      {errors.length > 0 && (
        <div className="errors">
          {errors.map((error, index) => (
            <p key={index} className="error">{error}</p>
          ))}
        </div>
      )}
      
      <button type="submit">支払い情報を送信</button>
    </form>
  )
}

条件付きフィールドのレンダリング関数です。

typescript// 支払い方法に応じたフィールド表示
function renderConditionalFields(
  paymentMethod: string, 
  formData: any, 
  setFormData: Function
) {
  const updateField = (field: string, value: any) => {
    setFormData((prev: any) => ({ ...prev, [field]: value }))
  }
  
  switch (paymentMethod) {
    case 'credit_card':
      return (
        <div>
          <input
            type="text"
            placeholder="カード番号 (16桁)"
            value={formData.cardNumber || ''}
            onChange={(e) => updateField('cardNumber', e.target.value)}
          />
          <input
            type="number"
            placeholder="有効期限月 (1-12)"
            value={formData.expiryMonth || ''}
            onChange={(e) => updateField('expiryMonth', parseInt(e.target.value))}
          />
          <input
            type="number"
            placeholder="有効期限年"
            value={formData.expiryYear || ''}
            onChange={(e) => updateField('expiryYear', parseInt(e.target.value))}
          />
          <input
            type="text"
            placeholder="CVV"
            value={formData.cvv || ''}
            onChange={(e) => updateField('cvv', e.target.value)}
          />
          <input
            type="text"
            placeholder="カード名義人"
            value={formData.holderName || ''}
            onChange={(e) => updateField('holderName', e.target.value)}
          />
        </div>
      )
      
    case 'bank_transfer':
      return (
        <div>
          <input
            type="text"
            placeholder="銀行名"
            value={formData.bankName || ''}
            onChange={(e) => updateField('bankName', e.target.value)}
          />
          <input
            type="text"
            placeholder="口座番号 (7-8桁)"
            value={formData.accountNumber || ''}
            onChange={(e) => updateField('accountNumber', e.target.value)}
          />
          <input
            type="text"
            placeholder="口座名義人"
            value={formData.accountHolderName || ''}
            onChange={(e) => updateField('accountHolderName', e.target.value)}
          />
          <input
            type="datetime-local"
            value={formData.transferDate || ''}
            onChange={(e) => updateField('transferDate', e.target.value)}
          />
        </div>
      )
      
    case 'digital_wallet':
      return (
        <div>
          <select
            value={formData.walletType || ''}
            onChange={(e) => updateField('walletType', e.target.value)}
          >
            <option value="">ウォレット種類を選択</option>
            <option value="paypal">PayPal</option>
            <option value="apple_pay">Apple Pay</option>
            <option value="google_pay">Google Pay</option>
          </select>
          <input
            type="text"
            placeholder="ウォレットID"
            value={formData.walletId || ''}
            onChange={(e) => updateField('walletId', e.target.value)}
          />
        </div>
      )
      
    default:
      return null
  }
}

支払い処理関数では、型安全にデータを処理できます。

typescript// 型安全な支払い処理
function processPayment(paymentInfo: PaymentInfo) {
  switch (paymentInfo.paymentMethod) {
    case 'credit_card':
      // クレジットカード固有の処理
      console.log(`カード処理: ${paymentInfo.cardNumber}`)
      console.log(`名義人: ${paymentInfo.holderName}`)
      // 実際のカード決済API呼び出し
      break
      
    case 'bank_transfer':
      // 銀行振込固有の処理
      console.log(`銀行: ${paymentInfo.bankName}`)
      console.log(`口座: ${paymentInfo.accountNumber}`)
      // 銀行振込の処理
      break
      
    case 'digital_wallet':
      // 電子マネー固有の処理
      console.log(`ウォレット: ${paymentInfo.walletType}`)
      console.log(`ID: ${paymentInfo.walletId}`)
      // 電子マネー決済API呼び出し
      break
  }
}

この実装により、以下の利点を得られます。

#利点説明
1型安全性選択した支払い方法に応じて必要なフィールドが自動的に型チェックされる
2バリデーション各支払い方法特有の検証ルールが適用される
3保守性新しい支払い方法の追加が容易
4UX向上不要なフィールドが表示されず、ユーザーの混乱を防げる

この方法により、複雑で動的なフォームでも、型安全性を保ちながら柔軟な実装が可能になります。

まとめ

本記事では、Zodのunion()discriminatedUnion()を使った柔軟な型定義について、基本概念から実践的な活用方法まで幅広く解説してきました。

重要なポイントの振り返り

Zodのunion型機能は、従来のTypeScriptだけでは解決が困難だった課題を見事に解決してくれます。特に以下の3つの側面で大きな価値を提供しています。

1. 型安全性の向上

従来の型アサーションや複雑な型ガードに頼る必要がなくなり、実行時までしっかりと型安全性が保証されます。外部APIからの予期しないデータや、ユーザー入力の検証において、バグを未然に防げるでしょう。

2. 開発効率の向上

discriminatedUnion()による最適化された型判定により、パフォーマンスと可読性を両立できます。switch文による分岐処理で、TypeScriptが自動的に型を絞り込んでくれるため、IDE の補完機能も最大限活用できるのです。

3. 保守性の確保

新しい型パターンの追加や変更が容易になり、長期的なメンテナンスコストを大幅に削減できます。exhaustive checkingにより、型の追加時に対応漏れがないかもコンパイル時に確認できるのは心強い限りです。

使い分けの指針

union()とdiscriminatedUnion()の選択については、以下の基準で判断するとよいでしょう。

union()を選ぶべきケース

  • 型の数が少ない(2-3個程度)
  • 判別可能なプロパティがない
  • シンプルな型合成で十分

discriminatedUnion()を選ぶべきケース

  • 型の数が多い(4個以上)
  • 共通の判別プロパティが存在する
  • 高いパフォーマンスが求められる
  • 型安全な分岐処理を多用する

実践への第一歩

まずは小さなプロジェクトから始めて、APIレスポンス処理やフォームバリデーションといった身近な場面で活用してみることをお勧めします。実際に手を動かして体験することで、union型の威力を実感していただけるはずです。

型安全性と柔軟性を両立するZodのunion機能を駆使して、より堅牢で保守性の高いアプリケーションを開発していきましょう。きっと、従来の開発スタイルでは実現できなかった新たな可能性に気づかれることでしょう。

関連リンク

公式ドキュメント

学習リソース

関連技術・ライブラリ

コミュニティ・参考記事