TypeScriptのnever型とは?switch文の網羅性チェック(exhaustive check)の実装パターン

TypeScriptのnever型の仕組みとvoidとの違いから、switch文の網羅性チェック(exhaustive check)をassertNever関数やsatisfies演算子で実装する方法までを解説する。Reduxのreducerや状態機械への実務適用例、FAQも収録。

TypeScriptで**ユニオン型(Union Type)**をswitch文で処理する際、すべてのケースを漏れなく処理できているかどうかは、実行時まで気づきにくいバグの温床になりやすい。この記事では、TypeScriptのnever型がどのような型システム上の位置づけを持つのかを整理したうえで、switch文の網羅性チェック(exhaustive check)をassertNever関数とsatisfies演算子の2つの実装パターンで解説する。最後に、Reduxのreducerや状態機械といった実務での適用例と、よくある疑問への回答もまとめている。

1. never型とは: 型システム上の位置づけ

TypeScriptの型は、大まかに「どれだけ多くの値を許容するか」という広さで序列がある。一方の端にはunknown(あらゆる値を許容するトップ型)があり、もう一方の端にはnever(どんな値も許容しないボトム型)がある。never型が表すのは「その型を持つ値はこの世に存在しない」という状態であり、すべての型のサブタイプとして扱われる。逆に言えば、never型の変数に代入できる値は存在しない。

neverが実際に登場するのは、大きく分けて次の2つの場面である。

  1. 関数が正常に戻ってこないことが型として分かっている場合(常に例外を投げる関数、無限ループする関数)
  2. 型の絞り込み(narrowing)を重ねた結果、理論上「もうどの型にも該当しない」状態に到達した場合

voidとの違い

neverとよく混同されるのがvoid型である。両者は「戻り値に意味がない」という点で似ているが、型システム上の意味はまったく異なる。

nevervoid
意味その型の値は存在しない意味のある戻り値を返さない(実際はundefinedが返る)
関数の呼び出し後呼び出し以降のコードは到達不能になる呼び出し後も通常どおり処理は続く
変数への代入いかなる値も代入できないundefinedは代入できる
型階層上の位置すべての型のボトム型(サブタイプ)通常の型の1つ
// void: 「意味のある値は返さないが、関数は正常に戻ってくる」
function log(message: string): void {
  console.log(message);
  // ここで処理は終わり、呼び出し元に制御が戻る
}

// never: 「この関数は決して正常に戻ってこない」
function fail(message: string): never {
  throw new Error(message);
  // 例外を投げるため、関数がreturnで終わることはない
}

log("done"); // 実行後、次の行に処理が続く
fail("エラー"); // ここで例外が投げられ、以降のコードには到達しない
console.log("到達しない");

logvoidを返す関数だが、呼び出したあとも普通に処理が続く。一方failneverを返す関数であり、TypeScriptはこの関数が「正常に戻ってくることはない」と認識する。この性質は、後述する網羅性チェックや、条件分岐後の型の絞り込みで重要な役割を果たす。

2. neverが現れる場面

到達不能コード

neverを返す関数を呼び出した直後のコードは、TypeScriptの制御フロー解析によって「到達不能」と判定される。これを利用すると、ガード節のあとで型を絞り込むことができる。

function fail(message: string): never {
  throw new Error(message);
}

function toUpperCase(input: string | null): string {
  if (input === null) {
    fail("input must not be null");
    // fail() の戻り値型が never なので、
    // TypeScript はここから先に処理が進まないことを知っている
  }
  // このスコープでは input の型は string に絞り込まれている
  return input.toUpperCase();
}

failの戻り値型がneverであるため、TypeScriptはifブロックを抜けた後のinputからnullを除外できる。もしfailの戻り値型がvoidだったなら、TypeScriptは「failのあとも処理が続く可能性がある」とみなし、inputstring | nullのままになってしまう。

絞り込みの果て

iftypeofによる型の絞り込みを尽くしたとき、理論上どの分岐にも当てはまらない残りの型はneverになる。

function describe(value: string | number): string {
  if (typeof value === "string") {
    return `文字列: ${value}`;
  } else if (typeof value === "number") {
    return `数値: ${value}`;
  } else {
    // string | number からstringとnumberを除いた残りは存在しない
    // value の型はここで never になる
    const exhausted: never = value;
    throw new Error(`Unexpected value: ${exhausted}`);
  }
}

valueの型はstring | numberであり、typeofによる2つの分岐で両方のケースを処理し尽くしている。そのためelse節に到達するvalueの型は理論上存在せず、TypeScriptはこれをneverと推論する。次のセクションで扱う網羅性チェックは、この性質をswitch文に応用したものである。

3. switch文の網羅性チェックを実装する

基本パターン: assertNever関数

switch文でユニオン型を処理する際、すべてのcaseを網羅していることをコンパイル時に保証する定番のテクニックが、default句でnever型への代入を行う方法である。単に変数へ代入するだけでなく、専用の関数を用意しておくと再利用しやすい。

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    case "rectangle":
      return shape.width * shape.height;
    default:
      return assertNever(shape);
  }
}

case節で"circle""square""rectangle"のすべてを処理し終えているため、default句に到達する時点でshapeの型はneverに絞り込まれている。assertNeverの引数はnever型を要求するので、この状態であれば正しく型検査を通過する。

const _: never = shape;という代入だけで済ませる書き方と比べて、assertNever関数を挟む利点は次の2つである。

  • 複数のswitch文で同じチェックを使い回せる
  • 万が一実行時に想定外の値が来ても(APIレスポンスの型が実際の値と食い違っていた場合など)、エラーメッセージ付きで例外を投げてくれる

つまりassertNeverは、コンパイル時のチェックと実行時のフェイルセーフを1つの関数にまとめたパターンだと言える。

現代的なパターン: satisfiesによるマッピングの網羅性チェック

TypeScript 4.9で導入されたsatisfies演算子を使うと、switch文とは別の切り口で網羅性を保証できる。単純な値のマッピングであれば、オブジェクトリテラルとsatisfies Record<...>を組み合わせるほうが簡潔になる場合がある。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

// kindごとの日本語ラベルを持つ、網羅的なマップ
const shapeLabels = {
  circle: "円",
  square: "正方形",
  rectangle: "長方形",
} satisfies Record<Shape["kind"], string>;

function label(shape: Shape): string {
  return shapeLabels[shape.kind];
}

satisfies Record<Shape["kind"], string>と書くことで、「shapeLabelsShape["kind"]が取りうるすべてのキーを持つオブジェクトでなければならない」という制約をTypeScriptに伝えられる。キーが1つでも欠けていれば、そのままコンパイルエラーになる。

switch文でロジックが複雑に分岐する処理にはassertNeverパターンが向いており、キーと値の単純な対応関係を表現したい場合にはsatisfiesによるマップの方が読みやすい。両者は排他的な選択肢ではなく、用途に応じて使い分けるのが現実的である。

4. ユニオン型にケースを追加したときにエラーで気づける実例

網羅性チェックの効果を実感するには、実際にユニオン型へメンバーを追加してみるのが一番早い。先ほどのShape型に"triangle"(三角形)を追加してみる。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number }; // 追加

このとき、case "triangle"を書き忘れたままarea関数を放置していると、default句に到達するshapeの型はneverではなく{ kind: "triangle"; base: number; height: number }になる。その結果、以下のようなコンパイルエラーが発生する。

Argument of type '{ kind: "triangle"; base: number; height: number; }'
is not assignable to parameter of type 'never'.

satisfiesを使ったshapeLabelsの側でも、同様にエラーが発生する。

Property 'triangle' is missing in type
'{ circle: string; square: string; rectangle: string; }'
but required in type 'Record<"circle" | "square" | "rectangle" | "triangle", string>'.

どちらのエラーも、caseの実装漏れやマップのキー漏れを、テストの実行を待たずにtscの実行(あるいはエディタ上)で即座に検知できる。ユニオン型に新しいメンバーを追加するリファクタリングは実務で頻繁に発生するため、この「エラーで気づける」という性質はレビューコストの削減に直結する。

5. 実務での適用パターン

Reduxアクション

Reduxやそれに準じたstate管理では、アクションの型をユニオンで定義し、reducer内のswitch文で処理を振り分けるのが典型的な構成である。ここにassertNeverを組み込んでおくと、アクションの追加漏れをコンパイル時に検知できる。

type CounterAction =
  | { type: "counter/increment" }
  | { type: "counter/decrement" }
  | { type: "counter/reset"; payload: number };

function counterReducer(state: number, action: CounterAction): number {
  switch (action.type) {
    case "counter/increment":
      return state + 1;
    case "counter/decrement":
      return state - 1;
    case "counter/reset":
      return action.payload;
    default:
      return assertNever(action);
  }
}

新しいアクション(たとえば"counter/incrementBy")をCounterActionに追加したにもかかわらずcaseを実装し忘れた場合、assertNever(action)の行でコンパイルエラーになる。reducerが肥大化しがちなReduxベースのアプリケーションほど、この仕組みの恩恵は大きい。

状態機械(ステートマシン)

非同期処理の状態を"idle" | "loading" | "success" | "error"のようなユニオン型で表現し、状態ごとの表示や遷移をswitch文で処理するパターンでも同様に有効である。

type FetchStatus =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: string };

function renderMessage(state: FetchStatus): string {
  switch (state.status) {
    case "idle":
      return "待機中";
    case "loading":
      return "読み込み中...";
    case "success":
      return `取得成功: ${state.data}`;
    case "error":
      return `エラー: ${state.error}`;
    default:
      return assertNever(state);
  }
}

状態機械は状態が増えるたびに表示ロジックや遷移ロジックのすべてを更新する必要があるため、更新漏れがバグに直結しやすい。assertNeverをdefault句に置いておくことで、「新しい状態を追加したのに、対応する分岐を書き忘れる」という典型的なミスをコンパイル時に防げる。

6. まとめ

never型は「値が存在しない」ことを表すボトム型であり、到達不能コードや型の絞り込みの果てに出現する。この性質をswitch文のdefault句に応用することで、ユニオン型のすべてのケースを処理し尽くしているかどうかをコンパイル時に検証できる。assertNever関数によるパターンは複雑な分岐ロジックに、satisfiesによるマップは単純な値の対応関係に、それぞれ向いている。Reduxのreducerや状態機械のように、ユニオン型が今後も拡張されていくことが予想されるコードには、積極的にこの網羅性チェックを組み込んでおくとよい。

よくある質問(FAQ)

Q. nevervoidの違いは?

voidは「意味のある戻り値を返さない」ことを表す型で、関数は正常に呼び出し元へ戻ってくる(実際の戻り値はundefined)。一方neverは「その型の値は存在しない」ことを表すボトム型で、関数がneverを返す場合はその関数が正常に戻ってくることはない(必ず例外を投げるか、無限ループする)。voidを返す関数の呼び出し後は通常どおり処理が続くが、neverを返す関数の呼び出し後のコードは到達不能として扱われる。

Q. default節は書くべき?

網羅性チェックを機能させるためには、default節を書き、その中でnever型への代入(またはassertNeverの呼び出し)を行う必要がある。default節を省略すると、TypeScriptは「網羅されていない値が来た場合は何もしない」という挙動になり、ケース漏れがあってもコンパイルエラーにならない。網羅性を保証したいswitch文には、default節とneverチェックのセットを常に書くことを推奨する。

Q. enumとユニオン型、どちらで書くべき?

網羅性チェックという観点だけで見れば、文字列リテラルのユニオン型でもenumでも、この記事で紹介したassertNeverパターンは同じように機能する。switch文でenumのすべてのメンバーを処理し尽くせば、default句での型は同様にneverまで絞り込まれる。したがって選択は網羅性チェックの可否ではなく、バンドルサイズやツリーシェイキング、他システムとのシリアライズ形式との相性、コードベース全体の既存の方針といった、網羅性チェック以外の観点で判断するのがよい。

参考