序論:なぜその「Union型引数」は実務でバグを生むのか
コードレビューをしていて、以下のようなコードに遭遇したことはないだろうか。
type AdminUser = { id: string; permissions: string[] };
type GuestUser = { id: string; sessionId: string };
// よくある Union 型による引数定義
function authorizeAndLog(user: AdminUser | GuestUser) {
// コンパイラがプロパティの存在テレポートに失敗し、エラーになる
console.log(user.permissions); // ❌ Property ‘permissions’ does not exist on type…
}
「ゲストには `permissions` がないのだから、当然だ」と Type Guard (`’permissions’ in user` など) を書き足していく。しかし、扱うドメインモデルが増え、コンポーネントのPropsやAPIクライアントのフックが肥大化するにつれ、このUnionベースの分岐はコードベースを確実に蝕んでいく。
TypeScriptの型システムにおいて、Intersection Types(交差型: `&`)は単なるプロパティの「マージ」ではない。これは、「複数の契約(Contract)を同時に満たすことをコンパイラに強制し、実行時における情報の欠損を防ぐための強力なミューテーション技術」である。
今回は、関数引数におけるIntersection Typesを用いた「ミックスイン(Mixin)設計」を極め、実行時安全性を担保したままボイラープレートを極限まで削ぎ落とすプロダクションレベルの手法を伝授する。
—
1. Intersection Types(`&`)のコンパイル時挙動の本質
まず、TypeScriptのコンパイラが `A & B` をどう評価しているか、その「重み」を正しく理解しよう。
Union型(`A | B`)が「OR(集合の和)」であり、安全にアクセスできるのは両者に共通する共通集合(Narrowingが必要)に限定されるのに対し、Intersection型( আরাম `A & B`)は「AND(集合の積)」である。
type Timestamped = { createdAt: Date };
type Identifiable = { id: string };
// 2つの独立した契約の交差
type Entity = Timestamped & Identifiable;
この `Entity` 型を持つオブジェクトは、`createdAt` と `id` の両方を必ず持っていなければならない。これを関数の引数に応用すると、「特定の機能(Mixin)を後付けで要求する、再利用性の極めて高い関数」が爆誕する。
しかし、安易な `&` の乱用は、型推論のパフォーマンス低下や、到達不能な型(Never)を生む原因になる。プロパティの衝突(同名で異なる型を持つ場合)が起きた時、コンパイラはどう評価するか?
type T1 = { value: string };
type T2 = { value: number };
type Conflict = T1 & T2;
// 評価結果: { value: string & number } -> { value: never }
`string` かつ `number` なプリミティブなど存在しないため、`never` に落ちる。このコンパイル時の挙動を逆手に取り、安全なミックスイン関数を設計していく。
—
2. 実践:フロントエンド&API連携における「引数ミックスイン」設計
現代のWebフロントエンド開発では、UIコンポーネント、状態管理、APIフェッチ層が複雑に絡み合う。ここで、特定の拡張機能(ロギング、認可、監査ログ付与など)を任意の関数・フックに横断的に注入したい場面を考えてほしい。
以下のコードは、「ベースのペイロードに、監査情報と環境情報を安全にミックスインしてAPIへ送信する関数」のプロダクションコードである。
/
- 1. 基本ドメインモデル
/
type BasePayload = {
readonly userId: string;
readonly action: string;
};
/
- 2. ミックスイン用パーツ(独立した関心の分離)
/
type AuditTrailMixin = {
readonly ipAddress: string;
readonly userAgent: string;
};
type TenantContextMixin = {
readonly tenantId: string;
readonly region: ‘jp-northeast-1’ | ‘us-east-1’;
};
/
- 3. ユーティリティ型:複数のMixinを安全に合成する
- プリミティブの衝突を防ぎつつ、型定義をフラットに保つ
/
type MergeMixins
TMixins extends readonly [infer Head, …infer Tail]
? MergeMixins
: TBase;
/
- 4. 高階関数(Mixinインジェクター)の設計
- 匠の知見: 引数をそのまま受け取るのではなく、コンテキストを合成する
- ジェネリクスとIntersectionを組み合わせることで、呼び出し元の型推論を完璧に維持する。
/
function createAuditedExecutor<
TBase,
const TMixins extends readonly object[]
>(
baseFunction: (payload: MergeMixins
…mixins: TMixins
) {
// 実行時にすべてのMixinオブジェクトをベースにマージする
return async (basePayload: TBase): Promise
// 実行時のオブジェクト合成(スプレッド構文による浅いコピー)
const mergedPayload = mixins.reduce(
(acc, mixin) => ({ …acc, …mixin }),
basePayload as any
) as MergeMixins
// 型安全にラップされた関数を実行
return baseFunction(mergedPayload);
};
}
// ==========================================
// 実際の利用シーン(プロダクションコード)
// ==========================================
// APIクライアントのコアロジック(ベース関数)
// 引数には「すべてがマージされた型」が要求されるため、内部でプロパティ欠損の心配がゼロになる
const sendTelemetry = async (
payload: BasePayload & AuditTrailMixin & TenantContextMixin
) => {
// コンパイラは payload.tenantId や payload.ipAddress が確実に存在することを知っている
console.log(`[API送信] User: ${payload.userId}, Tenant: ${payload.tenantId}, IP: ${payload.ipAddress}`);
// fetch(…) などの非同期処理
};
// ミックスインの定義
const auditData: AuditTrailMixin = {
ipAddress: ‘192.168.1.10’,
userAgent: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X…)’,
};
const tenantData: TenantContextMixin = {
tenantId: ‘tenant-enterprise-001’,
region: ‘jp-northeast-1’,
};
// 高階関数でラップして「機能拡張された関数」を生成
const secureExecute = createAuditedExecutor(
sendTelemetry,
auditData,
tenantData
);
// 呼び出し側:BasePayloadだけを渡せばよい。Mixinは自動注入される!
await secureExecute({
userId: ‘usr_999888’,
action: ‘DELETE_RESOURCE’,
});
この設計が優れている理由
1. 呼び出し側の認知負荷の劇的な軽減:
`secureExecute` を呼ぶ開発者は、面倒な監査ログのIPアドレスやテナントIDを毎回手動で渡す必要がない。しかし、コアの `sendTelemetry` 関数側では、それらのプロパティが絶対に存在するという保証(Type Safety)のもとでコードを書ける。
2. `const assertions` (`const TMixins`) の活用:
TypeScript 5.0以降で強力になった const type parameters を用いることで、配列として渡されたMixinのプロパティの型(リテラル型など)を正確に保持したまま交差型に落とし込んでいる。
3. プロパティ汚染の防止:
`BasePayload` のイミュータビリティ(`readonly`)を保ちつつ、安全に合成を行っているため、意図しないミューテーションが走らない。
—
3. パフォーマンスとコンパイル負荷に関する「チーフアーキテクトの警告」
Intersection Typesは非常に強力だが、型パズルのような複雑な `&` の乱用は、TypeScriptの型チェッカー(TSServer)のパフォーマンスを確実に殺す。
🚨 避けるべきアンチパターン
// 巨大な型同士を無秩序に交差させ続ける
type BadA = { a: string } & { b: string } & { c: string } / … 100個続く /;
type BadB = BadA & { z: string };
IDE(VSCodeなど)でコードを書いている最中に、型ヒントの表示が数秒間フリーズしたり、CPU使用率が100%に張り付く現象(いわゆる “Type Instantiation is excessively deep and possibly infinite” エラー)に直面したことがあるはずだ。これはコンパイラが型の遅延評価や直積の計算に溺れている証拠である。
💡 対策:型の「事前評価(Pre-evaluation)」テクニック
もし複雑なIntersectionを扱う場合、ヘルパー型(通称 `Prettify` や `Expand`)を挟むことで、コンパイラに型の構造をフラットに評価させ、エディタのパフォーマンスとホバー時の見通しを劇的に改善できる。
// 型の構造を強制的に展開・評価するユーティリティ
type Prettify
[K in keyof T]: T[K];
} & {};
// 使用例
type ComplexPayload = Prettify
// VSCodeのホバー表示が { userId: string; action: string; ipAddress: string; … } のように
// 結合されたフラットなオブジェクトとして綺麗に表示されるようになる。
—
結論:型は「制約」であり「ドキュメント」である
多くのジュニア・ミドルクラスの開発者は、型を「エラーを防ぐための足枷」と捉えがちだ。しかし、シニア・テックリードの視点において、型とは「コードの意図をコンパイラとチームメイトに伝える最高精度のドキュメント」であり、アーキテクチャそのものである。
今回解説したIntersection Typesを用いた引数のミックスイン設計を取り入れれば、コードの重複(ボイラープレート)を排除しつつ、実行時エラーの芽をコンパイル時に完全に摘み取ることができる。
次のコードレビューで、Union型の迷路に迷い込んでいるコードを見つけたら、胸を張ってこの「ミックスイン設計」へとリファクタリングを提案してほしい。チームの開発生産性は、確実に次のステージへと引き上げられるはずだ。