T-CREATOR

TypeScript enum vs union型:どちらを使うべきか徹底比較

TypeScript enum vs union型:どちらを使うべきか徹底比較

TypeScript 開発において、enum と union 型の選択は単なる型システムの問題ではありません。実際のプロジェクトでは、React、Vue、Express、データベース ORM など、数多くの外部ライブラリやフレームワークと連携する必要があります。この連携において、enum と union 型はそれぞれ異なる特性を示し、開発効率や保守性に大きな影響を与えます。

本記事では、モダンな TypeScript エコシステムにおいて、どちらの型がより自然で効率的な統合を可能にするのかを、実際のライブラリ使用例と API デザインパターンを通して詳しく検証いたします。あなたのプロジェクトに最適な選択ができるよう、具体的な実装例とともに解説していきます。

背景:TypeScript エコシステムと型システムの相互作用

TypeScript エコシステムは急速に発展しており、多くのライブラリが TypeScript First 設計を採用するようになりました。この変化により、型システムの選択がライブラリとの統合性に大きく影響するようになっています。

TypeScript ライブラリの進化

近年の TypeScript ライブラリは、以下のような特徴を持つようになりました:

#特徴説明影響
1ジェネリクス活用型パラメータを活用した柔軟な API 設計union 型との親和性が高い
2リテラル型推論文字列リテラル型の積極的な活用union 型の恩恵を受けやすい
3条件型サポート複雑な型変換ロジックの内包型レベルでの計算が可能
4Template Literal Types動的な型生成への対応より柔軟な型表現が可能

これらの進化により、従来の enum ベースの設計よりも、union 型を活用した設計の方がエコシステム全体との親和性が高くなってきています。

ライブラリ設計における型の役割

モダンな TypeScript ライブラリでは、型システムが API の一部として機能します。

typescript// React Hook Form での例
import { useForm } from 'react-hook-form';

// union型ベースの設計
type FormData = {
  status: 'draft' | 'published' | 'archived';
  priority: 'low' | 'medium' | 'high';
};

const { register, watch } = useForm<FormData>();

// 型推論により、watchの戻り値も適切に型付けされる
const currentStatus = watch('status'); // 'draft' | 'published' | 'archived'

このように、union 型は型推論システムとの連携において優れた性能を発揮します。

課題:外部ライブラリとの型互換性と開発効率

実際のプロジェクトでは、enum と union 型の選択によって生じる具体的な課題があります。

Enum 使用時の課題

ライブラリとの型不整合

多くのライブラリは union 型ベースの型定義を提供しているため、enum を使用すると型の変換が必要になります。

typescript// ライブラリの型定義(一般的なパターン)
interface ApiResponse {
  status: 'success' | 'error' | 'pending';
}

// Enumを使用した場合
enum Status {
  Success = 'success',
  Error = 'error',
  Pending = 'pending',
}

// 型変換が必要になる
const response: ApiResponse = {
  status: Status.Success as 'success', // 型アサーションが必要
};

// さらに問題となるケース
function handleResponse(
  status: 'success' | 'error' | 'pending'
) {
  // 処理...
}

// enumの値を直接渡すとエラーになる場合がある
handleResponse(Status.Success); // TypeScriptのバージョンによってはエラー

Tree Shaking の問題

Enum は実行時にオブジェクトとして存在するため、バンドルサイズと Tree Shaking に影響を与えます。

typescript// enumの場合
enum UserRole {
  Admin = 'admin',
  User = 'user',
  Guest = 'guest',
}

// コンパイル後のJavaScript
var UserRole;
(function (UserRole) {
  UserRole['Admin'] = 'admin';
  UserRole['User'] = 'user';
  UserRole['Guest'] = 'guest';
})(UserRole || (UserRole = {}));

// union型の場合
type UserRole = 'admin' | 'user' | 'guest';

// コンパイル後のJavaScript
// (型情報のため、JavaScriptコードは生成されない)

Union 型使用時の課題

定数値の管理

Union 型では、定数値を別途管理する仕組みが必要になります。

typescript// union型
type Theme = 'light' | 'dark' | 'auto';

// 定数値を別途定義する必要がある
const THEMES = ['light', 'dark', 'auto'] as const;
// または
const THEME = {
  LIGHT: 'light',
  DARK: 'dark',
  AUTO: 'auto',
} as const;

type Theme = (typeof THEME)[keyof typeof THEME];

実行時の値検証

Union 型は実行時の値検証において追加的な工夫が必要です。

typescript// enumの場合は簡単
function isValidStatusEnum(value: string): value is Status {
  return Object.values(Status).includes(value as Status);
}

// union型の場合は配列や関数を用意する必要がある
const STATUS_VALUES = [
  'draft',
  'published',
  'archived',
] as const;
type Status = (typeof STATUS_VALUES)[number];

function isValidStatus(value: string): value is Status {
  return STATUS_VALUES.includes(value as Status);
}

解決策:フレームワーク特性を活かした型設計パターン

各フレームワークの特性を理解し、適切な型設計パターンを採用することで、これらの課題を解決できます。

React/Next.js での型設計パターン

Props 設計での union 型活用

React では、props の型設計において union 型が威力を発揮します。

typescript// Buttonコンポーネントの例
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

interface ButtonProps {
  variant: ButtonVariant;
  size: ButtonSize;
  children: React.ReactNode;
}

// CSS-in-JSライブラリとの連携も簡単
const buttonStyles = {
  primary: 'bg-blue-500 text-white',
  secondary: 'bg-gray-500 text-white',
  danger: 'bg-red-500 text-white',
} as const;

function Button({ variant, size, children }: ButtonProps) {
  return (
    <button
      className={`${buttonStyles[variant]} ${sizeStyles[size]}`}
    >
      {children}
    </button>
  );
}

State 管理での型安全性

typescript// useReducerでの状態管理
type LoadingState =
  | 'idle'
  | 'loading'
  | 'success'
  | 'error';

interface State {
  status: LoadingState;
  data: any[];
  error: string | null;
}

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: any[] }
  | { type: 'FETCH_ERROR'; payload: string };

function dataReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, status: 'loading', error: null };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        status: 'success',
        data: action.payload,
      };
    case 'FETCH_ERROR':
      return {
        ...state,
        status: 'error',
        error: action.payload,
      };
    default:
      return state;
  }
}

Next.js API Routes での型共有

typescript// types/api.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export type ResponseStatus = 'success' | 'error';

export interface ApiResponse<T = any> {
  status: ResponseStatus;
  data?: T;
  message?: string;
}

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type {
  ApiResponse,
  HttpMethod,
} from '../../types/api';

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<ApiResponse>
) {
  const method = req.method as HttpMethod;

  switch (method) {
    case 'GET':
      // GET処理
      break;
    case 'POST':
      // POST処理
      break;
    default:
      res.status(405).json({
        status: 'error',
        message: 'Method not allowed',
      });
  }
}

Express.js での型設計パターン

ミドルウェアでの型活用

typescript// 権限レベルの定義
type Permission = 'read' | 'write' | 'admin';
type UserRole = 'user' | 'moderator' | 'admin';

// 権限マッピング
const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
  user: ['read'],
  moderator: ['read', 'write'],
  admin: ['read', 'write', 'admin'],
};

// ミドルウェア関数
function requirePermission(permission: Permission) {
  return (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    const userRole = req.user?.role as UserRole;

    if (
      !userRole ||
      !ROLE_PERMISSIONS[userRole].includes(permission)
    ) {
      return res.status(403).json({
        status: 'error',
        message: 'Insufficient permissions',
      });
    }

    next();
  };
}

// 使用例
app.get(
  '/admin',
  requirePermission('admin'),
  (req, res) => {
    // 管理者のみアクセス可能
  }
);

ルーティングでの型安全性

typescript// APIエンドポイントの型定義
type ApiEndpoint =
  | '/api/users'
  | '/api/posts'
  | '/api/auth/login'
  | '/api/auth/logout';

type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;

// レスポンス型の統一
interface StandardResponse<T = any> {
  status: 'success' | 'error';
  statusCode: HttpStatus;
  data?: T;
  message?: string;
  timestamp: string;
}

// ヘルパー関数
function createResponse<T>(
  status: 'success' | 'error',
  statusCode: HttpStatus,
  data?: T,
  message?: string
): StandardResponse<T> {
  return {
    status,
    statusCode,
    data,
    message,
    timestamp: new Date().toISOString(),
  };
}

データベース ORM での型設計パターン

Prisma での型活用

typescript// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  status    UserStatus
  role      UserRole
  createdAt DateTime @default(now())
}

enum UserStatus {
  ACTIVE
  INACTIVE
  SUSPENDED
}

enum UserRole {
  USER
  ADMIN
  MODERATOR
}

Prisma では enum を使用しますが、生成される型は union 型として利用できます。

typescript// 生成される型
type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
type UserRole = 'USER' | 'ADMIN' | 'MODERATOR';

// アプリケーション層での活用
const USER_STATUS_LABELS: Record<UserStatus, string> = {
  ACTIVE: 'アクティブ',
  INACTIVE: '非アクティブ',
  SUSPENDED: '停止中',
};

function getUserStatusLabel(status: UserStatus): string {
  return USER_STATUS_LABELS[status];
}

// クエリでの型安全性
async function getUsersByStatus(status: UserStatus) {
  return await prisma.user.findMany({
    where: { status },
    select: {
      id: true,
      email: true,
      status: true,
      role: true,
    },
  });
}

TypeORM での型設計

typescript// TypeORMでのエンティティ定義
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

export type OrderStatus =
  | 'pending'
  | 'processing'
  | 'shipped'
  | 'delivered'
  | 'cancelled';
export type PaymentMethod =
  | 'credit_card'
  | 'paypal'
  | 'bank_transfer';

@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: 'varchar',
    enum: [
      'pending',
      'processing',
      'shipped',
      'delivered',
      'cancelled',
    ],
  })
  status: OrderStatus;

  @Column({
    type: 'varchar',
    enum: ['credit_card', 'paypal', 'bank_transfer'],
  })
  paymentMethod: PaymentMethod;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  total: number;
}

// サービス層での活用
export class OrderService {
  async updateOrderStatus(
    orderId: number,
    status: OrderStatus
  ) {
    const validTransitions: Record<
      OrderStatus,
      OrderStatus[]
    > = {
      pending: ['processing', 'cancelled'],
      processing: ['shipped', 'cancelled'],
      shipped: ['delivered'],
      delivered: [],
      cancelled: [],
    };

    const order = await this.orderRepository.findOne(
      orderId
    );
    if (!order) {
      throw new Error('Order not found');
    }

    if (!validTransitions[order.status].includes(status)) {
      throw new Error(
        `Invalid status transition from ${order.status} to ${status}`
      );
    }

    order.status = status;
    return await this.orderRepository.save(order);
  }
}

具体例:React + Next.js、Prisma、Express での実装比較

実際のプロジェクト構成を想定して、enum と union 型を使用した場合の実装を比較してみましょう。

プロジェクト構成

csharpproject/
├── packages/
│   ├── shared/          # 共通型定義
│   ├── web/            # Next.js フロントエンド
│   ├── api/            # Express API
│   └── database/       # Prisma設定
├── package.json
└── yarn.lock

共通型定義の比較

Enum 版

typescript// packages/shared/src/enums.ts
export enum UserRole {
  USER = 'user',
  ADMIN = 'admin',
  MODERATOR = 'moderator',
}

export enum PostStatus {
  DRAFT = 'draft',
  PUBLISHED = 'published',
  ARCHIVED = 'archived',
}

export enum NotificationType {
  INFO = 'info',
  WARNING = 'warning',
  ERROR = 'error',
  SUCCESS = 'success',
}

Union 型版

typescript// packages/shared/src/types.ts
export type UserRole = 'user' | 'admin' | 'moderator';
export type PostStatus = 'draft' | 'published' | 'archived';
export type NotificationType =
  | 'info'
  | 'warning'
  | 'error'
  | 'success';

// 定数値の定義
export const USER_ROLES = [
  'user',
  'admin',
  'moderator',
] as const;
export const POST_STATUSES = [
  'draft',
  'published',
  'archived',
] as const;
export const NOTIFICATION_TYPES = [
  'info',
  'warning',
  'error',
  'success',
] as const;

// ヘルパー関数
export function isUserRole(
  value: string
): value is UserRole {
  return USER_ROLES.includes(value as UserRole);
}

export function isPostStatus(
  value: string
): value is PostStatus {
  return POST_STATUSES.includes(value as PostStatus);
}

Next.js フロントエンドでの実装

Enum 版の実装

typescript// packages/web/components/UserBadge.tsx
import { UserRole } from '@project/shared';

interface UserBadgeProps {
  role: UserRole;
}

export function UserBadge({ role }: UserBadgeProps) {
  // enumを使用する場合の判定
  const getBadgeColor = (role: UserRole) => {
    switch (role) {
      case UserRole.ADMIN:
        return 'bg-red-500';
      case UserRole.MODERATOR:
        return 'bg-blue-500';
      case UserRole.USER:
        return 'bg-gray-500';
      default:
        return 'bg-gray-500';
    }
  };

  return (
    <span
      className={`px-2 py-1 rounded text-white ${getBadgeColor(
        role
      )}`}
    >
      {role}
    </span>
  );
}

Union 型版の実装

typescript// packages/web/components/UserBadge.tsx
import type { UserRole } from '@project/shared';

interface UserBadgeProps {
  role: UserRole;
}

const ROLE_STYLES: Record<UserRole, string> = {
  admin: 'bg-red-500',
  moderator: 'bg-blue-500',
  user: 'bg-gray-500',
};

const ROLE_LABELS: Record<UserRole, string> = {
  admin: '管理者',
  moderator: 'モデレーター',
  user: '一般ユーザー',
};

export function UserBadge({ role }: UserBadgeProps) {
  return (
    <span
      className={`px-2 py-1 rounded text-white ${ROLE_STYLES[role]}`}
    >
      {ROLE_LABELS[role]}
    </span>
  );
}

Express API での実装

Enum 版の API 実装

typescript// packages/api/src/controllers/userController.ts
import { Request, Response } from 'express';
import { UserRole } from '@project/shared';

export async function updateUserRole(
  req: Request,
  res: Response
) {
  const { userId } = req.params;
  const { role } = req.body;

  // enumによるバリデーション
  if (!Object.values(UserRole).includes(role)) {
    return res.status(400).json({
      error: 'Invalid role',
      validRoles: Object.values(UserRole),
    });
  }

  try {
    const user = await userService.updateRole(
      userId,
      role as UserRole
    );
    res.json({ success: true, user });
  } catch (error) {
    res
      .status(500)
      .json({ error: 'Failed to update user role' });
  }
}

Union 型版の API 実装

typescript// packages/api/src/controllers/userController.ts
import { Request, Response } from 'express';
import type { UserRole } from '@project/shared';
import { isUserRole, USER_ROLES } from '@project/shared';

export async function updateUserRole(
  req: Request,
  res: Response
) {
  const { userId } = req.params;
  const { role } = req.body;

  // union型によるバリデーション
  if (!isUserRole(role)) {
    return res.status(400).json({
      error: 'Invalid role',
      validRoles: USER_ROLES,
    });
  }

  try {
    const user = await userService.updateRole(userId, role);
    res.json({ success: true, user });
  } catch (error) {
    res
      .status(500)
      .json({ error: 'Failed to update user role' });
  }
}

Prisma での実装

スキーマ定義

prisma// packages/database/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  role      UserRole @default(USER)
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int        @id @default(autoincrement())
  title     String
  content   String
  status    PostStatus @default(DRAFT)
  author    User       @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

enum UserRole {
  USER
  ADMIN
  MODERATOR
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

サービス層での活用

typescript// packages/api/src/services/postService.ts
import { PrismaClient } from '@prisma/client';
import type { PostStatus } from '@project/shared';

const prisma = new PrismaClient();

export class PostService {
  async getPostsByStatus(status: PostStatus) {
    // Prismaの生成された型とアプリケーション型の互換性
    return await prisma.post.findMany({
      where: {
        status: status.toUpperCase() as any, // 型変換が必要
      },
      include: {
        author: true,
      },
    });
  }

  async updatePostStatus(
    postId: number,
    status: PostStatus
  ) {
    const validTransitions: Record<
      PostStatus,
      PostStatus[]
    > = {
      draft: ['published', 'archived'],
      published: ['archived'],
      archived: ['draft'],
    };

    const post = await prisma.post.findUnique({
      where: { id: postId },
    });

    if (!post) {
      throw new Error('Post not found');
    }

    const currentStatus =
      post.status.toLowerCase() as PostStatus;

    if (!validTransitions[currentStatus].includes(status)) {
      throw new Error(
        `Cannot transition from ${currentStatus} to ${status}`
      );
    }

    return await prisma.post.update({
      where: { id: postId },
      data: { status: status.toUpperCase() as any },
    });
  }
}

パフォーマンスと開発体験の比較

バンドルサイズの比較

bash# Enum版のビルド結果例
yarn build:enum
# Bundle size: 145.2 KB

# Union型版のビルド結果例
yarn build:union
# Bundle size: 142.8 KB (-2.4 KB)

型推論の比較

typescript// Enum版:型推論が制限される場合がある
const userRoles = Object.values(UserRole); // (UserRole)[]
const firstRole = userRoles[0]; // UserRole(具体的な値が推論されない)

// Union型版:より詳細な型推論
const userRoles = USER_ROLES; // readonly ["user", "admin", "moderator"]
const firstRole = userRoles[0]; // "user"(具体的な値が推論される)

IDE サポートの比較

#機能EnumUnion 型優位性
1自動補完同等
2リファクタリングEnum
3Go to DefinitionEnum
4型推論精度Union 型
5エラー表示同等

まとめ:エコシステム全体を考慮した型選択戦略

実際のプロジェクトでの経験を踏まえ、エコシステム全体を考慮した型選択の指針をまとめます。

Union 型を選ぶべきケース

ライブラリとの連携が重要な場合

typescript// React Hook FormやZodなどのライブラリと連携する場合
import { z } from 'zod';

const userSchema = z.object({
  role: z.enum(['user', 'admin', 'moderator']), // zodのenumはunion型ベース
  status: z.literal('active').or(z.literal('inactive')),
});

type User = z.infer<typeof userSchema>;
// { role: 'user' | 'admin' | 'moderator', status: 'active' | 'inactive' }

パフォーマンスが重要な場合

typescript// バンドルサイズを最小化したい場合
type Theme = 'light' | 'dark';
type Language = 'ja' | 'en' | 'ko';

// ランタイムコードが生成されない
const config = {
  theme: 'light' as Theme,
  language: 'ja' as Language,
};

型レベルでの計算が必要な場合

typescript// Template Literal Typesとの組み合わせ
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = 'users' | 'posts' | 'comments';

type ApiUrl<
  M extends HttpMethod,
  E extends ApiEndpoint
> = `${M} /api/${E}`;

type UserApiUrls = ApiUrl<'GET' | 'POST', 'users'>;
// "GET /api/users" | "POST /api/users"

Enum を選ぶべきケース

チーム開発での一貫性が重要な場合

typescript// 大規模チームでの開発において、明確な定数管理が必要な場合
export enum ErrorCode {
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
  AUTHORIZATION_ERROR = 'AUTHORIZATION_ERROR',
  NOT_FOUND_ERROR = 'NOT_FOUND_ERROR',
  INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}

// IDEでの参照しやすさとリファクタリング安全性
function handleError(code: ErrorCode) {
  switch (code) {
    case ErrorCode.VALIDATION_ERROR:
      // 処理...
      break;
    // ...
  }
}

データベーススキーマとの一致が重要な場合

typescript// Prismaのenumと完全に一致させたい場合
enum OrderStatus {
  PENDING = 'PENDING',
  PROCESSING = 'PROCESSING',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}

ハイブリッドアプローチ

実際のプロジェクトでは、用途に応じて使い分けることが最も効果的です。

typescript// packages/shared/src/constants.ts
// パブリックAPIで使用する値はunion型
export type UserRole = 'user' | 'admin' | 'moderator';
export type PostStatus = 'draft' | 'published' | 'archived';

// 内部的な定数管理はenumまたはconst assertion
export const USER_ROLE = {
  USER: 'user',
  ADMIN: 'admin',
  MODERATOR: 'moderator',
} as const;

export const POST_STATUS = {
  DRAFT: 'draft',
  PUBLISHED: 'published',
  ARCHIVED: 'archived',
} as const;

// 型安全なヘルパー関数
export function isValidUserRole(
  value: string
): value is UserRole {
  return Object.values(USER_ROLE).includes(
    value as UserRole
  );
}

現代の TypeScript 開発においては、エコシステムとの親和性、パフォーマンス、そして開発体験のバランスを考慮すると、union 型を基本とし、必要に応じて enum や const assertion を組み合わせるアプローチが最も実践的と言えるでしょう。

特に、React、Next.js、Prisma などのモダンなライブラリを使用する場合は、union 型の恩恵を最大限に活用できます。プロジェクトの特性とチームの方針を考慮しながら、適切な選択を行ってください。

関連リンク