本文 AI 產出,尚未審核

TypeScript 錯誤處理與例外:型別安全的錯誤傳遞


簡介

在 JavaScript 中,throw/try…catch 是唯一的錯誤傳遞機制,然而它本身不具備型別資訊。當我們在大型專案或多人協作的環境裡,沒有明確的錯誤型別,往往會出現「捕到的錯誤不是我預期的」或「錯誤訊息過於模糊」的問題。

TypeScript 為 JavaScript 加上了靜態型別系統,讓開發者可以在編譯階段就捕捉錯誤傳遞的型別不一致。透過自訂錯誤類別、never 型別、以及 Result/Either 之類的函式式錯誤處理模式,我們可以在程式碼的每一層都清楚知道會拋出什麼錯誤、該如何處理,進而提升程式的可讀性、可維護性與可靠度。

本篇文章將從 型別安全的錯誤傳遞 出發,說明核心概念、提供實作範例、列出常見陷阱與最佳實踐,最後結合實務案例,幫助你在 TypeScript 專案中建立穩固的錯誤處理機制。


核心概念

1. 為什麼要自訂錯誤類別

原生的 Error 只提供 namemessagestack 三個屬性,若直接拋出字串或任意物件,編譯器無法得知「此錯誤到底是哪一種」:

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/awaitResult

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)
忘記處理 Resultok: false 只檢查 if (result.ok),未處理 else 分支 強制檢查,或使用 assertNever 讓編譯器提醒遺漏
錯誤類別未設定 name Error 內建的 name 仍是 "Error",難以在 switch 判斷 在子類別建構子中 明確設定 this.name
stack 被覆寫或遺失 自訂錯誤未呼叫 super(message),或在 Babel/TS 設定 target 為舊版 務必呼叫 super,並在 tsconfig.json 設定 target 為 ES2015+

最佳實踐

  1. 自訂錯誤類別:每個業務邏輯錯誤(驗證、授權、資源未找到)都應有獨立的類別。
  2. 型別守衛:在 catchswitch、或任何需要辨識錯誤的地方,都使用型別守衛。
  3. never 完整性檢查:在 switchif 中加入 assertNever,保證所有錯誤分支被列舉。
  4. 函式式錯誤處理:對於非例外情境(如驗證失敗、API 回傳錯誤碼),優先考慮 Result/Either
  5. 全域錯誤中介層:在 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 後端的資料庫操作

資料庫層常會拋出 UniqueConstraintErrorForeignKeyError 等錯誤。將這些錯誤封裝成自訂類別,並在 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、撰寫業務邏輯,並減少因錯誤未被正確捕捉而導致的系統崩潰。祝開發順利,錯誤「看得見」才是最安全的!