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 | 条件型サポート | 複雑な型変換ロジックの内包 | 型レベルでの計算が可能 |
4 | Template 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 サポートの比較
# | 機能 | Enum | Union 型 | 優位性 |
---|---|---|---|---|
1 | 自動補完 | ○ | ○ | 同等 |
2 | リファクタリング | ○ | △ | Enum |
3 | Go to Definition | ○ | △ | Enum |
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 型の恩恵を最大限に活用できます。プロジェクトの特性とチームの方針を考慮しながら、適切な選択を行ってください。
関連リンク
- review
もう「なんとなく」で決めない!『解像度を上げる』馬田隆明著で身につけた、曖昧思考を一瞬で明晰にする技術
- review
もう疲れ知らず!『最高の体調』鈴木祐著で手に入れた、一生モノの健康習慣術
- review
人生が激変!『苦しかったときの話をしようか』森岡毅著で発見した、本当に幸せなキャリアの築き方
- review
もう「何言ってるの?」とは言わせない!『バナナの魅力を 100 文字で伝えてください』柿内尚文著 で今日からあなたも伝え方の達人!
- review
もう時間に追われない!『エッセンシャル思考』グレッグ・マキューンで本当に重要なことを見抜く!
- review
プロダクト開発の悩みを一刀両断!『プロダクトマネジメントのすべて』及川 卓也, 曽根原 春樹, 小城 久美子