tagged-unions について
TypeScript 開発者向けのコーディングスキルです。相互依存するユニオン型プロパティを持つインターフェースをタグ付きインターフェースのユニオンに置き換え、有効な状態の表現や網羅性チェックを促します。
主な機能
- 01依存するプロパティをインターフェースのユニオンに分割
- 02リテラルタグでバリアントを識別
- 03型の絞り込みで各バリアントのフィールドにアクセス
- 04状態分岐の網羅性をチェック
- 05外部データ向けの内部型を定義
活用シーン
- 01状態ごとに異なるデータをモデル化
- 02関連するオプショナルフィールドの不整合を防止
- 03状態分岐にコンパイル時チェックを追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill tagged-unionsインストール後、Claude Code で/tagged-unionsを入力して読み込まれたか確認します。
詳細
ユニオンを持つインターフェースより、インターフェースのユニオンを優先する
概要
インターフェースにユニオン型のプロパティがある場合、多くの場合、インターフェースのユニオンのほうが適切です。
タグ付きユニオン(判別可能なユニオン)を使うと、プロパティ間の関係が明示され、網羅的な型チェックが可能になります。
このスキルを使う場面
- 一緒に使う場合にのみ意味を持つインターフェースのプロパティ
- 状態ごとに異なるデータを持つ型
- 依存関係のある複数のブール値フラグ
- 暗黙の関係を持つユニオン型のプロパティ
- 文字列または列挙型のステータスフィールドに対する switch 文
絶対ルール
NEVER create interfaces where property combinations can be invalid.
例外はありません:
- 「こちらのほうがシンプルだから」という理由でも
- 「関係性はドキュメントに記載しているから」という理由でも
- 「実行時に検証しているから」という理由でも
検出:「ユニオンを持つインターフェース」の兆候
プロパティ間に依存関係がある場合は、インターフェースのユニオンに分割します。
// ❌ VIOLATION: Allows invalid combinations
interface Layer {
type: 'fill' | 'line' | 'point';
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
// These are technically valid but make no sense:
const badLayer: Layer = {
type: 'fill',
layout: new LineLayout(), // Wrong layout for fill!
paint: new PointPaint(), // Wrong paint for fill!
};
解決策:タグ付きユニオン
// ✅ CORRECT: Each variant is explicit
interface FillLayer {
type: 'fill';
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
type: 'line';
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
type: 'point';
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
// Now invalid combinations are impossible:
const badLayer: Layer = {
type: 'fill',
layout: new LineLayout(), // Error! Not assignable to FillLayout
paint: new PointPaint(),
};
便利な点:絞り込みが自動的に機能する
function drawLayer(layer: Layer) {
switch (layer.type) {
case 'fill':
// TypeScript knows: layer is FillLayer
console.log(layer.paint); // FillPaint
console.log(layer.layout); // FillLayout
break;
case 'line':
// TypeScript knows: layer is LineLayer
console.log(layer.paint); // LinePaint
break;
case 'point':
// TypeScript knows: layer is PointLayer
console.log(layer.paint); // PointPaint
break;
}
}
例:リクエストの状態
// ❌ BAD: Allows invalid states
interface RequestState {
status: 'pending' | 'loading' | 'success' | 'error';
data?: ResponseData;
error?: Error;
}
// What does this mean?
const weird: RequestState = {
status: 'error',
data: someData, // Has data but errored?
};
// ✅ GOOD: Tagged union
interface RequestPending { status: 'pending' }
interface RequestLoading { status: 'loading' }
interface RequestSuccess { status: 'success'; data: ResponseData }
interface RequestError { status: 'error'; error: Error }
type RequestState = RequestPending | RequestLoading | RequestSuccess | RequestError;
// Each state has exactly the data it needs
function handleRequest(state: RequestState) {
switch (state.status) {
case 'pending':
return <Spinner />;
case 'loading':
return <LoadingBar />;
case 'success':
return <DataView data={state.data} />; // data is guaranteed
case 'error':
return <ErrorView error={state.error} />; // error is guaranteed
}
}
オプショナルプロパティ:まとめる
// ❌ BAD: Related optional fields
interface Person {
name: string;
placeOfBirth?: string; // These should be
dateOfBirth?: Date; // together or absent
}
// Valid but inconsistent:
const person: Person = {
name: 'Alice',
placeOfBirth: 'NYC', // Has place but no date?
};
// ✅ GOOD: Group related optional fields
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}
// Now they're always together:
function printBirth(person: Person) {
if (person.birth) {
// Both place AND date are guaranteed
console.log(`${person.birth.place} on ${person.birth.date}`);
}
}
タグはリテラル型でなければならない
// ❌ BAD: Tag is too broad
interface Shape {
type: string; // Any string - can't narrow!
}
// ✅ GOOD: Tag is a literal union
interface Circle { type: 'circle'; radius: number }
interface Square { type: 'square'; side: number }
type Shape = Circle | Square;
// Now narrowing works:
function area(shape: Shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius ** 2; // TypeScript knows it's Circle
}
return shape.side ** 2; // TypeScript knows it's Square
}
網羅性チェック
タグ付きユニオンを使うと、網羅性チェックが可能になります:
function assertNever(x: never): never {
throw new Error(`Unexpected: ${x}`);
}
function area(shape: Shape): number {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
default:
return assertNever(shape); // Error if we miss a case!
}
}
// Later, if we add Triangle:
type Shape = Circle | Square | Triangle;
// TypeScript errors in assertNever:
// Argument of type 'Triangle' is not assignable to parameter of type 'never'
型を変更できない場合
自分で管理していない API からデータを受け取る場合:
// External API returns this shape
interface APIResponse {
type: 'user' | 'admin';
name: string;
permissions?: string[]; // Only for admin
}
// Create a better internal type:
interface User { type: 'user'; name: string }
interface Admin { type: 'admin'; name: string; permissions: string[] }
type Person = User | Admin;
// Transform at the boundary:
function transformResponse(response: APIResponse): Person {
if (response.type === 'admin') {
return {
type: 'admin',
name: response.name,
permissions: response.permissions ?? [],
};
}
return { type: 'user', name: response.name };
}
圧力に負けないためのプロトコル
1. 「インターフェースが増えてしまう」
圧力:「4つより1つのインターフェースのほうがシンプルだ」
回答: 無効な状態は、デバッグが簡単ではありません。
対応: インターフェースを記述しましょう。インターフェースはドキュメントにもなります。
2. 「依存関係はドキュメントに記載している」
圧力:「コメントで、フィールドを適用する条件を説明している」
回答: 型はより優れたドキュメントです。型はチェックされます。
対応: 型で関係性を表現しましょう。
危険信号 — 立ち止まって再検討する
- 関連する複数のオプショナルフィールドを持つインターフェース
- オプショナルなデータフィールドを持つステータス列挙型
- 型を確認してからデータにアクセスする switch 文
- 「X の場合、Y が設定される」と説明するコメント
- フィールドの組み合わせに対する実行時検証
よくある正当化(すべて無効)
| 言い訳 | 実際のところ |
|---|---|
| 「こちらのほうがシンプルだ」 | 無効な状態は、デバッグが簡単ではありません。 |
| 「検証している」 | 型はコンパイル時にエラーを検出します。 |
| 「インターフェースが多すぎる」 | バグが多すぎるよりはましです。 |
クイックリファレンス
| パターン | 解決策 |
|---|---|
| ステータス + オプショナルなデータ/エラー | ステータスごとのタグ付きユニオン |
| 型の判別子 + ユニオンフィールド | 型ごとのタグ付きユニオン |
| 関連するオプショナルフィールド | オプショナルなネストオブジェクト |
| 依存関係のあるブール値フラグ | 状態ごとのタグ付きユニオン |
結論
プロパティ間に依存関係がある場合は、その関係を型システムで表現しましょう。
タグ付きユニオン(判別可能なユニオン)を使って、関係性を明示しましょう。網羅性チェックが可能になり、絞り込みの精度が上がり、有効な状態のみを表現できる型が得られます。
参考資料
Dan Vanderkam 著「Effective TypeScript」の項目 34「ユニオンを持つインターフェースより、インターフェースのユニオンを優先する」に基づいています。