こんにちは。テクニカルリードの私だ。
今日のコードレビューで、また「文字列の組み合わせだけで状態を管理しようとした、甘えの残るコンポーネント」を見かけた。
// ❌ 最悪なアンチパターン:不正な状態の存在を許す
type Status = ‘IDLE’ | ‘LOADING’ | ‘SUCCESS’ | ‘ERROR’;
interface State {
status: Status;
data: User | null;
error: Error | null;
// LOADINGなのにdataが存在する、という「存在してはならない矛盾した状態」をTypeScriptがすり抜けてしまう
}
フロントエンド開発、特に複雑な非同期API連携やUIの状態遷移において、「不正な状態を型レベルで表現不可能にする(Make impossible states unrepresentable)」ことは、シニアエンジニアにとって基本中の基本だ。
今回は、TypeScriptの `type` エイリアスとUnion Types(直和型)を極限まで駆使し、バグの入り込む隙を完全に断つ「代数的データ型(Algebraic Data Types: ADT)」によるモデリング手法を伝授する。
—
なぜ「インターフェースの拡張」ではなく「代数構造(直和型)」なのか?
オブジェクト指向的な発想のまま `interface` と `extends` で状態を管理しようとすると、各状態に特有のプロpayload(ペイロード)の排他制御が崩壊する。
代数的データ型、特に直和型(Tagged Union / Discriminated Union)を用いると、データ構造を数学的な「排他的論理和」として表現できる。TypeScriptのコンパイラは、判別可能なプロパティ(Discriminant)を見ることで、制御フロー解析(Control Flow Analysis)を働かせ、型を自動的に絞り込む(Narrowing)。
このメカニズムを理解していれば、ランタイムエラーの9割はコンパイル時に駆逐できる。
—
プロダクションコード:型安全な非同期状態・エラーハンドリングの極致
それでは、実務のフロントエンド(APIフェッチ、状態管理、UI描画)でそのまま使える、妥協のないプロダクションコードを提示する。
/
- =================================================================
- 1. 代数的データ型(ADT)による汎用リモートデータ型の定義
- =================================================================
- FP(関数型プログラミング)における RemoteData / Either パターンを
- TypeScriptのDiscriminated Unionで完璧に再現する。
/
export type RemoteData
| { readonly _tag: ‘Idle’ }
| { readonly _tag: ‘Loading’; readonly progress?: number }
| { readonly _tag: ‘Success’; readonly data: A }
| { readonly _tag: ‘Failure’; readonly error: E };
// コンストラクタ関数群(ボイラープレートを隠蔽し、開発者のミスを防ぐ)
export const RemoteData = {
idle:
loading:
success:
failure:
} as const;
/
- =================================================================
- 2. ドメイン特化の型定義(ユーザープロフィール取得の例)
- =================================================================
/
export interface UserProfile {
readonly id: string;
readonly name: string;
readonly email: string;
}
export interface ApiError {
readonly code: ‘NETWORK_ERROR’ | ‘UNAUTHORIZED’ | ‘SERVER_ERROR’;
readonly message: string;
}
// 最終的な状態型
export type UserState = RemoteData
/
- =================================================================
- 3. 網羅性チェック(Exhaustiveness Checking)を伴う安全なマッパ
- =================================================================
/
export function matchRemoteData
fa: RemoteData
matcher: {
readonly onIdle: () => B;
readonly onLoad: (progress?: number) => B;
readonly onSuccess: (data: A) => B;
readonly onFailure: (error: E) => B;
}
): B {
switch (fa._tag) {
case ‘Idle’:
return matcher.onIdle();
case ‘Loading’:
return matcher.onLoad(fa.progress);
case ‘Success’:
return matcher.onSuccess(fa.data);
case ‘Failure’:
return matcher.onFailure(fa.error);
default:
// 【極めて重要】将来のUnion拡張漏れをコンパイルエラーにする魔法
const _exhaustiveCheck: never = fa;
throw new Error(`Unhandled state: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
—
この設計が優れている理由(コードレビューの視点)
1. `readonly` によるイミュータビリティの強制
状態オブジェクトとそのプロパティにはすべて `readonly` を付与している。これにより、アプリケーションのどこかでうっかり `state.data = …` のような副作用を起こすコードを書いた瞬間、TypeScriptがコンパイルエラーで即座に叩き落とす。
2. 不正な状態の物理的排除
`Success` ステートなのに `error` プロパティが存在する、といった矛盾は型レベルで存在し得ない。`_tag` プロパティが `Success` のとき、TypeScriptの型システムは自動的に `data` の存在を保証し、`error` へのアクセスを禁じる。
3. `never` 型による網羅性チェック(Exhaustiveness Checking)
`matchRemoteData` 関数の `default` 句にある `const _exhaustiveCheck: never = fa;` はシニアの必須テクニックだ。
もし将来、`RemoteData` に新しく `{ readonly _tag: ‘Refreshing’ }` などの状態を追加し、かつ `matcher` のハンドリングを書き忘れた場合、TypeScriptはコンパイル時にこう叫ぶ。
> Type ‘…’ is not assignable to type ‘never’.
これにより、「新しい状態を追加したのに、UI側のハンドリングを忘れて本番で白画面になる」という致命的なヒューマンエラーをコンパイラが永久に防いでくれる。
—
実務:Reactコンポーネントでの実践
このADTを実際のUIコンポーネントでどう使うか。余計な `if/else` や三項演算子のネスト地獄から解放される様子を見てほしい。
import React from ‘react’;
import { UserState, matchRemoteData } from ‘./userState’;
interface UserProfileCardProps {
readonly userState: UserState;
readonly onRetry: () => void;
}
export const UserProfileCard: React.FC
return (
onIdle: () =>
ボタンを押してユーザー情報を取得してください。
,
onLoad: (progress) => (
),
onSuccess: (user) => (
{user.name}
{user.email}
),
onFailure: (error) => (
エラーが発生しました: {error.message}
),
})}
);
};
—
パフォーマンス上の注意点(V8エンジンとオブジェクト形状)
型エイリアスとUnionを用いた設計において、V8エンジン(Node.js / Chrome)の動作特性を一つだけ意識しておかなければならない。
JavaScriptのエンジンは、同じ「形状(Hidden Class / Shape)」を持つオブジェクトを大量に生成する場合に最適化(Inline Caching)を行う。
今回の `RemoteData` のように、`_tag` という共通の判別子を持ちつつ異なる形状を持つオブジェクトを頻繁に生成・破棄する場合、過剰なガベージコレクション(GC)のプレッシャーがかかる可能性がある。
最適化のプラクティス:
1. プリミティブなキャッシュの活用: `Idle` のようなペイロードを持たない状態は、毎回オブジェクトリテラル `{ _tag: ‘Idle’ }` を生成せず、モジュールスコープで定数としてシングルトン共有(あるいは今回の例のようにコンストラクター関数経由で統一)することで、不要なメモリ割り当てを抑制できる。
2. 過度のネストを避ける: ADTを何重にもネストさせると、型推論のコンパイル速度(TypeScript Language Serverのパフォーマンス)が著しく低下する。型定義はなるべく浅く、フラットに保つのがスケーラブルなコードベースの秘訣だ。
—
総括
「動けばいいや」というコードは、書いた本人以外の全員にとっての技術的負債になる。
Type Aliasによる代数的データ型のモデリングは、単に「型エラーが出ないようにするため」のものではない。「ビジネスロジックの仕様を、そのままコンパイル可能なドキュメントとしてコードに定着させる」ための最強の武器だ。
次のコードレビューで、もしバラバラのフラットなフラグ(`isLoading: boolean`, `hasError: boolean` など)で状態を管理しているコードを見かけたら、こう言ってやりたまえ。
「その状態、型で矛盾を排除できるよ」と。