Discriminated Unionsで仕留める、React Propsの型安全な要塞
こんにちは。日々、コンポーネントの肥大化とTypeScriptの型パズルに頭を悩ませているフロントエンド・ギークの皆さん。
アプリケーションが成長するにつれ、Propsのインターフェースが「なんでも受け付けるゴミ箱」のようになっていないだろうか?
「このフラグがtrueの時は、あっちのPropsが必須だけど、こっちがfalseならundefinedでもいいや…」
そんな曖昧な設計を放置した結果、ランタイムエラーの爆弾を抱えたままプロダクションへデプロイするハメになる。TypeScriptを使っているのに、コンパイルエラーを恐れて`any`やオプショナル(`?`)だらけのPropsを量産しているとしたら、それは道具に踊らされている証拠だ。
今回は、TypeScriptの真骨頂である Discriminated Unions(判別可能ユニオン型) を武器に、矛盾したPropsの組み合わせをコンパイル時に完全に駆逐し、IDEの補完すらハックするような堅牢なコンポーネント設計の極意を授けよう。
—
なぜオプショナル(`?`)の乱用は悪なのか?
実務でよく見かけるアンチパターンから話を始めよう。例えば、ローディング状態、成功状態、エラー状態を切り替える汎用的なカードコンポーネントを作るとする。
// ❌ 良くあるアンチパターン:すべてのPropsがオプショナルまたは混在している
type BadCardProps = {
status: ‘loading’ | ‘success’ | ‘error’;
data?: UserData; // successの時は必須のはず…
error?: Error; // errorの時は必須のはず…
onRetry?: () => void; // errorの時だけ使いたい…
};
このインターフェースの何が地獄かと言うと、「`status`が`success`なのに、`data`が渡されていない」という矛盾した状態を、TypeScriptの型システムがスルーしてしまう点だ。結果として、コンポーネントの内部で `if (status === ‘success’ && !data)` のような冗長なランタイムガード(防衛的コード)を書くことになる。
ブラウザエンジンは、不要な分岐や予測不可能なundefinedのチェックで無駄なサイクルを回し、メモリ上でも汚染されたオブジェクトを処理し続ける。何より、開発体験(DX)が最悪だ。
—
救世主:Discriminated Unionsによる型レベルの排他制御
ここで登場するのが Discriminated Unions だ。
共通の識別子(Discriminant、ここでは `status`)を持たせた複数の型をユニオン(`|`)で結ぶことで、TypeScriptのコンパイラ(TSServer)に「この状態の時は、このPropsしか存在しない」と強制的に理解させる。
早速、洗練されたアーキテクチャのコードを見てみよう。
import React from ‘react’;
type UserData = {
id: string;
name: string;
};
// 1. 各状態ごとのPropsを厳密に定義する
type LoadingCardProps = {
status: ‘loading’;
// 余計なプロパティは一切持たせない
};
type SuccessCardProps = {
status: ‘success’;
data: UserData; // successの時は絶対にデータが必要
onSelect?: (id: string) => void;
};
type ErrorCardProps = {
status: ‘error’;
error: Error; // errorの時はエラーオブジェクトが必須
onRetry: () => void; // 再試行関数も必須化できる
};
// 2. それらをユニオン型で結合する
type SmartCardProps = LoadingCardProps | SuccessCardProps | ErrorCardProps;
export const SmartCard: React.FC
// 3. switch文やif文で `status` を絞り込む(Narrowing)
switch (props.status) {
case ‘loading’:
// ここでは props は LoadingCardProps に絞り込まれる
return
;
case ‘success’:
// ここでは props は SuccessCardProps になり、dataに安全にアクセスできる
return (
{props.data.name}
{props.onSelect && (
)}
);
case ‘error’:
// ここでは props は ErrorCardProps になり、errorとonRetryが保証される
return (
エラーが発生しました: {props.error.message}
);
default:
// 4. 網羅性チェック(Exhaustiveness Checking)
// 将来ステータスが追加された際、ここに型エラーを出して実装漏れを防ぐ
const exhaustiveCheck: never = props;
return exhaustiveCheck;
}
};
このアプローチの美しさは、「不可能な状態を型で表現不可能にする(Make impossible states impossible)」という関数型プログラミングの哲学を、Reactのコンポーネントツリーに直結させられる点にある。
—
パフォーマンスとメモリ効率への波及効果
「型が厳密になるのは分かったけど、パフォーマンスにはどう影響するの?」というギークな疑問を持つ読者に向けて、もう少し踏み込んでみよう。
1. ランタイムの防衛的コードの削減
前述の通り、曖昧な型定義ではコンポーネントのレンダリング関数内で次のようなコードが散見される。
if (!data && status === ‘success’) return null;
このような無駄なガード条件は、V8エンジンなどのJavaScriptエンジンにおいてJITコンパイル時の最適化(インライン化や隠しクラスの安定化)を阻害する要因になり得る。型レベルで矛盾が排除されていれば、コンポーネント内の分岐予測は極めてシンプルになり、V8の最適化パイプラインに優しいコードになる。
2. 再レンダリング(Re-rendering)の局所化
Discriminated Unionsを使ってPropsを綺麗に分離すると、コンポーネントが「何に依存しているか」が明確になる。
例えば、`SuccessCardProps`に不要な`error`や`onRetry`が流れこまないため、親から渡されるオブジェクトの参照が変わったとしても、関係ない状態変更による不要な再レンダリングの検知コストを最小化できる。React.memoと組み合わせた際、比較関数(arePropsEqual)のロジックも劇的にシンプルになるのだ。
—
さらに実践的:ジェネリクスと組み合わせた高度な排他制御
実務では、さらに一歩進んだパターンに遭遇する。例えば、「単一のアイテムを選択するモード」と「複数のアイテムをトグルで選択するモード」を持つリストコンポーネントを考えてみよう。
// 単一選択モード
type SingleSelectProps
selectionMode: ‘single’;
selectedId: T | null;
onSelect: (id: T | null) => void;
};
// 複数選択モード
type MultiSelectProps
selectionMode: ‘multiple’;
selectedIds: T[];
onSelect: (ids: T[]) => void;
};
// 共通のProps
type BaseListProps
items: T[];
renderItem: (item: T) => React.ReactNode;
};
// ジェネリクスとDiscriminated Unionsの融合
type AdvancedListProps
export function AdvancedList
// 実装側では selectionMode によって完全に型が分岐する
const isSelected = (item: T) => {
if (props.selectionMode === ‘single’) {
return props.selectedId === item.id;
} else {
return props.selectedIds.includes(item.id);
}
};
return (
-
{props.items.map((item) => (
- {
if (props.selectionMode === ‘single’) {
props.onSelect(props.selectedId === item.id ? null : item.id);
} else {
const exists = props.selectedIds.includes(item.id);
const newIds = exists
? props.selectedIds.filter((i) => i !== item.id)
: […props.selectedIds, item.id];
props.onSelect(newIds);
}
}}>
{props.renderItem(item)} {isSelected(item) && ‘✅’}
))}
);
}
このコンポーネントを使う側は、`selectionMode=”single”` を選んだ瞬間に `selectedIds` や `onSelect` の引数の型が配列ではなく単一の値であることをIDEが強制する。開発者は誤ったプロパティ名を渡すことが物理的に不可能になるのだ。
—
結びにかえて:型安全は最高のドキュメントである
私たちは日々、コードを書く時間よりも、コードを読む時間、そして「これどういう仕様だっけ?」と悩む時間に多くのリソースを割いている。
Discriminated UnionsによるPropsの分岐は、単なるTypeScriptの機能を使ったテクニックではない。それは、「このコンポーネントは、こういう状態の時にしか存在してはいけない」というビジネスロジックの制約を、コードベースに刻み込むための最強のアーキテクチャ手法だ。
オプショナルなPropsの海に溺れるのはもうやめよう。
明日の朝、君のチームのプルリクエストから `?` マークの乱用を消し去り、型安全という名の鉄壁の要塞を築き上げてほしい。

コメント