TypeScript • 型別推論與型別保護(Control Flow Analysis)
簡介
在 JavaScript 世界裡,變數的型別是「動態」的,程式在執行時才會決定到底是字串、數字或是物件。TypeScript 為了在編譯階段就捕捉錯誤,引入了型別推論(type inference)與型別保護(type narrowing)。其中最核心的機制就是 Control Flow Analysis(控制流程分析)——編譯器會根據程式的執行路徑,動態推斷變數的可能型別,並在適當的時候「縮窄」它們。
為什麼這麼重要?
- 提升開發效率:不必手動為每個變數寫完整的型別宣告,編譯器會自動幫你補完。
- 減少執行時錯誤:在條件分支、
switch、if內部,TypeScript 能保證你只對符合條件的型別呼叫屬性或方法。 - 增進程式可讀性:控制流程分析讓程式的意圖更清晰,未來維護或重構時也不易出現型別不一致的問題。
接下來,我們會一步步拆解「控制流程分析」的運作原理,並透過實務範例說明如何在日常開發中善加利用。
核心概念
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 或明確的類型比較。 |
最佳實踐
- 讓編譯器自行推論:除非有特殊需求,盡量省略顯式的型別註記,讓 TypeScript 透過控制流程自動推斷。
- 使用內建型別保護:
typeof、instanceof、in、真值檢查是最直接且效能最好的保護方式。 - 建立可重用的型別保護函式:對於複雜的判斷(如深層物件結構),寫成
isXxx函式,提升可讀性與一致性。 - 在
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 的控制流程分析為你自動「看門」——在每一次條件分支裡,保證變數只會是它被允許的型別。