🚢

判別可能なユニオン型を試してみる

2024/06/03に公開

TypeScriptにおける判別可能なユニオン型(Discriminated Union Types)は、異なるデータのバリエーションを安全かつ効率的に管理するための強力な抽象化を提供します。

この記事では、判別可能なユニオン型とは何か、そして React & Next.js の環境でそれをどのように使用するかについて試してみます。具体的な例も提供します。

判別可能なユニオン型(Discriminated Union Types)とは?

TypeScriptにおける判別可能なユニオン型(Discriminated Union Types)とは、複数のバリエーションの値を表現できる型を定義するための技術です。これらのバリエーションを明確に区別する方法を用意されています。

この技術は、一般に「ディスクリミネータ」(`discriminator``)と呼ばれるプロパティをすべてのバリエーションに共通して持たせることで実現されます。これにより、TypeScriptの静的型付けシステムを活用しながら、多相的なデータをエレガントに操作する手段が提供されます。

export type Circle = {
  kind: 'circle'
  radius: number
}

export type Square = {
  kind: 'square'
  sideLength: number
}

export type Triangle = {
  kind: 'triangle'
  base: number
  height: number
}

type Shape = Circle | Square | Triangle

// 形状の面積を計算する関数
export const calculateArea = (shape: Shape): number => {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'square':
      return shape.sideLength ** 2
    case 'triangle':
      return (shape.base * shape.height) / 2
    default:
      throw new Error('サポートされていない形状')
  }
}

// 使用例
const circle: Circle = { kind: 'circle', radius: 5 }
const square: Square = { kind: 'square', sideLength: 4 }
const triangle: Triangle = { kind: 'triangle', base: 3, height: 6 }

console.log('円の面積:', calculateArea(circle)) // 出力: 円の面積: 78.53981633974483
console.log('正方形の面積:', calculateArea(square)) // 出力: 正方形の面積: 16
console.log('三角形の面積:', calculateArea(triangle)) // 出力: 三角形の面積: 9

この例では、Shapeは判別可能なユニオン型であり、3つの異なる幾何学形状(円、四角形、三角形)を表すことができます。Shape の各バリエーションは、ディスクリミネータ(discriminator)として機能する kind プロパティによって区別されます。Circle、Square、および Triangle の各タイプは、それぞれの形状に特有のプロパティ(例えば、円の場合は radius、四角形の場合は sideLength など)を定義しています。

関数 calculateArea は、形状 (Shape) を引数として受け取り、kind プロパティに基づいて特定の形状タイプを判断し、それに応じて面積を計算します。

この例は、判別可能なユニオン型が異なるデータのバリエーションを安全かつ正確に操作する方法を示しています。これにより、TypeScriptはディスクリミネータ(discriminator)の値に基づいて各インスタンスの具体的な型を特定することができます。

次の例では、判別可能なユニオン型を React & Next.js 環境でどのように使用するかを見ていきます。

バージョン管理されたAPI呼び出し

フロントエンドにおける判別可能なユニオン型(Discriminated Union Types)の使用例の一つは、バージョン管理されたAPI呼び出しの管理です。バージョンによって異なる形式のレスポンスを返すAPIを利用する場合があります。このような場合、判別可能なユニオン型を使用してこれらの異なるレスポンスをモデル化し、そのレスポンスに応じて適切に処理することができます。

この例では、Next.jsアプリケーション内でAPIから取得したユーザー情報を表示したいと考えています。判別可能なユニオン型を使用して、ユーザーのデータの異なるバージョンを管理し、それに応じて表示を行います。

以下に、ユーザー情報を表示するために判別可能なユニオン型を使用するReactコンポーネントの例を示します。

// src/api.ts
import { REST_API_DOMAIN } from '@/constants/domain'
export type APIVersion = 'v1' | 'v2'

export type UserV1 = {
  version: 'v1'
  data: {
    name: string
    email: string
  }
}

export type UserV2 = {
  version: 'v2'
  data: {
    fullName: string
    email: string
  }
}

export type User = UserV1 | UserV2

export async function getUser(version: APIVersion): Promise<User> {
  const response = await fetch(`${REST_API_DOMAIN}/api/${version}/data`)
  return await response.json()
}
// src/components/UserInfo.tsx
import { FC } from 'react'
import { getUser, APIVersion } from '@/api'

export const UserInfo: FC<{ version: APIVersion }> = async ({ version }) => {
  const userData = await getUser(version)

  return (
    <>
      <h2>ユーザー情報</h2>
      {userData.version === 'v1' && (
        <div>
          <p>名前: {userData.data.name}</p>
          <p>Eメール: {userData.data.email}</p>
        </div>
      )}
      {userData.version === 'v2' && (
        <div>
          <p>フルネーム: {userData.data.fullName}</p>
          <p>Eメール: {userData.data.email}</p>
        </div>
      )}
    </>
  )
}

この例では、UserInfo コンポーネントが getUser 関数を使用して指定されたバージョンのAPIを呼び出します。データが取得されると、取得されたデータのバージョンに応じてユーザー情報を表示します。

このコンポーネントは、userData ステートの version プロパティを利用して、受け取ったユーザーデータのバージョンを判断し、それに応じて適切な情報を表示します。

このようにして、判別可能なユニオン型(Discriminated Union Types)を利用することで、Next.jsアプリケーション内で異なるバージョンのAPIを安全かつ正確に管理することができます。

Reactコンポーネントにおける状態管理

判別可能なユニオン型(Discriminated Union Types)のもう一つのユースケースは、Reactコンポーネントの状態("loading"、"error"、"success")の管理です。

このパターンは、API呼び出しやデータフェッチの結果に基づく状態遷移を明確に管理するのに役立ちます。判別可能なユニオン型を使用することで、各状態に対して適切なUIを表示することができます。

以下に、判別可能なユニオン型を使用してコンポーネントの状態を管理する例を示します。

type LoadingState = { status: 'loading' }
type SuccessState<T> = { status: 'success'; data: T }
type ErrorState = { status: 'error'; message: string }

type State<T> = LoadingState | SuccessState<T> | ErrorState

function MyComponent<T>({ state }: { state: State<T> }) {
  switch (state.status) {
    case 'loading':
      return <div>読み込み中...</div>
    case 'success':
      return <div>データが読み込まれました: {String(state.data)}</div>
    case 'error':
      return <div>エラー: {state.message}</div>
    default:
      return null
  }
}

この例では、コンポーネント MyComponent が判別可能なユニオン型(Discriminated Union Types)を使用して、loading、success、error といった異なる状態をモデル化し、状態に応じてコンポーネントの内容を表示します。ここで、ディスクリミネータ(discriminator)となっているのは status フィールドです。

Next.jsアプリケーションにおけるアクションの管理

useContext と useReducer を組み合わせて、アクションを管理する方法を紹介します。この方法は、Reduxのような状態管理をReact内でシンプルに実現するために有効です。

以下に、TODOリストを扱うシンプルな例を示します。

// src/actions/todo.ts
type AddTodoAction = {
  type: 'ADD_TODO'
  payload: string
}

type ToggleTodoAction = {
  type: 'TOGGLE_TODO'
  payload: number
}

type ClearTodosAction = {
  type: 'CLEAR_TODOS'
}

export type TodoAction = AddTodoAction | ToggleTodoAction | ClearTodosAction

export const addTodo = (text: string): AddTodoAction => ({
  type: 'ADD_TODO',
  payload: text,
})

export const toggleTodo = (id: number): ToggleTodoAction => ({
  type: 'TOGGLE_TODO',
  payload: id,
})

export const clearTodos = (): ClearTodosAction => ({
  type: 'CLEAR_TODOS',
})
// src/hooks/todoReducer.ts
import { TodoAction } from '@/actions/todo'

export type TodoState = {
  todos: { id: number; text: string; completed: boolean }[]
}

export const initialState: TodoState = {
  todos: [],
}

export const todoReducer = (
  state = initialState,
  action: TodoAction,
): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: state.todos.length + 1,
            text: action.payload,
            completed: false,
          },
        ],
      }
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo,
        ),
      }
    case 'CLEAR_TODOS':
      return {
        ...state,
        todos: [],
      }
    default:
      return state
  }
}
// src/contexts/TodoContext.tsx
import React, { createContext, useReducer, ReactNode } from 'react'
import { TodoState, todoReducer, initialState } from '@/hooks/todoReducer'
import { addTodo, toggleTodo, clearTodos } from '@/actions/todo'

type TodoContextProps = {
  state: TodoState
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  clearTodos: () => void
}

export const TodoContext = createContext<TodoContextProps | undefined>(
  undefined,
)

export const TodoProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [state, dispatch] = useReducer(todoReducer, initialState)

  const value = {
    state,
    addTodo: (text: string) => dispatch(addTodo(text)),
    toggleTodo: (id: number) => dispatch(toggleTodo(id)),
    clearTodos: () => dispatch(clearTodos()),
  }

  return <TodoContext.Provider value={value}>{children}</TodoContext.Provider>
}
// src/hooks/useTodo.ts
import { useContext } from 'react'
import { TodoContext } from '@/contexts/TodoContext'

export const useTodo = () => {
  const context = useContext(TodoContext)
  if (context === undefined) {
    throw new Error('useTodoはTodoProviderの内側で使用する必要があります')
  }
  return context
}

この例では、判別可能なユニオン型(Discriminated union types)を用いて、様々な種類のアクション TodoAction を定義しています。各アクションは特定のタイプ type を持ち、場合によってはデータ payload を含むことがあります。さらに、アクションクリエーターを使用してアクションを作成しています。

リデューサーでは、判別可能なユニオン型を用いて異なるアクションをフィルタリングし、アクションのタイプに応じた適切なロジックを実行しています。

判別可能なユニオン型を使用することで、全てのアクションが正しく型付けされ、リデューサーやアクションクリエーター内でuseContextがアクションと状態の型を自動的に推論できるようになります。これにより、コードがより安全でメンテナンスしやすくなります。

'use client'

import React, { useState } from 'react'
import { TodoProvider } from '@/contexts/TodoContext'
import { useTodo } from '@/hooks/useTodo'

export const TodoApp = () => {
  return (
    <TodoProvider>
      <div className="min-h-screen bg-gray-100 flex items-center justify-center">
        <div className="bg-white p-6 rounded shadow-md w-full max-w-xl">
          <h1 className="text-2xl font-bold mb-4">TODO LIST</h1>
          <TodoInput />
          <TodoList />
        </div>
      </div>
    </TodoProvider>
  )
}

const TodoList = () => {
  const { state, toggleTodo, clearTodos } = useTodo()

  return (
    <div>
      <ul className="space-y-2">
        {state.todos.map((todo) => (
          <li
            key={todo.id}
            onClick={() => toggleTodo(todo.id)}
            className={`cursor-pointer p-2 rounded ${
              todo.completed ? 'line-through text-gray-500' : 'text-gray-800'
            }`}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      <button
        onClick={clearTodos}
        className="mt-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-700"
      >
        すべてクリア
      </button>
    </div>
  )
}

const TodoInput = () => {
  const { addTodo } = useTodo()
  const [text, setText] = useState('')

  const handleAddTodo = () => {
    addTodo(text)
    setText('')
  }

  return (
    <div className="mt-4 flex space-x-2">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        className="flex-grow p-2 border border-gray-300 rounded"
        placeholder="新しいタスクを入力"
      />
      <button
        onClick={handleAddTodo}
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-700"
      >
        追加
      </button>
    </div>
  )
}

Reactコンポーネント内でのコンテンツのバリアントの管理

Reactコンポーネント内でメディアのようなコンテンツのバリアントを管理しなければならない場合があります。

以下は、メディアコンテンツの異なるバリアント(例:画像、動画)を管理する方法を示した例です。TypeScriptの判別可能なユニオン型(Discriminated union types)を使用して、コンテンツのタイプごとの表示方法を柔軟に切り替えます。

// src/components/RenderMedia.tsx
import NextImage from 'next/image'

type Image = { type: 'image'; url: string; alt: string }
type Video = { type: 'video'; url: string; caption: string }

type Media = Image | Video

export const RenderMedia = (media: Media) => {
  switch (media.type) {
    case 'image':
      return <NextImage src={media.url} alt={media.alt} />
    case 'video':
      return (
        <div>
          <video src={media.url} controls />
          <p>{media.caption}</p>
        </div>
      )
    default:
      return null
  }
}

他の例と同様に、ここでは type フィールドで表されるディスクリミネータ(discriminator)を使用して、異なる種類のメディアを正しく表示しています。

まとめ

判別可能なユニオン型(Discriminated Union Types)は、TypeScriptの強力な機能であり、複雑で多様なデータ型を簡潔かつ安全にモデリングすることができます。APIレスポンスの異なるバージョンをモデリングしたり、Reactコンポーネントの状態を管理したり、useContextのアクションを定義したり、コンテンツのバリアントを表現したりする際に、判別可能なユニオン型はコードをより堅牢に、読みやすくし、エラーを回避するための効果的なソリューションを提供します。

ディスクリミネータ(discriminator)を基にデータの異なるバリアントを区別する能力のおかげで、判別可能なユニオン型はフロントエンドプロジェクトにおけるデータの安全かつ正確な操作を保証します。また、TypeScriptによる静的なコード検証を深く行えるため、コードのメンテナンスが容易になります。

判別可能なユニオン型は、堅牢でメンテナンス性の高いアプリケーションを作成しようとするTypeScriptフロントエンド開発者にとって必須のツールです。これを適切に活用することで、ReactやNext.jsプロジェクトのコードの可読性と品質を向上させることができます。

今回、資料で作成したソースコードをgithub上に置いています。ご参考になれば幸いです。

https://github.com/andmorefine/next-typescript-discriminated-unions

参考資料

Discussion