【実務・中級編】関数引数における「Discriminated Unions」を用いた型安全なオプションオブジェクトの設計 – TypeScript コア・型システムの基礎解析バイブル

関数引数における「Discriminated Unions」を用いた型安全なオプションオブジェクトの設計

Webエンジニア諸君、日々の開発お疲れ様です。フロントエンド、バックエンド、そしてその間のAPI連携。あらゆるレイヤーでTypeScriptの恩恵を享受していることと思います。しかし、関数設計において、単純なオプショナル引数の乱用によって、型安全性を損なったり、コードの可読性を著しく低下させてしまったりするケースを散見します。

本稿では、単なるオプショナル引数では実現できない、「特定の引数の組み合わせのみを許可する」という、より堅牢なAPI設計を実現する強力なパターン、「Discriminated Unions(識別子付きユニオン型)」を用いたオプションオブジェクトの設計に焦点を当てます。これにより、バグの温床となりがちな曖昧な引数渡しを排除し、プロダクションレベルで通用する、美しく保守性の高いコードを構築する方法を伝授します。

なぜ、単純なオプショナル引数では不十分なのか?

まずは、なぜ単純なオプショナル引数では限界があるのかを、具体的なコード例で確認しましょう。

例えば、ユーザー情報を取得する非同期関数 `fetchUser` を考えてみます。IDで取得する場合と、メールアドレスで取得する場合があるとしましょう。

// 従来型のオプショナル引数による設計
interface FetchUserOptions {
id?: number;
email?: string;
}

async function fetchUser(options: FetchUserOptions): Promise {
if (options.id !== undefined) {
// IDによるユーザー検索ロジック
console.log(`Fetching user by ID: ${options.id}`);
// … 実際にはAPI呼び出しなど
return { id: options.id, name: “Alice” }; // 仮のユーザーオブジェクト
} else if (options.email !== undefined) {
// Emailによるユーザー検索ロジック
console.log(`Fetching user by Email: ${options.email}`);
// … 実際にはAPI呼び出しなど
return { id: 1, name: “Bob” }; // 仮のユーザーオブジェクト
} else {
// 引数が指定されていない場合
console.warn(“No identifier provided for fetching user.”);
return null;
}
}

// 呼び出し例
fetchUser({ id: 123 });
fetchUser({ email: “test@example.com” });
// fetchUser({}); // これは許容されてしまう
// fetchUser({ id: 123, email: “test@example.com” }); // これも許容されてしまうが、どちらの条件で検索されるべきか曖昧

このコードの問題点は明らかです。

  • 曖昧な引数渡し: `fetchUser({})` のように、引数を一切渡さない場合でもコンパイルエラーになりません。実行時に `null` が返るだけで、意図しない挙動の原因となり得ます。
  • 排他条件の欠如: `{ id: 123, email: “test@example.com” }` のように、`id` と `email` の両方を渡してもコンパイルエラーになりません。しかし、関数内部ではどちらか一方の条件で処理されるため、開発者は「もし両方渡されたらどうなるんだ?」という不安を抱えることになります。これは、コードの意図が不明確になり、バグを生み出す温床です。
  • 可読性の低下: 関数シグネチャだけを見たときに、どのような引数の組み合わせが有効なのか、一目で判断するのが困難です。

Discriminated Unions による堅牢な設計

ここで、Discriminated Unions の出番です。Discriminated Unions は、ユニオン型に「タグ」となるリテラル型のプロパティを追加することで、そのプロパティの値によってユニオンのメンバーを識別可能にするパターンです。

先ほどの `fetchUser` 関数を、Discriminated Unions を用いて再設計してみましょう。

// User 型の定義 (例)
interface User {
id: number;
name: string;
}

// 検索方法を識別するためのタグ型
type UserId = {
type: ‘id’; // タグとなるリテラル型プロパティ
id: number;
};

type UserEmail = {
type: ‘email’; // 別のタグ
email: string;
};

// 検索オプションのユニオン型
// どちらか一方の条件を持つオブジェクトのみが許容される
type FetchUserOptions = UserId | UserEmail;

async function fetchUser(options: FetchUserOptions): Promise {
// switch 文で options.type を判定することで、TypeScript は options の型を絞り込める
switch (options.type) {
case ‘id’:
// このブロック内では、options は UserId 型として扱われる
console.log(`Fetching user by ID: ${options.id}`);
// … 実際には API 呼び出しなど
return { id: options.id, name: “Alice” }; // 仮のユーザーオブジェクト
case ‘email’:
// このブロック内では、options は UserEmail 型として扱われる
console.log(`Fetching user by Email: ${options.email}`);
// … 実際には API 呼び出しなど
return { id: 1, name: “Bob” }; // 仮のユーザーオブジェクト
// default ケースは不要。TypeScript が網羅性をチェックしてくれる
}
}

// 呼び出し例
fetchUser({ type: ‘id’, id: 123 });
fetchUser({ type: ‘email’, email: “test@example.com” });

// 以下はコンパイルエラーになる
// fetchUser({}); // Error: Argument of type ‘{}’ is not assignable to parameter of type ‘FetchUserOptions’.
// fetchUser({ id: 123 }); // Error: Argument of type ‘{ id: number; }’ is not assignable to parameter of type ‘FetchUserOptions’.
// // Type ‘{ id: number; }’ is not assignable to type ‘UserEmail’.
// // Types of property ‘type’ are incompatible.
// // Type ‘undefined’ is not assignable to type ‘”email”‘.
// fetchUser({ type: ‘id’, email: “test@example.com” }); // Error: Object literal may only specify known properties, and ‘email’ does not exist in type ‘UserId’.

この設計の素晴らしさは、以下の点に集約されます。

1. コンパイル時の厳格なチェック: `FetchUserOptions` 型は `UserId` または `UserEmail` のいずれかの構造を持つオブジェクトのみを許容します。そのため、`fetchUser({})` や `fetchUser({ id: 123 })` のような、不完全または無効な引数の組み合わせは、コンパイル時に即座に検出されます。これにより、実行時エラーに繋がるバグの芽を開発段階で摘むことができます。
2. コードの意図の明確化: `type` プロパティ(タグ)の存在により、関数がどのような条件で動作するのかが、コードを読んだだけで一目瞭然です。`{ type: ‘id’, id: 123 }` という引数は、「IDでユーザーを検索する」という明確な意図を表しています。
3. 実行時の型安全な分岐: `switch (options.type)` のような条件分岐を用いることで、TypeScript は `case` ブロックごとに `options` の型を正確に絞り込むことができます。これにより、`case ‘id’` の中では `options.id` に安全にアクセスでき、`case ‘email’` の中では `options.email` に安全にアクセスできます。
4. 網羅性の保証: `switch` 文の `default` ケースを省略することで、TypeScript はユニオンの全てのメンバーが網羅されているかを確認します。もし将来的に `FetchUserOptions` に新しいメンバー(例: `type: ‘username’`)が追加された場合、`switch` 文に `case` が追加されていないとコンパイルエラーとなり、コードの保守漏れを防ぐことができます。

実務での応用例:ReactコンポーネントのProps設計

このパターンは、ReactコンポーネントのProps設計においても非常に強力です。例えば、異なる状態や振る舞いを持つボタンコンポーネントを設計する場合を考えてみましょう。

// ButtonコンポーネントのProps定義

// 通常のボタン
type NormalButton = {
type: ‘normal’;
label: string;
onClick: () => void;
};

// リンクボタン(hrefを持つ)
type LinkButton = {
type: ‘link’;
label: string;
href: string;
};

// フォーム送信ボタン(submitボタン)
type SubmitButton = {
type: ‘submit’;
formId: string; // どのフォームを送信するか指定
};

// ボタンのPropsのユニオン型
type ButtonProps = NormalButton | LinkButton | SubmitButton;

// Buttonコンポーネントの実装
const Button = (props: ButtonProps) => {
switch (props.type) {
case ‘normal’:
return (

);
case ‘link’:
return (

{props.label}

);
case ‘submit’:
return (

);
// default:
// // TypeScript が網羅性をチェックしてくれるため、通常は不要
// const _exhaustiveCheck: never = props;
// console.error(“Unknown button type:”, _exhaustiveCheck);
// return null;
}
};

// — 使用例 —

// 通常ボタン

タイトルとURLをコピーしました