【テクニカル・上級編】 タグ付きユニオン(判別共用体)の模倣 – JavaScript実践ガイド

タグ付きユニオンでJavaScriptの動的型付けの荒波を乗りこなす:実務で使える堅牢な型安全パターン

こんにちは、フロントエンドの現場を渡り歩いてきたチーフアーキテクトの私だ。

日々のコードレビューで、こんな状態のオブジェクトに遭遇して頭を抱えたことはないだろうか?

// 地獄の「プロパティ存在確認」の山
function handleResponse(res) {
if (‘data’ in res && !res.error) {
// 成功処理
} else if (‘error’ in res) {
// エラー処理
} else if (‘loading’ in res) {
// ローディング処理
}
}

JavaScriptは動的言語としての柔軟性を持つがゆえに、こうした「何が入っているか実行時までわからないオブジェクト」の海で溺れがちだ。TypeScriptを導入すればコンパイラが守ってくれるが、プレーンなJavaScriptで書かれた巨大なコードベースや、ランタイムのデータ境界(APIレスポンスのパースなど)では、私たち自身が型安全の防壁を築かなければならない。

そこで今回は、関数型言語やTypeScriptのDiscriminated Union(判別共用体)の思想をJavaScriptの世界に輸入し、極限まで堅牢でパフォーマンスの高いコードベースを構築するためのデザインパターンを解説しよう。

—

なぜ「タグ付きユニオン(判別共用体)」なのか?

タグ付きユニオンの本質は、「状態を識別するための共通のプロパティ(タグ)をオブジェクトに持たせ、その値によって取り得るデータ構造を完全に排他的に定義する」ことだ。

JavaScriptのオブジェクトは非常に緩い。任意のプロパティを後から追加できるし、存在しないプロパティにアクセスしても`undefined`が返るだけで、静かにサイレントバグを孕む。
「あれが入っていればこの状態、これが入っていなければあの状態」という曖昧な判定を繰り返していると、コードは一瞬でスパゲッティ化し、V8エンジンのJITコンパイラにとっても最適化しにくい(隠しクラスが頻繁に変わり、インラインキャッシュが効かなくなる)厄介な存在になる。

タグ付きユニオンを導入することで、以下のメリットが生まれる。

1. 分岐の網羅性と予測可能性: `type`プロパティを軸に処理を分岐させることで、状態の抜け漏れを防ぐ。
2. メモリ効率とV8の隠しクラス(Hidden Class)の最適化: オブジェクトの形状(Shape)をあらかじめ固定化し、V8のインラインキャッシュを最大限に活かす。
3. 非同期処理の競合(Race Condition)の制御: ステートマシーン的なアプローチが可能になり、不正な状態遷移をコンパイル時(またはランタイムのバリデーション時)に弾く。

—

実装:プレーンなJavaScriptで描くタグ付きユニオン

百聞は一見に如かず。まずは、非同期のAPIリクエストを模した堅牢なタグ付きユニオンの実装を見てほしい。

/

  • @file networkState.js
  • タグ付きユニオンを用いたネットワーク状態の表現

/

// 1. 各状態を生成するファクトリー関数(オブジェクトの形状を完全に固定化する)
const NetworkState = {
idle: () => ({
type: ‘IDLE’
}),

loading: (progress = 0) => ({
type: ‘LOADING’,
payload: { progress } // 進行度を強制的に持たせる
}),

success: (data) => ({
type: ‘SUCCESS’,
payload: { data } // 成功データ型をカプセル化
}),

failure: (error) => ({
type: ‘FAILURE’,
payload: { error } // エラーオブジェクトを強制
})
};

// 2. パターンマッチングを模した安全なハンドラー
function matchNetworkState(state, handlers) {
const handler = handlers[state.type];

if (!handler) {
throw new Error(`予期せぬ状態の型が検出されました: ${state.type}`);
}

// 状態に応じたペイロードを型安全に(JS的文脈で)渡す
return handler(state.payload);
}

// — 使用例 —

let currentState = NetworkState.idle();

// 状態がローディングに遷移
currentState = NetworkState.loading(45);

// レンダリングやデータ処理のロジック
const result = matchNetworkState(currentState, {
IDLE: () => ‘待機中…’,
LOADING: ({ progress }) => `読み込み中… ${progress}%`,
SUCCESS: ({ data }) => `データ取得成功: ${JSON.stringify(data)}`,
FAILURE: ({ error }) => `エラー発生: ${error.message}`
});

console.log(result); // “読み込み中… 45%”

このパターンの美しいところは、「不正な状態の存在を許さない」という点にある。例えば、`SUCCESS`なのに`progress`が存在するといった矛盾したデータ構造は、ファクトリー関数を通すことで物理的に排除される。

—

V8エンジンの内部挙動とパフォーマンスの最適化

ギークな私たちにとって、JavaScriptの「実行速度」と「メモリ消費」は無視できないテーマだ。
動的言語であるJavaScriptにおいて、オブジェクトは本質的に「辞書(ハッシュマップ)」に近い。しかし、V8などのモダンなエンジンは、オブジェクトのプロパティ構造(隠しクラス / Hidden Class)が同じであれば、C++の構造体のように高速にアクセスできるように最適化を行っている。

ここで、散らかり放題のオブジェクトを生成しているとどうなるか?

// アンチパターン:プロパティの動的追加は隠しクラスを破壊する
const badState = { type: ‘LOADING’ };
if (hasProgress) {
badState.progress = 45; // ここでオブジェクトの形状が変わり、V8の最適化が外れる(Deoptimization)
}

タグ付きユニオンのファクトリー関数(先ほどの `NetworkState` のようなパターン)を使う最大のメリットは、最初からすべてのキーを含んだ(あるいは一貫した形状を持つ)オブジェクトインスタンスを生成することにある。
プロパティの順序や有無がバラバラにならないため、V8は「インラインキャッシュ(Inline Caching)」をフル活用でき、プロパティアクセスをネイティブコードレベルの速度まで引き上げることができる。

—

非同期の競合(Race Condition)と状態の排他制御

フロント開発で最も頭を悩ませるのが、複数の非同期処理が絡み合ったときの「競合」だ。
例えば、ユーザーが素早くタブを切り替えたとき、古いリクエストのレスポンスが後から返ってきて、画面のデータが上書きされてしまうバグ(いわゆる古の幽霊データ問題)は誰もが経験しているはずだ。

タグ付きユニオンは、この非同期の競合を防ぐためのステートマシーン(有限オートマトン)の基盤としても極めて強力に機能する。

/

  • リクエストIDをタグ付きユニオンに含め、古いレスポンスを完全に無視するアーキテクチャ

/
class AsyncDataStore {
constructor() {
this.state = { type: ‘IDLE’ };
this.currentRequestId = 0;
}

async fetch(apiClient, endpoint) {
// リクエストごとにユニークなトークンを発行(これが実質的な追加タグになる)
const requestId = ++this.currentRequestId;

this.state = {
type: ‘LOADING’,
payload: { requestId }
};

try {
const data = await apiClient.get(endpoint);

// 競合チェック:非同期処理の途中で別のリクエストが発生していたら結果を捨てる
if (this.currentRequestId !== requestId) {
console.warn(`古いリクエスト(#${requestId})の結果破棄されました。`);
return;
}

this.state = {
type: ‘SUCCESS’,
payload: { data, requestId }
};
} catch (error) {
if (this.currentRequestId !== requestId) return;

this.state = {
type: ‘FAILURE’,
payload: { error, requestId }
};
}
}
}

このように、状態を単なる「フラグの集まり(`isLoading: true`, `hasError: false`など)」ではなく、「排他的なユニオン型」として捉えることで、不可能な状態の組み合わせ(例: `isLoading: true` かつ `hasError: true`)を型レベル、あるいは設計レベルで完全に封じ込めることができる。

—

実務で直面する「型破り」なデータへの対抗策:ランタイムバリデーション

TypeScriptを使っていればコンパイル時にすべてが解決するが、私たちが対峙するリアルな世界には「外部から飛んでくる怪しいJSON」が存在する。APIサーバーが仕様書を裏切って変なデータを返してきたとき、タグ付きユニオンの前提は音を立てて崩れ去る。

ここで重要になるのが、「境界でのバリデーション(Boundary Validation)」だ。データがアプリケーションの内部に侵入するまさにその瞬間に、タグ付きユニオンの形に強制変換、あるいは検証するガードを挟む。

/

  • 外部からの未知の入力を、安全なタグ付きユニオンに変換するガード関数

/
function parseExternalApiResponse(rawResponse) {
// タグが存在するか、想定内か
if (!rawResponse || typeof rawResponse.status !== ‘string’) {
return NetworkState.failure(new Error(‘不正なレスポンスフォーマットです’));
}

switch (rawResponse.status) {
case ‘ok’:
return NetworkState.success(rawResponse.body);

case ‘fail’:
return NetworkState.failure(new Error(rawResponse.message));

default:
return NetworkState.failure(new Error(`未知のステータス: ${rawResponse.status}`));
}
}

アプリケーションのコアロジックは、このパース済みのタグ付きユニオンだけを扱えば良い。外部の混沌から内部の秩序を守る「防波堤」として、このパターンは機能する。

—

まとめ:コードベースの「エントロピー」に抗うために

ソフトウェアの規模が拡大するにつれて、エントロピー(無秩序さ)は確実に増大する。「とりあえずプロパティを生やしておけば動く」というJavaScriptの手軽さは、初期のプロトタイピングには最高だが、数ヶ月後のメンテナンスフェーズではチームの足を引っ張る負債に変わる。

タグ付きユニオン(判別共用体)は、決して大げさなオーバーエンジニアリングではない。

  • 予測可能なデータ構造によるバグの根絶
  • V8エンジンの隠しクラスを意識したパフォーマンスの維持
  • 非同期処理の競合をコントロールする明確な指針

これらを手に入れられるのであれば、導入しない理由はない。

プレーンなJavaScriptであっても、設計の思想次第でコードは驚くほど堅牢で美しいものに生まれ変わる。さあ、あなたのプロジェクトの散らかった状態管理を、今すぐタグ付きユニオンでリファクタリングしてみよう。エンジニアとしての職人技が、そこに宿るはずだ。

コメント

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