TypeScript Union型の実務的な使い方|API応答処理から型安全な実装まで
TypeScriptのUnion型は、複数の型を組み合わせて表現する強力な機能です。しかし、教科書的な説明だけでは、実務でどう活用すればいいかが見えづらいという方も多いのではないでしょうか。本記事では、実際のプロジェクトで役立つUnion型の使い方を、具体的なコード例を交えて解説します。
Union型の簡易的な解説
Union型は、複数の型のいずれかを受け入れることができる型です。パイプ記号(|)で複数の型を繋ぎます。
// 基本的なUnion型の例
type Status = 'pending' | 'success' | 'error';
type Result = string | number;
const status: Status = 'pending'; // OK
const result: Result = 42; // OK
シンプルに見えるかもしれませんが、Union型の真価は「複雑なビジネスロジックを型安全に表現できる」という点にあります。
業務でのユースケース
1. API応答の複数パターン処理
実務では、APIが成功・失敗を含むさまざまなパターンで応答を返します。Union型を使うことで、すべてのパターンに対応する型安全なコードが書けます。
2. ステート管理における状態遷移
フロントエンドアプリケーションでは、ローディング、成功、失敗など複数の状態を管理します。Union型でこれらを表現すれば、状態漏れを防げます。
3. フォーム入力値のバリデーション結果
入力値が有効な場合と無効な場合で異なる型を返す必要があります。Union型でこれを実装すると、バリデーション後の型チェックが自動化されます。
実装コード:実務レベルの具体例
例1:API応答処理
// APIの応答型をUnion型で定義
type ApiResponse =
| { status: 'success'; data: T; timestamp: number }
| { status: 'error'; code: string; message: string }
| { status: 'loading' };
// ユーザー情報のAPI応答
interface User {
id: number;
name: string;
email: string;
}
type UserResponse = ApiResponse;
// 実装例:APIからデータを取得
async function fetchUser(userId: number): Promise {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
return {
status: 'error',
code: `HTTP_${response.status}`,
message: `Failed to fetch user: ${response.statusText}`
};
}
const data = await response.json();
return {
status: 'success',
data,
timestamp: Date.now()
};
} catch (error) {
return {
status: 'error',
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
// レスポンスの処理:型チェックが強制される
function handleUserResponse(response: UserResponse): void {
if (response.status === 'success') {
console.log(`User: ${response.data.name} (${response.data.email})`);
console.log(`Fetched at: ${new Date(response.timestamp).toISOString()}`);
} else if (response.status === 'error') {
console.error(`Error [${response.code}]: ${response.message}`);
} else {
console.log('Loading...');
}
}
例2:フォームバリデーション
// バリデーション結果をUnion型で定義
type ValidationResult =
| { ok: true; value: T }
| { ok: false; errors: Record };
interface SignupFormData {
email: string;
password: string;
passwordConfirm: string;
username: string;
}
// バリデーション関数
function validateSignupForm(formData: SignupFormData): ValidationResult {
const errors: Record = {};
// メールアドレスのバリデーション
if (!formData.email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(formData.email)) {
errors.email = ['Valid email address is required'];
}
// パスワードのバリデーション
if (!formData.password || formData.password.length < 8) {
errors.password = ['Password must be at least 8 characters'];
}
// パスワード確認のバリデーション
if (formData.password !== formData.passwordConfirm) {
errors.passwordConfirm = ['Passwords do not match'];
}
// ユーザー名のバリデーション
if (!formData.username || formData.username.length < 3) {
errors.username = ['Username must be at least 3 characters'];
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
return { ok: true, value: formData };
}
// バリデーション結果の処理:Union型のおかげで型安全
function handleValidationResult(result: ValidationResult): void {
if (result.ok) {
// resultがok: trueの場合、valueが確実に存在する
console.log('Signup data is valid:', result.value);
// submitSignupData(result.value);
} else {
// resultがok: falseの場合、errorsが確実に存在する
Object.entries(result.errors).forEach(([field, fieldErrors]) => {
console.error(`${field}: ${fieldErrors.join(', ')}`);
});
}
}
例3:ステート管理(React/Reduxの実装例)
// 非同期処理の状態をUnion型で管理
type AsyncState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
interface Product {
id: number;
name: string;
price: number;
stock: number;
}
interface ApiError {
code: string;
message: string;
retryable: boolean;
}
type ProductListState = AsyncState;
// Reduxアクション型
type ProductAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: Product[] }
| { type: 'FETCH_ERROR'; payload: ApiError }
| { type: 'RESET' };
// Reduxレデューサー(型安全に実装)
function productReducer(
state: ProductListState = { status: 'idle' },
action: ProductAction
): ProductListState {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload };
case 'FETCH_ERROR':
return { status: 'error', error: action.payload };
case 'RESET':
return { status: 'idle' };
default:
return state;
}
}
// コンポーネントでの使用例(React)
function ProductList({ state }: { state: ProductListState }) {
// 型チェックが強制されるため、すべてのケースを処理する必要がある
switch (state.status) {
case 'idle':
return Click to load products;
case 'loading':
return Loading...;
case 'success':
// stateがsuccess型であることが確定しているため、dataへアクセス可能
return (
{state.data.map(product => (
-
{product.name} - ${product.price} ({product.stock} in stock)
))}
);
case 'error':
// stateがerror型であることが確定しているため、errorへアクセス可能
return (
Error: {state.error.message}
Code: {state.error.code}
{state.error.retryable && }
);
}
}
例4:支払い処理(複数の決済方法対応)
// 支払い方法ごとに異なるペイロード構造をUnion型で表現
type PaymentMethod =
| {
type: 'credit_card';
cardNumber: string;
expiryDate: string;
cvv: string;
holderName: string;
}
| {
type: 'bank_transfer';
bankCode: string;
accountNumber: string;
accountHolder: string;
}
| {
type: 'digital_wallet';
provider: 'apple_pay' | 'google_pay' | 'paypal';
token: string;
}
| {
type: 'convenience_store';
storeType: '7-eleven' | 'lawson' | 'familymart';
amount: number;
};
interface Payment {
orderId: string;
amount: number;
currency: 'JPY' | 'USD' | 'EUR';
method: PaymentMethod;
}
// 支払い処理関数:Union型により各決済方法に対応
async function processPayment(payment: Payment): Promise<{ success: boolean; transactionId: string }> {
// メソッドチェーンで型が絞られるため、各ブロック内で安全にアクセス可能
if (payment.method.type === 'credit_card') {
// ここでは payment.method は credit_card 型に確定
const masked = payment.method.cardNumber.slice(-4).padStart(16, '*');
console.log(`Processing credit card: ${masked}`);
// 実際の処理...
} else if (payment.method.type === 'bank_transfer') {
// ここでは payment.method は bank_transfer 型に確定
console.log(`Processing bank transfer to ${payment.method.accountHolder}`);
// 実際の処理...
} else if (payment.method.type === 'digital_wallet') {
// ここでは payment.method は digital_wallet 型に確定
console.log(`Processing ${payment.method.provider} payment`);
// 実際の処理...
} else if (payment.method.type === 'convenience_store') {
// ここでは payment.method は convenience_store 型に確定
console.log(`Issuing payment code for ${payment.method.storeType}`);
// 実際の処理...
}
return {
success: true,
transactionId: `TXN_${Date.now()}`
};
}
よくある応用パターン
パターン1:ディスクリミナント付きUnion型
Union型に「ディスクリミナント」(判別子)を含めることで、型ガード(型絞り込み)が容易になります。上記の例では、statusやtypeフィールドがディスクリミナントとして機能しており、if文やswitch文で型を自動的に絞り込めます。
パターン2:Mapped Types との組み合わせ
// 複数のエンティティに対して統一的な非同期状態を定義
type EntityStates = {
users: AsyncState;
products: AsyncState;
orders: AsyncState;
};
// Mapped Typesを使ってアクションを自動生成
type EntityActions = {
[K in keyof EntityStates]:
| { type: `FETCH_${string & K}_START` }
| { type: `FETCH_${string & K}_SUCCESS`; payload: EntityStates[K] extends AsyncState ? T : never }
| { type: `FETCH_${string & K}_ERROR`; error: any };
}[keyof EntityStates];
パターン3:型安全なイベントハンドラー
// UIイベントをUnion型で定義
type UIEvent =
| { type: 'button_click'; buttonId: string; timestamp: number }
| { type: 'form_submit'; formId: string; formData: Record }
| { type: 'input_change'; fieldName: string; value: string }
| { type: 'navigation'; path: string; params: Record };
function handleUIEvent(event: UIEvent): void {
switch (event.type) {
case 'button_click':
console.log(`Button ${event.buttonId} clicked at ${event.timestamp}`);
break;
case 'form_submit':
console.log(`Form ${event.formId} submitted with:`, event.formData);
break;
case 'input_change':
console.log(`Field ${event.fieldName} changed to ${event.value}`);
break;
case 'navigation':
console.log(`Navigating to ${event.path}`, event.params);
break;
}
}
注意点とベストプラクティス
注意点1:Union型が大きくなりすぎないこと
Union型にあまりにも多くのケースを詰め込むと、コードの可読性が低下します。関連するケースはグループ化し、必要に応じてネストされたUnion型を使いましょう。
// 避けるべき:すべてを1つのUnion型に
type BadExample =
| { type: 'case1'; ... }
| { type: 'case2'; ... }
| { type: 'case3'; ... }
// ... 20個以上のケース
// 改善:関連するケースをグループ化
type SuccessResult =
| { type: 'success'; data: any }
| { type: 'partial_success'; data: any; warnings: string[] };
type ErrorResult =
| { type: 'error'; code: string; message: string }
| { type: 'validation_error'; errors: Record };
type Result = SuccessResult | ErrorResult;
注意点2:型ガードの不完全性
すべてのケースをカバーしないと、TypeScriptはコンパイル時に警告を出します。switch文を使う場合は、必ず default ケースを含めるか、exhaustiveCheck関数を使用しましょう。
// exhaustiveCheck関数:すべてのケースをカバーしたことを保証
function exhaustiveCheck(value: never): never {
throw new Error(`Unhandled value: ${value}`);
}
function handleResult(result: Result): void {
if (result.type === 'success') {
console.log(result.data);
} else if (result.type === 'error') {
console.error(result.message);
} else {
exhaustiveCheck(result); // 型漏れがあるとコンパイルエラー
}
}
注意点3:Union型と any の混在を避ける
Union型の型安全性は、型チェックが厳密であることに依存しています。むやみに any を使うと、その利点が失われます。
// 避けるべき
type BadResponse =
| { status: 'success'; data: any } // any を使わない
| { status: 'error'; error: any };
// 改善
type GoodResponse =
| { status: 'success'; data: T }
| { status: 'error'; error: Error | string };
注意点4:null と undefined の扱い
Union型に null や undefined を含める場合は、明示的に型定義します。Optional Properties(?)と区別して使い分けることが重要です。
// 異なる意味
type Example1 = { value: string | null }; // 常にvalueプロパティが存在するが、値がnullの可能性あり
type Example2 = { value?: string }; // valueプロパティ自体が存在しない可能性あり
// 実務では、nullableな値か、プロパティ自体が存在しないかを明確に区別する
type UserResponse =
| { status: 'success'; user: User; lastLogin: Date | null } // nullableな値
| { status: 'not_found' }; // プロパティ自体が存在しないケース
実務でのパフォーマンス考慮
Union型は実行時には型情報が削除されるため、パフォーマンスオーバーヘッドはありません。ただし、Union型が複雑になると、TypeScriptのコンパイル時間が増加する可能性があります。大規模なプロジェクトでは、型定義を適切に分割することをお勧めします。
まとめ
TypeScriptのUnion型は、複数の状態やパターンを型安全に表現する強力なツールです。実務では、API応答処理、フォームバリデーション、ステート管理、複数の決済方法対応など、様々な場面で活躍します。
重要なポイントは:
- ディスクリミナント付きUnion型を使って、型絞り込みを自動化する
- Union型の複雑さを管理し、可読性を保つ
- すべてのケースをカバーすることを強制する仕組みを用意する
- null/undefined の扱いを明確にする
これらを意識することで、バグが少なく、保守性の高いTypeScriptコードを書くことができます。実務プロジェクトにぜひ取り入れてみてください。

