本文 AI 產出,尚未審核

TypeScript 型別相容性與型別系統 – Type Widening / Narrowing


簡介

在 TypeScript 中,型別相容性(type compatibility)是靜態檢查的核心機制,而 type wideningtype narrowing 則是「讓程式碼在型別安全與彈性之間取得平衡」的兩把關鍵鑰匙。

  • Widening:在變數宣告或運算過程中,TypeScript 會把較窄的字面值型別自動擴大成更寬廣的型別(例如 1number),讓程式碼不必每次都寫明確的型別。
  • Narrowing:相反的,當程式執行到分支判斷或類型守護(type guard)時,TypeScript 會把寬泛的型別「縮小」成更具體的型別,讓開發者可以安全地存取屬性或呼叫方法。

掌握這兩個概念,不僅能減少冗長的型別宣告,還能避免因型別過寬或過窄而產生的執行時錯誤。以下將以實作範例說明其運作原理與最佳使用方式。


核心概念

1. 什麼是 Type Widening?

當 TypeScript 推斷一個變數的型別時,若它的值是字面量(literal),預設會把它 「寬化」 成對應的基本型別。

let a = 10;          // a 被推斷為 number(而非 10)
let b = "hello";     // b 被推斷為 string(而非 "hello")
const c = true;      // const 會保留字面值型別,c 為 true
  • let / var:會自動寬化,因為變數之後可能被重新指派不同的值。
  • const:保留字面值型別,除非使用 as const 進一步凍結。

為何需要 Widening?

  • 減少重複型別註記:不必每次都寫 numberstring,編譯器會幫你推斷。
  • 提升彈性:允許在後續的程式流程中改變值的型別(只要相容)。

2. 什麼是 Type Narrowing?

在程式的控制流程(ifswitchinstanceoftypeof 等)裡,TypeScript 會根據條件把變數的型別 「縮小」,讓開發者可以安全使用更具體的屬性或方法。

function printLength(x: string | number) {
  if (typeof x === "string") {
    // 在此分支中,x 被縮小為 string
    console.log(x.length);
  } else {
    // 這裡的 x 為 number
    console.log(x.toFixed(2));
  }
}

常見的縮小技巧

判斷方式 會縮小的型別
typeof stringnumberbooleansymbolbigintfunction
instanceof 任何 class 或 constructor
in (property existence) 具備該屬性的物件型別
自訂型別守護 (x is Y) 任意使用者自訂的型別

3. Widening 與 Narrowing 的互動

以下範例示範在同一段程式中,先被寬化再被縮小的完整流程。

let value = "TS";          // value 被寬化為 string
function process(v: string | number) {
  if (typeof v === "string") {
    // 進入此分支,v 被縮小回字面值型別 string
    console.log(`字串長度 ${v.length}`);
  } else {
    console.log(`數字 ${v.toFixed(1)}`);
  }
}
process(value);            // 正常呼叫,編譯器知道 value 為 string

如果把 value 改成 const value = "TS" as const;,則在 process 呼叫時,value 的型別會是字面值 "TS",仍然符合 string 的相容性,但在更嚴格的函式簽名中(例如 function foo(v: "TS"))就能直接匹配。


4. 具體範例

範例 1:使用 as const 防止過度寬化

const colors = ["red", "green", "blue"]; // 推斷為 string[]
// 加上 as const,變成 readonly ["red","green","blue"]
const colorsLiteral = ["red", "green", "blue"] as const;

// 只接受字面值型別的函式
function setTheme(color: "red" | "green" | "blue") {
  console.log(`主題顏色:${color}`);
}

setTheme(colors[0]);          // ❌ 編譯錯誤,因為 colors[0] 為 string
setTheme(colorsLiteral[0]);   // ✅ 正確,因為 colorsLiteral[0] 為 "red"

重點as const 可以「凍結」陣列或物件,使其保持最窄的字面值型別,避免不必要的寬化。

範例 2:利用 instanceof 縮小類別型別

class Dog {
  bark() { console.log("汪汪"); }
}
class Cat {
  meow() { console.log("喵喵"); }
}
type Pet = Dog | Cat;

function speak(pet: Pet) {
  if (pet instanceof Dog) {
    // pet 被縮小為 Dog
    pet.bark();
  } else {
    // pet 為 Cat
    pet.meow();
  }
}

範例 3:自訂型別守護(type guard)

interface Square {
  kind: "square";
  size: number;
}
interface Circle {
  kind: "circle";
  radius: number;
}
type Shape = Square | Circle;

// 型別守護函式
function isSquare(s: Shape): s is Square {
  return s.kind === "square";
}

function area(shape: Shape) {
  if (isSquare(shape)) {
    // shape 被縮小為 Square
    return shape.size ** 2;
  }
  // 此時 shape 為 Circle
  return Math.PI * shape.radius ** 2;
}

範例 4:in 操作符縮小物件型別

type Admin = { role: "admin"; access: number };
type Guest = { role: "guest"; visitCount: number };
type User = Admin | Guest;

function describe(u: User) {
  if ("access" in u) {
    // u 為 Admin
    console.log(`管理員權限等級 ${u.access}`);
  } else {
    // u 為 Guest
    console.log(`訪客已來訪 ${u.visitCount} 次`);
  }
}

範例 5:從寬化到縮小的實務流程

let input = document.getElementById("num") as HTMLInputElement; // 型別被寬化為 HTMLElement
if (input instanceof HTMLInputElement) {
  // 縮小為 HTMLInputElement,安全取得 value
  const value = Number(input.value);
  console.log(`輸入的數字是 ${value}`);
}

常見陷阱與最佳實踐

陷阱 說明 解決方式
過度寬化導致失去字面值資訊 使用 letvar 時,字面值會自動變成寬泛型別,導致後續無法利用字面值的安全性。 需要保留字面值時,改用 constas const
縮小後仍被視為寬泛型別 某些條件(如 typeof x === "object")只能縮小到 object,無法辨識更具體的介面。 搭配 in、自訂型別守護或 instanceof 提供更精細的縮小。
使用 any 破壞縮小機制 若變數被宣告為 any,TypeScript 會放棄所有型別檢查,縮小也失效。 盡量避免 any,改用 unknown 並在需要時手動縮小。
函式重載與寬化衝突 重載簽名過於寬泛會讓編譯器無法正確推斷返回型別。 為每個重載提供具體的參數型別,並在實作中使用縮小。
陣列/物件的可變性 直接把 let arr = [] 推斷為 any[],容易產生錯誤。 指定初始型別或使用 as const 讓陣列保持只讀。

最佳實踐

  1. 先寬化,再縮小:在變數宣告時允許寬化,提高彈性;在使用時透過型別守護縮小,確保安全。
  2. 盡量使用 const:保留字面值型別,減少不必要的寬化。
  3. 利用 as const:對不可變的資料結構(如設定檔、列舉)凍結型別。
  4. 寫自訂型別守護:對於複雜聯合型別,提供明確的 isX 函式,讓編譯器能正確縮小。
  5. 避免 any:若必須使用,改用 unknown,配合縮小後再斷言。

實際應用場景

  1. 表單驗證
    • 使用 HTMLInputElement 的寬化 (HTMLElement) 取得元素,透過 instanceof 縮小後安全存取 value
  2. API 回傳資料的型別守護
    • 從後端取得的 JSON 可能是多種形態,使用 in 或自訂型別守護判斷屬性是否存在,縮小為正確的介面再進行業務邏輯。
  3. Redux / Zustand 狀態管理
    • 狀態常以聯合型別表示(Loading | Success<T> | Failure),在 UI 渲染時透過 switchif 判斷 status,縮小型別後直接使用對應屬性。
  4. 函式重載與多態
    • 例如 function format(input: string): string; function format(input: number): string;,在實作中先寬化為 string | number,再用 typeof 縮小分別處理。
  5. 設定檔與常數列舉
    • 使用 as const 定義不可變的設定物件,讓後續的函式只能接受列舉中明確的字面值,提升編譯期安全性。

總結

  • Type Widening 讓變數在宣告時自動擴展為寬泛型別,減少冗餘的型別標註;
  • Type Narrowing 則在程式的控制流程中把寬泛型別縮小為具體型別,使得屬性存取與方法呼叫更安全。
  • 兩者配合使用,是 TypeScript 型別系統中最實用的技巧之一,能在保持彈性的同時,提供強大的編譯期檢查。

掌握 寬化 → 縮小 的思考模式,並善用 constas constinstanceoftypeofin 以及自訂型別守護,你就能寫出 既安全又易維護 的 TypeScript 程式碼。祝你在日常開發中玩得開心、寫得更好!