TypeScript 型別收窄 (Type Narrowing)

型別收窄 (Type Narrowing) 是 TypeScript 根據程式碼的控制流程,自動將較寬的型別縮小為更精確型別的過程。這讓我們可以安全地存取特定型別的屬性和方法。

typeof 型別守衛

使用 typeof 運算子可以收窄原始型別:

function printValue(value: string | number) {
  if (typeof value === 'string') {
    // value 被收窄為 string
    console.log(value.toUpperCase());
  } else {
    // value 被收窄為 number
    console.log(value.toFixed(2));
  }
}

printValue('hello'); // HELLO
printValue(3.14159); // 3.14

typeof 可以檢查的型別:

  • "string"
  • "number"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object" (注意:null 也會回傳 "object")
  • "function"
  • "bigint"

真值收窄 (Truthiness Narrowing)

利用 JavaScript 的真值檢查來收窄型別:

function printName(name: string | null | undefined) {
  if (name) {
    // name 被收窄為 string
    console.log(name.toUpperCase());
  } else {
    console.log('沒有名字');
  }
}

// 使用邏輯運算子
function getLength(value: string | null): number {
  return value?.length ?? 0;
}
假值 (falsy values) 包括:false0""nullundefinedNaN。空字串 "" 也是假值,所以用真值檢查時要注意。

相等收窄 (Equality Narrowing)

使用 ===!====!= 進行比較:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // x 和 y 都被收窄為 string(唯一共同的型別)
    console.log(x.toUpperCase());
    console.log(y.toUpperCase());
  }
}

// 檢查 null
function process(value: string | null) {
  if (value !== null) {
    // value 被收窄為 string
    console.log(value.length);
  }
}

in 運算子收窄

使用 in 運算子檢查物件是否有某個屬性:

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish) {
  if ('fly' in animal) {
    // animal 被收窄為 Bird
    animal.fly();
  } else {
    // animal 被收窄為 Fish
    animal.swim();
  }
}

instanceof 運算子收窄

使用 instanceof 檢查建構函式:

function logDate(date: Date | string) {
  if (date instanceof Date) {
    // date 被收窄為 Date
    console.log(date.toISOString());
  } else {
    // date 被收窄為 string
    console.log(new Date(date).toISOString());
  }
}

// 自訂類別也可以
class Cat {
  meow() {
    console.log('喵~');
  }
}

class Dog {
  bark() {
    console.log('汪!');
  }
}

function makeSound(animal: Cat | Dog) {
  if (animal instanceof Cat) {
    animal.meow();
  } else {
    animal.bark();
  }
}

指派收窄 (Assignment Narrowing)

TypeScript 會根據指派的值來收窄型別:

let value: string | number;

value = 'hello';
// 這裡 value 的型別是 string
console.log(value.toUpperCase());

value = 42;
// 這裡 value 的型別是 number
console.log(value.toFixed(2));

控制流程分析

TypeScript 會追蹤程式的控制流程:

function example(value: string | number | null) {
  if (value === null) {
    // value 是 null
    return;
  }

  // value 是 string | number(null 被排除了)

  if (typeof value === 'string') {
    // value 是 string
    console.log(value.length);
    return;
  }

  // value 是 number(string 被排除了)
  console.log(value * 2);
}

死碼分析

function example(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase());
    return;
  }

  // 這裡 value 必定是 number
  console.log(value.toFixed(2));

  // 以下程式碼永遠不會執行
  // TypeScript 知道這一點
}

可辨識聯合 (Discriminated Unions)

透過共同的字面量屬性來收窄型別:

interface Circle {
  kind: 'circle';
  radius: number;
}

interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}

interface Triangle {
  kind: 'triangle';
  base: number;
  height: number;
}

type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      // shape 被收窄為 Circle
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      // shape 被收窄為 Rectangle
      return shape.width * shape.height;
    case 'triangle':
      // shape 被收窄為 Triangle
      return (shape.base * shape.height) / 2;
  }
}

never 型別與窮盡檢查

使用 never 確保處理了所有情況:

type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      // 如果 Shape 新增了型別但這裡沒處理,會報錯
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

陣列收窄

function processArray(arr: string[] | number[]) {
  // 無法直接存取,因為可能是 string[] 或 number[]

  if (arr.length > 0) {
    const first = arr[0];

    if (typeof first === 'string') {
      // arr 被收窄為 string[]
      arr.forEach((item) => console.log(item.toUpperCase()));
    } else {
      // arr 被收窄為 number[]
      arr.forEach((item) => console.log(item.toFixed(2)));
    }
  }
}

常見陷阱

typeof null

function example(value: object | null) {
  // 錯誤的方式:typeof null === "object"
  if (typeof value === 'object') {
    // value 仍然可能是 null!
    // value.toString();  // 可能報錯
  }

  // 正確的方式
  if (value !== null) {
    value.toString(); // OK
  }
}

陣列與物件

function example(value: object | unknown[]) {
  // typeof 對陣列和物件都回傳 "object"
  if (typeof value === 'object') {
    // 無法區分陣列和物件
  }

  // 使用 Array.isArray
  if (Array.isArray(value)) {
    // value 是 unknown[]
    console.log(value.length);
  }
}

實用範例

處理 API 回應

type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function handleResponse<T>(response: ApiResponse<T>): T | null {
  switch (response.status) {
    case 'success':
      return response.data;
    case 'error':
      console.error(response.message);
      return null;
    case 'loading':
      console.log('載入中...');
      return null;
  }
}

表單值處理

type FormValue = string | number | boolean | null;

function formatFormValue(value: FormValue): string {
  if (value === null) {
    return '';
  }

  if (typeof value === 'boolean') {
    return value ? '是' : '否';
  }

  if (typeof value === 'number') {
    return value.toString();
  }

  // value 是 string
  return value;
}

事件處理

function handleEvent(event: MouseEvent | KeyboardEvent) {
  if (event instanceof MouseEvent) {
    console.log(`滑鼠點擊位置: (${event.clientX}, ${event.clientY})`);
  } else {
    console.log(`按鍵: ${event.key}`);
  }
}