TypeScript | 関数・クラス・ジェネリクス:関数設計の深化 – エラーを投げる関数の型

TypeScript
スポンサーリンク

まず「エラーを投げる関数」を2種類に分けて考える

いきなり型の話に行く前に、役割で分けます。

エラーを投げる関数には、大きく言って次の2パターンがあります。

1つ目は、
必ずエラーを投げて、絶対に呼び出し元に戻ってこない関数」。

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

2つ目は、
エラーを投げる“かもしれない”けれど、普通に値を返すこともある関数」。

function parseNumber(text: string): number {
  const n = Number(text);
  if (Number.isNaN(n)) {
    throw new Error("not a number");
  }
  return n;
}
TypeScript

この2つは、型の付け方も、設計としての意味もまったく違うので、
まずここをはっきり分けておくのが大事です。


「必ずエラーを投げる関数」の型は never にする

「戻ってこない」ことを型で宣言する

さっきの fail をもう一度見ます。

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

戻り値型が never になっています。

never は「絶対に値が存在しない型」でした。
関数の戻り値として使うときは、

「この関数は、正常に戻ってくることはない」

という意味になります。

実際、fail の中には return がありません。
必ず throw で終わります。

never にすることで得られるメリット

この never が効いてくるのは、「その後のコードの型推論」です。

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

function doSomething(value: string | null) {
  if (value === null) {
    fail("value is null");
  }

  // ここでは value は string として扱える
  console.log(value.toUpperCase());
}
TypeScript

failnever を返すと分かっているので、
TypeScript はこう推論します。

if (value === null) の中で fail を呼んだら、
その分岐から先には進まない。
だから、その外側では value はもう null ではありえない。
value の型を string に絞り込んでよい。

もし fail の戻り値を void にしてしまうと、こうはいきません。

function failVoid(message: string): void {
  throw new Error(message);
}

function doSomething2(value: string | null) {
  if (value === null) {
    failVoid("value is null");
  }

  // ここでは value は string | null のまま
  console.log(value.toUpperCase()); // エラー
}
TypeScript

void だと「戻ってこない」とは判断されないので、
value の型は狭まりません。

「ここで処理は必ず終わる」という事実を型に教えるために、
“必ず throw で終わる関数”は never を返すように設計する

これが1つ目の重要ポイントです。


「ここに来たらバグ」を表す assertNever パターン

ユニオン型の「漏れ」を検出するための関数

never を返す関数の代表的な使い方が、assertNever です。

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

この関数の引数は never、戻り値も never です。

x に値が入ること自体がおかしい」
「ここに来たら絶対にバグ」

という意味を、型で表現しています。

これをユニオン型の switch と組み合わせます。

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius * shape.radius;
    case "square":
      return shape.size * shape.size;
    default:
      return assertNever(shape); // ここに来たらバグ
  }
}
TypeScript

今は Shape"circle""square" だけなので、
default に入ることはありません。

でも、将来こうなったらどうでしょう。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "triangle"; base: number; height: number };
TypeScript

areaswitchtriangle を書き忘れていると、
assertNever(shape) のところでコンパイルエラーになります。

shape の型は Shape なのに、
assertNevernever しか受け取れないからです。

「ユニオン型の列挙漏れをコンパイル時に検出するための“保険”として、
never を返す assertNever を置いておく」

これは実務でもよく使う、かなり強力なパターンです。


「エラーを投げる“かもしれない”関数」は never にしない

典型例:パース関数や検証関数

次は、こういう関数です。

function parseNumber(text: string): number {
  const n = Number(text);
  if (Number.isNaN(n)) {
    throw new Error("not a number");
  }
  return n;
}
TypeScript

この関数は「エラーを投げることもある」けれど、
「普通に number を返すこともある」関数です。

この場合、戻り値型は number にすべきで、never ではありません。

もし never にしてしまうと、
「この関数は絶対に戻ってこない」と嘘をつくことになります。

function parseNumberBad(text: string): never {
  const n = Number(text);
  if (Number.isNaN(n)) {
    throw new Error("not a number");
  }
  return n; // ここでエラー(never に number は返せない)
}
TypeScript

never は「100%戻らない」関数だけに使う
「たまに throw する」関数には絶対に使わない。

ここを混ぜないことが、設計としてとても大事です。

「エラーを投げるかもしれない」ことは型には出さない

TypeScript には「この関数は例外を投げるかもしれない」という型はありません。

function mightThrow(): number {
  if (Math.random() > 0.5) {
    throw new Error("oops");
  }
  return 42;
}
TypeScript

この関数の型はあくまで () => number です。

「throw するかもしれない」は、
型ではなくドキュメントや命名で伝える領域 になります。

never を使うのは、
「throw する“かもしれない”」ではなく、
「throw する“に決まっている”」関数だけです。


エラーを投げる関数の「設計の視点」

この関数の“役割”は何か?をはっきりさせる

エラーを投げる関数を設計するとき、
自分にこう聞いてみてください。

「この関数の役割は、
“値を返すこと”か?
“処理を止めること”か?」

failassertNever のような関数は、
完全に「処理を止める係」です。

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

こういう関数は never で設計するのが自然です。

一方、parseNumber のような関数は、
「値を返すこと」が主役で、
throw は「異常系の手段」にすぎません。

function parseNumber(text: string): number {
  // メインの役割は number を返すこと
}
TypeScript

こういう関数は、戻り値型を number にして、
「throw するかもしれない」ことはコメントや命名で伝えます。

「止める係」か「返す係」かを分けて考えると、
never を使うべき関数と、そうでない関数がはっきりしてくる

と思ってください。

「止める係」を小さな関数に閉じ込める

実務では、こんな設計が気持ちいいです。

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

function getUserName(user: { name?: string }): string {
  if (!user.name) {
    panic("name is missing");
  }
  return user.name;
}
TypeScript

panic は「止める係」。
getUserName は「値を返す係」。

panicnever にしておくことで、
getUserName の中では user.name が必ずあると推論されます。

「止めるロジック」を小さな never 関数に閉じ込めておくと、
他の関数の中がスッキリし、型推論も賢くなります。


まとめ:エラーを投げる関数の型を自分の言葉で言うと

最後に、あなた自身の言葉でこう整理してみてください。

エラーを投げる関数には2種類ある。

1つは「必ず throw して戻ってこない関数」。
これは never を返す関数として設計する。
例:fail, panic, assertNever など。

もう1つは「throw することもあるが、普通に値も返す関数」。
これは Tnumber, string など)を返す関数として設計し、
「throw するかもしれない」ことは型ではなく説明で伝える。

設計するときは、

「この関数の役割は“処理を止めること”か?」
「ここに来たらバグ、と言い切れる場所か?」

と自分に問いかけて、
それでも「はい」と言えるときだけ never を戻り値に選ぶ。

そうやって意図的に使うと、
エラーを投げる関数は、
“ただ throw するだけの関数”から、
コード全体の流れと安全性をコントロールするための設計ツール

に変わっていきます。

タイトルとURLをコピーしました