まず「エラーを投げる関数」を2種類に分けて考える
いきなり型の話に行く前に、役割で分けます。
エラーを投げる関数には、大きく言って次の2パターンがあります。
1つ目は、
「必ずエラーを投げて、絶対に呼び出し元に戻ってこない関数」。
function fail(message: string): never {
throw new Error(message);
}
TypeScript2つ目は、
「エラーを投げる“かもしれない”けれど、普通に値を返すこともある関数」。
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());
}
TypeScriptfail が never を返すと分かっているので、
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()); // エラー
}
TypeScriptvoid だと「戻ってこない」とは判断されないので、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 };
TypeScriptarea の switch に triangle を書き忘れていると、assertNever(shape) のところでコンパイルエラーになります。
shape の型は Shape なのに、assertNever は never しか受け取れないからです。
「ユニオン型の列挙漏れをコンパイル時に検出するための“保険”として、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 は返せない)
}
TypeScriptnever は「100%戻らない」関数だけに使う
「たまに throw する」関数には絶対に使わない。
ここを混ぜないことが、設計としてとても大事です。
「エラーを投げるかもしれない」ことは型には出さない
TypeScript には「この関数は例外を投げるかもしれない」という型はありません。
function mightThrow(): number {
if (Math.random() > 0.5) {
throw new Error("oops");
}
return 42;
}
TypeScriptこの関数の型はあくまで () => number です。
「throw するかもしれない」は、
型ではなくドキュメントや命名で伝える領域 になります。
never を使うのは、
「throw する“かもしれない”」ではなく、
「throw する“に決まっている”」関数だけです。
エラーを投げる関数の「設計の視点」
この関数の“役割”は何か?をはっきりさせる
エラーを投げる関数を設計するとき、
自分にこう聞いてみてください。
「この関数の役割は、
“値を返すこと”か?
“処理を止めること”か?」
fail や assertNever のような関数は、
完全に「処理を止める係」です。
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;
}
TypeScriptpanic は「止める係」。getUserName は「値を返す係」。
panic を never にしておくことで、getUserName の中では user.name が必ずあると推論されます。
「止めるロジック」を小さな never 関数に閉じ込めておくと、
他の関数の中がスッキリし、型推論も賢くなります。
まとめ:エラーを投げる関数の型を自分の言葉で言うと
最後に、あなた自身の言葉でこう整理してみてください。
エラーを投げる関数には2種類ある。
1つは「必ず throw して戻ってこない関数」。
これは never を返す関数として設計する。
例:fail, panic, assertNever など。
もう1つは「throw することもあるが、普通に値も返す関数」。
これは T(number, string など)を返す関数として設計し、
「throw するかもしれない」ことは型ではなく説明で伝える。
設計するときは、
「この関数の役割は“処理を止めること”か?」
「ここに来たらバグ、と言い切れる場所か?」
と自分に問いかけて、
それでも「はい」と言えるときだけ never を戻り値に選ぶ。
そうやって意図的に使うと、
エラーを投げる関数は、
“ただ throw するだけの関数”から、
コード全体の流れと安全性をコントロールするための設計ツール
に変わっていきます。
