本文 AI 產出,尚未審核

TypeScript • 型別推論與型別保護(Control Flow Analysis)


簡介

在 JavaScript 世界裡,變數的型別是「動態」的,程式在執行時才會決定到底是字串、數字或是物件。TypeScript 為了在編譯階段就捕捉錯誤,引入了型別推論(type inference)與型別保護(type narrowing)。其中最核心的機制就是 Control Flow Analysis(控制流程分析)——編譯器會根據程式的執行路徑,動態推斷變數的可能型別,並在適當的時候「縮窄」它們。

為什麼這麼重要?

  1. 提升開發效率:不必手動為每個變數寫完整的型別宣告,編譯器會自動幫你補完。
  2. 減少執行時錯誤:在條件分支、switch、if 內部,TypeScript 能保證你只對符合條件的型別呼叫屬性或方法。
  3. 增進程式可讀性:控制流程分析讓程式的意圖更清晰,未來維護或重構時也不易出現型別不一致的問題。

接下來,我們會一步步拆解「控制流程分析」的運作原理,並透過實務範例說明如何在日常開發中善加利用。


核心概念

1. 基本型別推論

當變數在宣告時即被賦值,TypeScript 會依照賦值的字面值推斷型別。

let count = 0;          // 推論為 number
const name = "Alice";   // 推論為 "Alice"(字面量類型)
let flag = true;        // 推論為 boolean

注意:const 會保留字面量型別(例如 "Alice"),而 let 會退化成更寬鬆的基本型別(string)。

2. 控制流程分析的工作原理

控制流程分析會在 程式的控制流(if、else、switch、while、for、try/catch…) 中,根據條件的結果「縮窄」變數的型別。簡單來說,編譯器會把 「可能的型別集合」 逐步縮小,直到只能剩下單一型別或是更精確的聯合型別。

2.1 if / else 之間的縮窄

function printLength(value: string | number) {
  if (typeof value === "string") {
    // 這裡 value 已被縮窄為 string
    console.log(value.length);
  } else {
    // 這裡 value 被縮窄為 number
    console.log(value.toFixed(2));
  }
}
  • typeof value === "string" 為 型別保護(type guard),讓編譯器在 if 區塊內把 value 視為 string,在 else 區塊視為 number。
  • 若在 if 內直接寫 value.toFixed(2),編譯器會報錯,因為此時 value 仍被視為 string | number。

2.2 switch 內的縮窄

type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      // s 被縮窄為 { kind: "circle"; radius: number }
      return Math.PI * s.radius ** 2;
    case "square":
      // s 被縮窄為 { kind: "square"; side: number }
      return s.side ** 2;
    case "rect":
      // s 被縮窄為 { kind: "rect"; width: number; height: number }
      return s.width * s.height;
  }
}
  • switch 會自動把 s 的型別根據 case 中的字面值「分支」縮窄。
  • 只要 case 內的屬性在對應的型別中存在,編譯器就會正確推斷,避免手動使用 as 斷言。

2.3 迴圈與條件判斷

function filterNumbers(arr: (string | number)[]): number[] {
  const result: number[] = [];
  for (const item of arr) {
    if (typeof item === "number") {
      // item 在此區塊內被縮窄為 number
      result.push(item);
    }
  }
  return result;
}
  • 在 for...of 迴圈中,item 的型別同樣會受到 if 條件的縮窄影響。
  • 這讓我們可以安全地把 item 推入 number[],而不會因為「可能是字串」而產生錯誤。

2.4 真值檢查(Truthiness Check)

function greet(name?: string) {
  if (name) {
    // name 在此被視為 string(非 undefined、null、空字串)
    console.log(`Hello, ${name}!`);
  } else {
    console.log("Hello, guest!");
  }
}
  • TypeScript 把 if (name) 視為「排除 undefined、null、""」的保護,讓 name 在 if 內部被認定為 string。
  • 這與 JavaScript 的真值概念一致,但在型別層面提供了安全保證。

3. 用戶自訂的型別保護(User‑Defined Type Guards)

有時候內建的 typeof、instanceof 不足,我們可以自行寫函式返回 類型謂詞(type predicate):

interface Cat { kind: "cat"; meow(): void }
interface Dog { kind: "dog"; bark(): void }

function isCat(pet: Cat | Dog): pet is Cat {
  return pet.kind === "cat";
}

function talk(pet: Cat | Dog) {
  if (isCat(pet)) {
    // pet 被縮窄為 Cat
    pet.meow();
  } else {
    // pet 被縮窄為 Dog
    pet.bark();
  }
}
  • pet is Cat 是 類型謂詞,告訴編譯器「只要 isCat 回傳 true,pet 就一定是 Cat」。
  • 這種方式非常適合在大型代碼庫中建立可重用的型別保護函式。

4. 交叉型別與縮窄的限制

type A = { a: number; common: string };
type B = { b: boolean; common: string };
type AB = A | B;

function getCommon(x: AB) {
  // 直接存取 common 仍然安全,因為兩個型別都有此屬性
  return x.common;
}
  • 若所有聯合成員都具備同名屬性,縮窄不會改變其可用性。
  • 但若屬性在部分成員缺失,必須先做保護才能安全存取:
function getA(x: AB) {
  if ("a" in x) {
    // x 被縮窄為 A
    return x.a;
  }
  // 這裡 x 為 B,沒有 a 屬性
}

程式碼範例

以下提供 5 個實用範例,展示控制流程分析在日常開發中的典型應用。每段程式碼皆附有說明註解。

範例 1:API 回傳的多型別結果

type ApiResponse =
  | { status: "ok"; data: string[] }
  | { status: "error"; error: Error };

function handleResponse(res: ApiResponse) {
  if (res.status === "ok") {
    // ✅ res 被縮窄為 { status: "ok"; data: string[] }
    console.log("取得資料:", res.data.join(", "));
  } else {
    // ✅ res 被縮窄為 { status: "error"; error: Error }
    console.error("發生錯誤:", res.error.message);
  }
}

實務意義:在前端與後端溝通時,常見「成功」與「失敗」的不同結構,控制流程分析讓我們不必手動斷言,減少錯誤。

範例 2:表單欄位的動態驗證

type Field =
  | { type: "text"; value: string }
  | { type: "number"; value: number }
  | { type: "checkbox"; checked: boolean };

function validate(field: Field) {
  switch (field.type) {
    case "text":
      // field 被縮窄為 { type: "text"; value: string }
      return field.value.trim().length > 0;
    case "number":
      // field 被縮窄為 { type: "number"; value: number }
      return !isNaN(field.value);
    case "checkbox":
      // field 被縮窄為 { type: "checkbox"; checked: boolean }
      return field.checked === true;
  }
}

實務意義:表單元件往往有多種型別,使用 switch 讓每個分支只關注自己需要的屬性,程式碼更易讀且安全。

範例 3:自訂型別保護檢查 DOM 元素

function isHTMLInputElement(el: Element): el is HTMLInputElement {
  return el instanceof HTMLInputElement;
}

function getInputValue(el: Element): string | null {
  if (isHTMLInputElement(el)) {
    // el 被縮窄為 HTMLInputElement
    return el.value;
  }
  return null;
}

實務意義:在操作瀏覽器 DOM 時,常需要先確認元素類型,這裡的自訂型別保護讓 TypeScript 能正確推斷 value 屬性的存在。

範例 4:處理 null 與 undefined 的安全寫法

function getLength(s?: string | null): number {
  // 先排除 null、undefined、空字串
  if (!s) return 0;
  // 此時 s 被縮窄為 string
  return s.length;
}

實務意義:API 回傳的字串欄位常會是 null 或 undefined,使用真值檢查可一次排除多種「不存在」的情況。

範例 5:從混合陣列過濾出特定型別

type Primitive = string | number | boolean;

function filterPrimitives(arr: any[]): Primitive[] {
  const out: Primitive[] = [];
  for (const v of arr) {
    if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
      // v 在此被縮窄為 Primitive
      out.push(v);
    }
  }
  return out;
}

實務意義:在處理 JSON 或外部資料時,常需要把雜湊資料過濾成特定型別,控制流程分析讓 push 的型別安全性自動得到保障。


常見陷阱與最佳實踐

陷阱 說明 解決方式
忘記 break/return 在 switch 雖然 TypeScript 仍會縮窄,但執行時會跌入下一個 case,可能導致不預期的行為。 在每個 case 內使用 return、break 或 throw 結束分支。
使用 any 破壞推論 一旦變數被指定為 any,控制流程分析無法再提供型別安全。 盡量避免 any,改用 unknown 並在需要時手動保護。
過度依賴 as 斷言 as 會告訴編譯器「我知道這是什麼」,但若斷言錯誤,執行時會出錯。 優先使用 typeof、instanceof 或自訂型別保護,僅在確定安全時才使用 as。
聯合型別的共同屬性 只因所有成員都有某屬性,編譯器不會縮窄至單一型別,仍保留聯合型別。 若需要更精細的型別資訊,使用 in 檢查或自訂型別保護。
null/undefined 的真值檢查 if (value) 會排除 0、false 等「假值」,若這些值在你的邏輯中是合法的,會造成誤判。 針對 null/undefined 使用 value != null 或明確的類型比較。

最佳實踐

  1. 讓編譯器自行推論:除非有特殊需求,盡量省略顯式的型別註記,讓 TypeScript 透過控制流程自動推斷。
  2. 使用內建型別保護:typeof、instanceof、in、真值檢查是最直接且效能最好的保護方式。
  3. 建立可重用的型別保護函式:對於複雜的判斷(如深層物件結構),寫成 isXxx 函式,提升可讀性與一致性。
  4. 在 switch 中列舉所有可能的 case:若使用 never 來捕捉未處理的型別,可在未來擴充聯合型別時得到編譯錯誤提醒。
function exhaustiveCheck(x: never): never {
  throw new Error(`未處理的值: ${x}`);
}

// 範例
function foo(v: "a" | "b") {
  switch (v) {
    case "a":
      // ...
      break;
    case "b":
      // ...
      break;
    default:
      exhaustiveCheck(v); // 編譯時若新增 "c" 會報錯
  }
}

實際應用場景

1. 前端表單驗證框架

在大型表單(多步驟、動態欄位)中,欄位的型別往往在執行時才決定。透過控制流程分析,我們可以寫出一套 型別安全的驗證函式庫,每個驗證器只需要關注自己能處理的型別,其他分支自動被排除。

2. Redux / NgRx 狀態管理

狀態物件通常是 聯合型別(例如 Loading | Success<T> | Failure),在 reducer 或 selector 中使用 if (state.status === "Success") 時,TypeScript 立即縮窄至 Success<T>,讓開發者無需手動斷言即可安全存取 state.payload。

3. Node.js 後端 API 參數驗證

在 Express、Koa 等框架中,req.body 常是 any。透過自訂型別保護(例如 isCreateUserDto(req.body))),我們能在進入業務邏輯前把 req.body 縮窄為明確的 DTO(Data Transfer Object),同時避免 any 帶來的隱藏錯誤。

4. 跨平台程式庫(如 React Native)

平台差異導致某些 API 僅在 iOS 或 Android 上可用。使用 if (Platform.OS === "ios") 時,編譯器會把相關變數縮窄,讓只能在 iOS 上使用的型別(如 IOSSpecificProp) 不會在 Android 分支中被誤用。


總結

  • 控制流程分析 是 TypeScript 型別系統的核心機制,讓編譯器根據程式的執行路徑自動推斷與縮窄型別。
  • 透過 if/else、switch、迴圈、真值檢查以及 自訂型別保護,我們可以在不寫冗長型別宣告的前提下,仍然得到完整的型別安全。
  • 正確運用這些特性,不僅能減少執行時錯誤,還能提升程式可讀性與維護性。
  • 在實務開發中,從 API 回傳結果、表單驗證、狀態管理到跨平台差異,都能看到控制流程分析的身影。只要遵守最佳實踐、避免 any 與過度斷言,我們就能充分發揮 TypeScript 的型別力量。

關鍵一句話:讓 TypeScript 的控制流程分析為你自動「看門」——在每一次條件分支裡,保證變數只會是它被允許的型別。