TypeScript type guard narrowing discriminated union

[TypeScript] 型別守衛與窄化

unionunknown 在型別系統裡好用,但一要呼叫方法,TS 就會逼你「先確定它是什麼」。這篇收齊讓 TS 在 runtime 分流時自動縮型別的工具——typeofininstanceof、自訂 type guard、asserts,以及最該優先用的 discriminated union。

所謂「窄化(narrowing)」,就是在某個程式分支裡,TS 把變數的型別從廣的縮小到具體的某一種。

typeof 守衛

最基本的一招,拿來分原始型別:

function pad(value: string | number) {
  if (typeof value === 'number') {
    return value.toFixed(2)   // value: number
  }
  return value.padStart(5)    // value: string
}

typeof 的可能回傳值是 JS 規定的那幾種:'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function'

注意陣列、null、物件的 typeof 全是 'object',靠它分不出來。

truthiness 守衛

直接拿值當條件,也能窄化:

function f(v: string | null | undefined) {
  if (v) {
    v.toUpperCase()   // v: string
  }
}

陷阱在於空字串、0NaNfalse 都是 falsy,會一併被排除。只想擋 null / undefined 的話,改用 == null??

if (v != null) { /* string */ }   // 同時排掉 null 和 undefined

equality 守衛

兩個變數互相比較,TS 會把雙方都窄化到型別的交集:

function f(a: string | number, b: string | boolean) {
  if (a === b) {
    // 兩邊型別交集是 string,所以 a, b 都被窄化為 string
    a.toUpperCase()
    b.toUpperCase()
  }
}

跟字面值比較,就窄到那個字面值:

function f(s: 'a' | 'b' | 'c') {
  if (s === 'a') {
    // s: 'a'
  } else {
    // s: 'b' | 'c'
  }
}

in 守衛

用「有沒有某個屬性」來分物件:

type Fish = { swim: () => void }
type Bird = { fly: () => void }

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    animal.swim()       // Fish
  } else {
    animal.fly()        // Bird
  }
}

instanceof 守衛

function logDate(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toISOString())   // Date
  } else {
    console.log(x.toUpperCase())   // string
  }
}

只能用在有 constructor 的型別(class、Error、Date 等);純物件字面量沾不上邊。

discriminated union(標籤聯合)

最強大、也最該優先用的窄化模式:讓每個成員帶一個共同的「辨識欄位」(discriminator):

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number }
  | { kind: 'rect'; w: number; h: number }

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2
    case 'square': return s.size ** 2
    case 'rect':   return s.w * s.h
  }
}

s.kind 是字面值 union,TS 在每個 case 裡自動把 s 窄化到對應的分支,連自訂守衛都不用寫。

加上 never 做窮舉檢查

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2
    case 'square': return s.size ** 2
    case 'rect':   return s.w * s.h
    default:
      const _: never = s   // 漏掉 case 這裡會編譯錯
      return _
  }
}

好處很實際:之後新增一個 Shape 變體卻忘了更新 area,TS 會直接在 default 分支報錯提醒你。

user-defined type guard:is 語法

內建守衛不夠用時,自己寫一個:

interface Cat { meow: () => void }
interface Dog { bark: () => void }

function isCat(x: Cat | Dog): x is Cat {
  return (x as Cat).meow !== undefined
}

function play(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.meow()    // Cat
  } else {
    animal.bark()    // Dog
  }
}

x is Cat 叫做 type predicate(型別謂詞),是 TS 獨有的語法,意思是「這個函式回 true 時,請把 x 當 Cat」。它只在型別檢查層有意義,編譯到 JS 後整個消失,剩下的就是一個回 boolean 的普通函式。

要小心的是:寫這個語法等於跟 TS 立下承諾「我回 true,x 就真的是 Cat」。TS 不會驗證實作邏輯對不對,謊報了它也照單全收——所以實作裡要誠實。

assertion function:asserts

type predicate 是回 boolean 讓你分流,assertion function 則是不對就直接 throw:

function assertString(x: unknown): asserts x is string {
  if (typeof x !== 'string') {
    throw new Error('not a string')
  }
}

function f(v: unknown) {
  assertString(v)
  v.toUpperCase()    // ✅ 這之後 v 都是 string
}

幾個要點:

  • 回傳型別寫成 asserts x is T
  • 函式必須真的會 throw,否則型別系統就被騙了
  • 不能用 arrow function(語法不支援)

型別斷言(as)vs 型別守衛

astype guard
是否實際檢查❌ 純編譯期✅ 執行期實際判斷
錯了會發生什麼執行期炸不會錯,分支邏輯正確
用在何處過渡 / 確定型別但 TS 推不出主要工具
const el = document.getElementById('app') as HTMLDivElement   // 信任,不檢查
const el2 = document.getElementById('app')
if (el2 instanceof HTMLDivElement) { /* 真的檢查 */ }

non-null 斷言(!)

const el = document.getElementById('app')!   // 告訴 TS:絕不為 null

as 一樣是逃生口,不做任何檢查。能用守衛就用守衛。

常見窄化陷阱

1. 在 callback 裡守衛失效

type Box = { value: string | undefined }

function f(box: Box) {
  if (box.value !== undefined) {
    [1, 2].forEach(() => {
      box.value.toUpperCase()   // ❌ box.value 又被當 string | undefined
    })
  }
}

callback 是一個新的 scope,TS 沒辦法保證它執行時 box.value 還沒被改過,所以窄化不帶進去。解法是先存成區域變數:

const v = box.value
if (v !== undefined) {
  [1, 2].forEach(() => v.toUpperCase())  // ✅ v 不會被別處改
}

2. 函式呼叫後窄化會「重置」

function check(x: string | null) {
  if (x !== null) {
    doSomething()
    x.toUpperCase()   // ✅ 還在窄化範圍內,但若 doSomething 修改外部狀態...
  }
}

區域變數通常沒事;物件屬性、外部變數就要小心,呼叫別的函式之後窄化可能不再成立。

3. typeof null === ‘object’

function f(x: object | null) {
  if (typeof x === 'object') {
    x.foo   // ❌ x 仍可能是 null
  }
  if (x !== null) {
    // ✅ 排掉 null 後 x: object
  }
}

JS 的老坑:typeof null 也是 'object'。null check 要自己明確寫。

控制流分析

TS 的窄化是基於控制流的,不只看 if 區塊:

function f(v: string | number) {
  if (typeof v !== 'string') {
    return            // 提早 return 窄化掉一個
  }
  v.toUpperCase()     // ✅ v: string
}

if 裡面 return 或 throw 之後,後面的程式自動被窄化——能執行到那裡,就代表前面的條件不成立。

最後一篇進到模組與 .d.ts,把跨檔案 import / export、ambient 宣告、第三方套件型別這些工程面的東西收尾。

Latest Updates

  • 2026.06.11 Content updated