TypeScript 錯誤處理與例外:型別安全的錯誤傳遞
簡介
在 JavaScript 中,throw/try…catch 是唯一的錯誤傳遞機制,然而它本身不具備型別資訊。當我們在大型專案或多人協作的環境裡,沒有明確的錯誤型別,往往會出現「捕到的錯誤不是我預期的」或「錯誤訊息過於模糊」的問題。
TypeScript 為 JavaScript 加上了靜態型別系統,讓開發者可以在編譯階段就捕捉錯誤傳遞的型別不一致。透過自訂錯誤類別、never 型別、以及 Result/Either 之類的函式式錯誤處理模式,我們可以在程式碼的每一層都清楚知道會拋出什麼錯誤、該如何處理,進而提升程式的可讀性、可維護性與可靠度。
本篇文章將從 型別安全的錯誤傳遞 出發,說明核心概念、提供實作範例、列出常見陷阱與最佳實踐,最後結合實務案例,幫助你在 TypeScript 專案中建立穩固的錯誤處理機制。
核心概念
1. 為什麼要自訂錯誤類別
原生的 Error 只提供 name、message、stack 三個屬性,若直接拋出字串或任意物件,編譯器無法得知「此錯誤到底是哪一種」:
throw "Network error"; // 沒有型別資訊,catch 時只能得到 string
透過 繼承 Error 並加入自訂屬性,我們可以讓錯誤本身成為一個具體的型別,在 catch 區塊裡使用 型別守衛(type guard)即可得到完整資訊。
class ValidationError extends Error {
constructor(public readonly field: string, message: string) {
super(message);
this.name = "ValidationError";
}
}
2. never 型別與拋出錯誤的函式
在 TypeScript 中,never 表示「永遠不會有返回值」的函式,例如拋出錯誤或無限迴圈。將拋錯的函式宣告為 never,可以讓編譯器在控制流程分析時正確推斷後續程式碼不會執行,避免出現 未處理的 undefined 錯誤。
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
此函式常用於 switch 完整性檢查,保證所有可能的錯誤型別都被列舉。
3. 使用 Result 型別模擬函式式錯誤處理
函式式程式設計(Functional Programming)常以 返回值 來傳遞錯誤,而不是拋出例外。以下是一個簡化的 Result 型別:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
使用 Result 的好處是錯誤必須被顯式處理(因為回傳值是聯合型別),編譯器會提醒你檢查 ok 屬性。
4. 型別守衛(Type Guard)搭配自訂錯誤
型別守衛是一種返回布林值的函式,能讓 TypeScript 在 if 判斷後收窄變數的型別。對於自訂錯誤,我們可以寫:
function isValidationError(err: unknown): err is ValidationError {
return err instanceof ValidationError;
}
在 catch 中使用:
try {
// …
} catch (e) {
if (isValidationError(e)) {
console.error(`欄位 ${e.field} 錯誤: ${e.message}`);
} else {
console.error("未知錯誤", e);
}
}
程式碼範例
範例 1:自訂錯誤類別與型別守衛
// file: errors.ts
export class NotFoundError extends Error {
constructor(public readonly resource: string) {
super(`${resource} not found`);
this.name = "NotFoundError";
}
}
export function isNotFoundError(err: unknown): err is NotFoundError {
return err instanceof NotFoundError;
}
// file: service.ts
import { NotFoundError, isNotFoundError } from "./errors";
async function fetchUser(id: string) {
const user = await db.findUser(id);
if (!user) {
throw new NotFoundError(`User(${id})`);
}
return user;
}
// 呼叫端
(async () => {
try {
const u = await fetchUser("123");
console.log(u);
} catch (e) {
if (isNotFoundError(e)) {
// 這裡 e 已被縮小為 NotFoundError,field 完全可用
console.warn(`找不到資源:${e.resource}`);
} else {
console.error("其他錯誤", e);
}
}
})();
重點:
isNotFoundError讓編譯器知道e的具體型別,避免any造成的隱藏錯誤。
範例 2:never 用於 switch 完整性檢查
type ApiError = ValidationError | NotFoundError | PermissionError;
function handleApiError(err: ApiError) {
switch (err.name) {
case "ValidationError":
console.log(`欄位 ${err.field} 錯誤`);
break;
case "NotFoundError":
console.log(`找不到 ${err.resource}`);
break;
case "PermissionError":
console.log(`權限不足`);
break;
default:
// 若未列舉所有錯誤型別,編譯會在此行報錯
assertNever(err);
}
}
assertNever 會在 未處理的錯誤型別 出現時拋出例外,且編譯器會在 default 分支提示「err 不是 never」的錯誤,強迫開發者補齊缺漏。
範例 3:Result 型別的實作與使用
// file: result.ts
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export function success<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function failure<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// file: parser.ts
import { Result, success, failure } from "./result";
interface User {
id: number;
name: string;
}
/**
* 解析 JSON,若格式不正確回傳錯誤資訊
*/
export function parseUser(json: string): Result<User, string> {
try {
const obj = JSON.parse(json);
if (typeof obj.id !== "number" || typeof obj.name !== "string") {
return failure("Invalid user shape");
}
return success(obj as User);
} catch {
return failure("Invalid JSON");
}
}
// 呼叫端
const r = parseUser('{"id":1,"name":"Alice"}');
if (r.ok) {
console.log("User:", r.value);
} else {
console.error("Parse error:", r.error);
}
使用 Result 可以把 錯誤處理的責任搬到返回值,強迫呼叫端必須檢查 ok,降低遺漏 try…catch 的風險。
範例 4:結合 async/await 與 Result
async function fetchJson(url: string): Promise<Result<any, Error>> {
try {
const resp = await fetch(url);
if (!resp.ok) {
return failure(new Error(`HTTP ${resp.status}`));
}
const data = await resp.json();
return success(data);
} catch (e) {
return failure(e instanceof Error ? e : new Error(String(e)));
}
}
// 使用
(async () => {
const result = await fetchJson("/api/data");
if (result.ok) {
console.log("Data:", result.value);
} else {
console.warn("Fetch failed:", result.error.message);
}
})();
此模式在 前端/Node.js 中非常實用,因為網路請求常伴隨非 2xx 狀態與 JSON 解析錯誤,Result 能把所有可能的失敗集中管理。
範例 5:全域錯誤處理中介層(Express)
import express, { Request, Response, NextFunction } from "express";
import { ValidationError, NotFoundError, isValidationError, isNotFoundError } from "./errors";
const app = express();
app.get("/users/:id", async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await fetchUser(req.params.id); // 可能拋出自訂錯誤
res.json(user);
} catch (e) {
next(e); // 交給全域錯誤中介層處理
}
});
// 全域錯誤中介層
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (isValidationError(err)) {
res.status(400).json({ error: err.message, field: err.field });
} else if (isNotFoundError(err)) {
res.status(404).json({ error: err.message });
} else {
console.error("未處理的錯誤:", err);
res.status(500).json({ error: "Internal Server Error" });
}
});
透過 型別守衛,全域中介層可以根據錯誤類型回傳不同的 HTTP 狀態碼與訊息,實現 型別安全且一致的 API 錯誤回應。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方案 |
|---|---|---|
拋出字串或 any |
直接 throw "error",失去型別資訊 |
永遠拋出 Error 或其子類別,或使用 never 函式 |
在 catch 中使用 any |
catch (e) 預設型別為 unknown,若直接 e as Error 可能隱藏非錯誤物件 |
使用 unknown,搭配 型別守衛 (instanceof、自訂 guard) |
忘記處理 Result 的 ok: false |
只檢查 if (result.ok),未處理 else 分支 |
強制檢查,或使用 assertNever 讓編譯器提醒遺漏 |
錯誤類別未設定 name |
Error 內建的 name 仍是 "Error",難以在 switch 判斷 |
在子類別建構子中 明確設定 this.name |
stack 被覆寫或遺失 |
自訂錯誤未呼叫 super(message),或在 Babel/TS 設定 target 為舊版 |
務必呼叫 super,並在 tsconfig.json 設定 target 為 ES2015+ |
最佳實踐
- 自訂錯誤類別:每個業務邏輯錯誤(驗證、授權、資源未找到)都應有獨立的類別。
- 型別守衛:在
catch、switch、或任何需要辨識錯誤的地方,都使用型別守衛。 never完整性檢查:在switch或if中加入assertNever,保證所有錯誤分支被列舉。- 函式式錯誤處理:對於非例外情境(如驗證失敗、API 回傳錯誤碼),優先考慮
Result/Either。 - 全域錯誤中介層:在 Express、Koa、NestJS 等框架裡統一處理錯誤,並根據錯誤型別回傳適當的 HTTP 狀態碼與錯誤結構。
實際應用場景
1. 前端表單驗證
在 React + TypeScript 專案中,使用自訂的 ValidationError 來收集欄位錯誤,並在表單提交時一次拋出錯誤陣列:
function validate(form: Record<string, any>): void {
const errors: ValidationError[] = [];
if (!form.email.includes("@")) {
errors.push(new ValidationError("email", "Email 必須包含 @"));
}
if (form.password.length < 8) {
errors.push(new ValidationError("password", "密碼長度至少 8 位"));
}
if (errors.length) {
// 把錯誤陣列包成單一錯誤拋出
throw new AggregateError(errors, "表單驗證失敗");
}
}
在 UI 層捕獲後,透過 isValidationError 把錯誤映射回對應的表單欄位,實現 即時、型別安全的錯誤提示。
2. 微服務間的錯誤協議
在微服務架構中,服務 A 可能收到服務 B 回傳的錯誤代碼。將錯誤代碼映射成 型別安全的錯誤類別(如 RemoteServiceError),再利用 Result 讓呼叫方必須處理:
type RemoteErrorCode = "TIMEOUT" | "UNAUTHORIZED" | "NOT_FOUND";
class RemoteServiceError extends Error {
constructor(public readonly code: RemoteErrorCode, message: string) {
super(message);
this.name = "RemoteServiceError";
}
}
// 呼叫遠端服務
async function callRemote(): Promise<Result<Data, RemoteServiceError>> {
const resp = await fetch("https://service-b/api");
if (!resp.ok) {
const code = resp.status === 401 ? "UNAUTHORIZED" : "NOT_FOUND";
return failure(new RemoteServiceError(code as RemoteErrorCode, resp.statusText));
}
const data = await resp.json();
return success(data);
}
這樣的設計讓服務 A 在編譯期就知道「只能收到 RemoteServiceError」或成功的 Data,避免因錯誤代碼變更而產生隱蔽 bug。
3. Node.js 後端的資料庫操作
資料庫層常會拋出 UniqueConstraintError、ForeignKeyError 等錯誤。將這些錯誤封裝成自訂類別,並在 Repository 層返回 Result:
class UniqueConstraintError extends Error {
constructor(public readonly field: string) {
super(`欄位 ${field} 重複`);
this.name = "UniqueConstraintError";
}
}
async function createUser(user: User): Promise<Result<User, UniqueConstraintError>> {
try {
const saved = await db.insert(user);
return success(saved);
} catch (e) {
if (e instanceof db.UniqueViolation) {
return failure(new UniqueConstraintError(e.column));
}
throw e; // 非預期錯誤仍拋出
}
}
呼叫端必須顯式處理 UniqueConstraintError,例如回傳 409 Conflict,提升 API 的一致性與可預測性。
總結
- 型別安全的錯誤傳遞 不是額外的負擔,而是提升程式碼品質的關鍵工具。
- 透過 自訂錯誤類別、型別守衛、
never完整性檢查,我們可以在編譯期即捕捉錯誤型別不匹配的問題。 - **
Result/Either** 等函式式錯誤處理模式,讓錯誤變成普通的返回值,迫使呼叫方必須處理,避免遺漏try…catch`。 - 在 前端表單驗證、微服務協議、資料庫操作 等實務情境中,將上述概念落實,就能得到 一致、可維護且易於除錯的系統。
掌握了這套「型別安全」的錯誤處理思維,未來在任何 TypeScript 專案裡,你都能更自信地設計 API、撰寫業務邏輯,並減少因錯誤未被正確捕捉而導致的系統崩潰。祝開發順利,錯誤「看得見」才是最安全的!