Kodokon kodokon.com

never、網羅性、そして高度な判別可能ユニオン

判別可能ユニオンで状態をモデル化し、never 型でその網羅性を固定します。

10 分 · 3 問

このレッスンを Kodokon で開く

never は空の型です。どんな値もそこには存在しません。コードパスが到達不可能であるとコンパイラが証明したときに現れます。たとえば、ユニオンのすべてのメンバーを使い尽くした後などです。この性質が never を設計ツールにします。switch の default 分岐でコンパイラに never と言わせられれば、すべてのケースが処理されている静的な証明が得られます。これが 網羅性チェック の基礎です。

前のモジュールで判別可能ユニオンを知りました。今度はそれを本物の設計ツールに変える番です。省略可能なフィールドを詰め込んだ単一のインターフェースと比べて、判別可能ユニオンは 不可能な状態を表現不可能にしますdataerror を同時に持つことも、データのない成功を持つこともできません。読み込み状態、操作結果、イベントメッセージにうってつけのパターンです。

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data は success バリアントにのみ存在し、他のどこからもアクセスできません。

default 分岐で呼ばれる assertNever ガードにはすでに出会っています。ここでその仕組みを正確に分解しましょう。すべてのケースがカバーされている限り、その引数はそこで never 型になり、すべてがコンパイルされます。switch を更新せずにユニオンにバリアントを追加すると、忘れられたバリアントはもはや never に代入できなくなります。コンパイル時エラーとなり、メッセージには欠けているケースの名前が示されます。本番のバグを、拒否されるビルドへと変えるのです。

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
Shape にバリアントを追加すると area のコンパイルが壊れます。

注目に値する高度な細部が 2 つあります。第一に、判別子による絞り込みが入れ子の構造に伝播するのは、囲んでいるオブジェクトを直接テストした場合だけです。判別子だけを分割代入すると結び付きが切れます(TypeScript 4.6 以降で扱えるようになった共同分割代入のケースを除く)。第二に、never はユニオンで吸収されます(string | never は単に string です)。これが、Exclude のように条件型がバリアントを取り除ける理由です。

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract は判別子でユニオンを絞り込みます。ハンドラは正確になります。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. 読み込み状態において、省略可能なフィールドを持つインターフェースよりも判別可能ユニオンが好ましいのはなぜですか?
    • 実行時のメモリ使用量が少ない
    • 矛盾した組み合わせ(data と error を同時に持つ)を表現不可能にする
    • switch 文を書かずに済む
    • strict モードで必須だから
  2. assertNever を含む switch を変更せずに Shape にバリアントを追加すると何が起きますか?
    • area を呼ぶたびに例外がスローされる
    • コンパイルは通るが、新しいバリアントは undefined を返す
    • 忘れられたバリアントが never に代入できないため、コンパイルが失敗する
  3. string | never という型は何になりますか?
    • never
    • string
    • unknown
    • string | undefined