TypeScript:エラー処理と型
1学習の目的
- カスタムエラークラスと Result 型を使い分けられるようになる。「失敗するかもしれない」ことを型として表現し、対処漏れを防げるようになる。
- never による網羅性チェックを理解する。分岐の書き忘れを、コンパイラが検出してくれる仕組みを作れるようになる。
2基礎解説
JS10では例外(throw / catch)を学びました。TypeScriptにはもう1つの選択肢があります——失敗を「例外」ではなく「戻り値」として扱う方法です。
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| 例外 | throw new Error(…) | 想定外の異常 |
| カスタムエラー | class XError extends Error | エラーの種類を分けたい |
| Result 型 | { ok: true, value } | { ok: false, error } | 想定内の失敗 |
| 網羅性チェック | const _: never = value | 分岐漏れの検出 |
- 例外は型に現れない。function f(): number と書いてあっても、実は throw するかもしれない——呼び出す側は型からそれを知れない。
- Result 型なら失敗が型に現れる。戻り値が { ok: false } の可能性を含むので、呼び出し側は必ず確認を迫られる。
- Error を継承したカスタムエラーを作ると、instanceof で種類を判別できる。追加の情報(どの項目でエラーか等)も持たせられる。
- エラーの種類もタグ付きユニオンで表せる(TS09)。{ kind: "network" } | { kind: "validation" } のように分ければ、種類ごとに違う情報を持てる。
- never で網羅性を検証できる。switch にすべての場合を書いたか、コンパイラが確認してくれる。新しい種類を追加したとき、対応漏れが必ず見つかる。
現場使用例:フォーム検証、API呼び出しの失敗、ファイル読み込み、決済処理。「起きて当然の失敗」は Result 型、「起きてはいけない異常」は例外と使い分ける。
// カスタムエラー(種類と追加情報を持てる)
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = "ValidationError";
}
}
try {
throw new ValidationError("name", "必須です");
} catch (e) {
if (e instanceof ValidationError) {
console.log(e.field, e.message); // field にアクセスできる
}
}
// Result 型(失敗が戻り値の型に現れる)
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: "0で除算はできません" };
}
return { ok: true, value: a / b };
}
const r = divide(10, 0);
console.log(r.ok ? r.value : r.error); // 確認しないと使えない
// エラーの種類をタグ付きユニオンで分ける
type AppError =
| { kind: "network"; status: number }
| { kind: "validation"; field: string };
function describe(e: AppError): string {
switch (e.kind) {
case "network":
return `通信エラー(${e.status})`;
case "validation":
return `入力エラー:${e.field}`;
default: {
const _exhaustive: never = e; // 漏れがあればエラーになる
return _exhaustive;
}
}
}
3基本ドリル(10問)
出力 == name / 必須です
class ValidationError extends Error { constructor(public field: string, message: string) { super(message); } }
class ValidationError extends Error {
constructor(
public field: string,
message: string
) {
super(message);
this.name = "ValidationError";
}
}
try {
throw new ValidationError("name", "必須です");
} catch (e) {
if (e instanceof ValidationError) {
console.log(`${e.field} / ${e.message}`);
}
}
カスタムエラーには追加情報を持たせられる。「どの項目でエラーが起きたか」を、エラー自身が知っている状態にできる。
A. 分かる B. 分からない C. 常に投げる D. 投げられない 選択★☆☆無料
解答 == B
戻り値の型に例外の情報は含まれるか。
B
これが例外の弱点。呼び出す側は型を見ても失敗の可能性に気づけず、try/catch を書き忘れる。Result 型ならこの問題が起きない。
出力2行。5 / 0で除算はできません
type Result
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: "0で除算はできません" };
}
return { ok: true, value: a / b };
}
for (const [a, b] of [[10, 2], [10, 0]]) {
const r = divide(a, b);
console.log(r.ok ? r.value : r.error);
}
失敗が戻り値の型に含まれているので、呼び出す側は r.ok を確認しないと値を取り出せない。対処忘れが構造的に起きない。
class MyError extends Error {}
try {
throw new MyError("失敗");
} catch (e) {
if (e ____ MyError) {
console.log("MyErrorです");
}
}
出力 == MyErrorです
クラスのインスタンスかを判定する演算子(TS14)。
instanceof
catch の e は unknown なので、必ず種類を確認する。複数のカスタムエラーを使い分けるときも、この判定で分岐する。
A. 処理が速い B. 失敗の可能性が型に現れる C. コードが短い D. 例外より安全に throw できる 選択★☆☆無料
解答 == B
呼び出す側から見て何が変わるか。
B
型を見れば「この関数は失敗しうる」と分かる。try/catch の書き忘れという事故が起きなくなる。
出力2行。通信エラー(404) / 入力エラー:email
switch で e.kind により分岐する。それぞれのブロックで固有のプロパティが使える。
type AppError =
| { kind: "network"; status: number }
| { kind: "validation"; field: string };
function describe(e: AppError): string {
switch (e.kind) {
case "network":
return `通信エラー(${e.status})`;
case "validation":
return `入力エラー:${e.field}`;
}
}
console.log(describe({ kind: "network", status: 404 }));
console.log(describe({ kind: "validation", field: "email" }));
エラーの種類ごとに持つ情報が違うのが実務の実態。タグ付きユニオンなら、通信エラーに field が無いことも型で保証される。
type Status = "a" | "b";
function check(s: Status): string {
switch (s) {
case "a":
return "A";
case "b":
return "B";
default: {
const exhaustive: ____ = s;
return exhaustive;
}
}
}
console.log(check("a"));
出力 == A
「決して起こらない」を表す型。小文字5文字。
never
すべての場合を処理していれば、default に到達する値は存在しない。だから never 型に代入できる。分岐を書き漏らすと、この行がエラーになって教えてくれる。
A. 何も起きない B. 対応していない箇所がエラーになる C. 自動で対応される D. 実行時エラーになる 選択★★☆無料
解答 == B
新しい種類は default に流れ込む。
B
これが網羅性チェックの真価。エラーの種類を1つ追加すれば、対応が必要なすべての箇所がコンパイルエラーで教えてくれる。修正漏れが起きない。
出力2行。42 / 数値に変換できません
Number() の結果を Number.isNaN で確認する。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function toNumber(s: string): Result<number> {
const n = Number(s);
if (Number.isNaN(n)) {
return { ok: false, error: "数値に変換できません" };
}
return { ok: true, value: n };
}
for (const s of ["42", "abc"]) {
const r = toNumber(s);
console.log(r.ok ? r.value : r.error);
}
「変換できないことは想定内」なので Result 型が適している。例外を投げるほどの異常ではなく、日常的に起こりうる失敗だから。
出力2行。見つかりません / 権限がありません
それぞれ Error を継承し、instanceof で判別する。
class NotFoundError extends Error {}
class PermissionError extends Error {}
function handle(e: unknown): string {
if (e instanceof NotFoundError) {
return "見つかりません";
}
if (e instanceof PermissionError) {
return "権限がありません";
}
return "不明なエラー";
}
console.log(handle(new NotFoundError()));
console.log(handle(new PermissionError()));
エラーの種類ごとに処理を分けられる。「見つからない」と「権限が無い」では、ユーザーに見せるべきメッセージも次の行動も違う。
4実践シナリオ(5問)
出力3行。田中 / 未入力です / 長すぎます
trim() してから判定する。21文字は "あ".repeat(21)。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function validateName(name: string): Result<string> {
const trimmed = name.trim();
if (trimmed.length === 0) {
return { ok: false, error: "未入力です" };
}
if (trimmed.length > 20) {
return { ok: false, error: "長すぎます" };
}
return { ok: true, value: trimmed };
}
for (const input of ["田中", "", "あ".repeat(21)]) {
const r = validateName(input);
console.log(r.ok ? r.value : r.error);
}
フォーム検証は「失敗が当たり前」の処理。例外を投げると try/catch だらけになるので、Result 型のほうが自然に書ける。
出力3行。通信エラー(500) / 入力エラー:email / 不明:予期しない問題
switch の default で const _: never = e; と書く。
type AppError =
| { kind: "network"; status: number }
| { kind: "validation"; field: string }
| { kind: "unknown"; message: string };
function describe(e: AppError): string {
switch (e.kind) {
case "network":
return `通信エラー(${e.status})`;
case "validation":
return `入力エラー:${e.field}`;
case "unknown":
return `不明:${e.message}`;
default: {
const exhaustive: never = e;
return exhaustive;
}
}
}
console.log(describe({ kind: "network", status: 500 }));
console.log(describe({ kind: "validation", field: "email" }));
console.log(describe({ kind: "unknown", message: "予期しない問題" }));
4種類目のエラーを追加すると、この default 行がエラーになる。「対応を忘れている」ことをコンパイラが教えてくれる仕組み。
出力 == 在庫不足です(残り5個)
constructor(public remaining: number) で在庫数を保持する。
class OutOfStockError extends Error {
constructor(public remaining: number) {
super("在庫不足です");
this.name = "OutOfStockError";
}
}
function order(stock: number, qty: number): void {
if (qty > stock) {
throw new OutOfStockError(stock);
}
}
try {
order(5, 10);
} catch (e) {
if (e instanceof OutOfStockError) {
console.log(`${e.message}(残り${e.remaining}個)`);
}
}
エラー自身が「残り何個か」を知っている。呼び出し側で在庫を再取得する必要がなく、ユーザーに具体的な情報を伝えられる。
出力3行が完全一致
1つ目の Result が失敗ならそのまま返し、成功なら次の検証に進む。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function toNumber(s: string): Result<number> {
const n = Number(s);
return Number.isNaN(n)
? { ok: false, error: "数値に変換できません" }
: { ok: true, value: n };
}
function requirePositive(n: number): Result<number> {
return n > 0
? { ok: true, value: n }
: { ok: false, error: "正の数ではありません" };
}
function parse(s: string): Result<number> {
const converted = toNumber(s);
if (!converted.ok) {
return converted;
}
return requirePositive(converted.value);
}
for (const s of ["42", "-5", "abc"]) {
const r = parse(s);
console.log(r.ok ? r.value : r.error);
}
失敗したらそこで止めて、そのまま返す——これが Result 型をつなぐ基本形。if (!converted.ok) return converted; の1行で、以降は成功したと確定する。
出力2行が完全一致
function tryCatch
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function tryCatch<T>(fn: () => T): Result<T> {
try {
return { ok: true, value: fn() };
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "不明なエラー",
};
}
}
const a = tryCatch(() => 42);
console.log(a.ok ? `成功:${a.value}` : `失敗:${a.error}`);
const b = tryCatch<number>(() => {
throw new Error("計算エラー");
});
console.log(b.ok ? `成功:${b.value}` : `失敗:${b.error}`);
既存のライブラリが例外を投げる場合の橋渡し。1箇所で Result に変換すれば、以降のコードは try/catch を書かずに済む。
5仕上げ課題
エラーの定義(タグ付きユニオン)
type OrderError =
| { kind: "invalidQty"; qty: number }
| { kind: "outOfStock"; remaining: number }
| { kind: "notFound"; id: number };
Result 型:{ ok: true; value: T } | { ok: false; error: OrderError }
処理する注文(4件)
・{ id: 1, qty: 2 } … ノートPC(在庫5・単価128000)→ 成功
・{ id: 1, qty: 10 } … 在庫不足
・{ id: 2, qty: 0 } … 数量が不正
・{ id: 99, qty: 1 } … 商品が存在しない
エラーメッセージ(never による網羅性チェックを入れること)
・invalidQty →「数量が不正です(0)」
・outOfStock →「在庫不足です(残り5個)」
・notFound →「商品が見つかりません(ID:99)」
期待される出力(6行)
✅ 256,000円
❌ 在庫不足です(残り5個)
❌ 数量が不正です(0)
❌ 商品が見つかりません(ID:99)
---
成功1件 / 失敗3件
出力6行が完全一致
order 関数は Result
type OrderError =
| { kind: "invalidQty"; qty: number }
| { kind: "outOfStock"; remaining: number }
| { kind: "notFound"; id: number };
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: OrderError };
type Product = { id: number; name: string; price: number; stock: number };
const products: Product[] = [
{ id: 1, name: "ノートPC", price: 128000, stock: 5 },
{ id: 2, name: "マウス", price: 3200, stock: 42 },
];
function order(id: number, qty: number): Result<number> {
if (qty <= 0) {
return { ok: false, error: { kind: "invalidQty", qty } };
}
const product = products.find((p) => p.id === id);
if (!product) {
return { ok: false, error: { kind: "notFound", id } };
}
if (qty > product.stock) {
return {
ok: false,
error: { kind: "outOfStock", remaining: product.stock },
};
}
return { ok: true, value: product.price * qty };
}
function describeError(e: OrderError): string {
switch (e.kind) {
case "invalidQty":
return `数量が不正です(${e.qty})`;
case "outOfStock":
return `在庫不足です(残り${e.remaining}個)`;
case "notFound":
return `商品が見つかりません(ID:${e.id})`;
default: {
const exhaustive: never = e;
return exhaustive;
}
}
}
const requests = [
{ id: 1, qty: 2 },
{ id: 1, qty: 10 },
{ id: 2, qty: 0 },
{ id: 99, qty: 1 },
];
let ok = 0;
let ng = 0;
for (const req of requests) {
const result = order(req.id, req.qty);
if (result.ok) {
ok++;
console.log(`✅ ${result.value.toLocaleString()}円`);
continue;
}
ng++;
console.log(`❌ ${describeError(result.error)}`);
}
console.log("---");
console.log(`成功${ok}件 / 失敗${ng}件`);
3種類の失敗が、それぞれ違う情報を持っている。在庫不足なら「残り何個か」、商品が無いなら「どのIDか」、数量が不正なら「いくつだったか」——ユーザーに伝えるべき情報は、失敗の種類によって違う。タグ付きユニオンなら、それを型として正確に表現できる。
そして describeError の default にある const exhaustive: never = e; に注目してほしい。この1行があるおかげで、OrderError に4種類目を追加した瞬間、この関数がコンパイルエラーになる。「新しいエラーを定義したが、メッセージを書き忘れた」という事故が起きない。
例外との違いも明確だ。もし throw で実装していたら、order の型は number のままで、呼び出す側は失敗の可能性に気づけない。try/catch を書き忘れれば、アプリごと落ちる。
「起きて当然の失敗」は Result 型、「起きてはいけない異常」は例外——この使い分けが、堅牢なアプリケーションの土台になる。在庫不足は日常的に起こることであり、異常ではない。だから戻り値で表現するのが正しい。
次章では tsconfig と厳格モードを学ぶ。ここまで暗黙的に有効だった設定を、自分で制御できるようになる。