mcpField
T
tagged-unions

tagged-unions

github.com/marius-townhouse開発652 回インストール39 週間前に更新
リポジトリ

tagged-unions について

TypeScript 開発者向けのコーディングスキルです。相互依存するユニオン型プロパティを持つインターフェースをタグ付きインターフェースのユニオンに置き換え、有効な状態の表現や網羅性チェックを促します。

主な機能

  1. 01依存するプロパティをインターフェースのユニオンに分割
  2. 02リテラルタグでバリアントを識別
  3. 03型の絞り込みで各バリアントのフィールドにアクセス
  4. 04状態分岐の網羅性をチェック
  5. 05外部データ向けの内部型を定義

活用シーン

  1. 01状態ごとに異なるデータをモデル化
  2. 02関連するオプショナルフィールドの不整合を防止
  3. 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「ユニオンを持つインターフェースより、インターフェースのユニオンを優先する」に基づいています。

コメント