TypeScript 型別守衛 (Type Guards)

型別守衛 (Type Guards) 是用來在執行時期檢查型別,讓 TypeScript 能夠收窄變數的型別。除了內建的 typeofinstanceofin 運算子外,我們還可以建立自訂的型別守衛。

自訂型別守衛函式

使用 is 關鍵字定義型別謂詞 (type predicate):

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

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

// 自訂型別守衛
function isFish(animal: Bird | Fish): animal is Fish {
  return (animal as Fish).swim !== undefined;
}

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

型別謂詞語法

function isType(value: unknown): value is SpecificType {
  // 回傳 boolean,但 TypeScript 會根據結果收窄型別
  return /* 型別檢查邏輯 */;
}
  • value 是要檢查的參數
  • value is SpecificType 是型別謂詞
  • 函式必須回傳 boolean

常見的型別守衛模式

檢查 null 和 undefined

function isNotNull<T>(value: T | null): value is T {
  return value !== null;
}

function isNotUndefined<T>(value: T | undefined): value is T {
  return value !== undefined;
}

function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

// 使用
const items = ['a', null, 'b', undefined, 'c'];
const validItems = items.filter(isDefined); // string[]

檢查陣列

function isArray<T>(value: T | T[]): value is T[] {
  return Array.isArray(value);
}

function isNonEmptyArray<T>(value: T[]): value is [T, ...T[]] {
  return value.length > 0;
}

// 使用
function process(value: string | string[]) {
  if (isArray(value)) {
    value.forEach((item) => console.log(item));
  } else {
    console.log(value);
  }
}

檢查物件屬性

function hasProperty<T extends object, K extends string>(
  obj: T,
  key: K
): obj is T & Record<K, unknown> {
  return key in obj;
}

// 使用
function process(obj: object) {
  if (hasProperty(obj, 'name')) {
    console.log(obj.name); // OK
  }
}

類別的型別守衛

class Cat {
  meow() {
    console.log('喵~');
  }
}

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

function isCat(animal: Cat | Dog): animal is Cat {
  return animal instanceof Cat;
}

function isDog(animal: Cat | Dog): animal is Dog {
  return animal instanceof Dog;
}

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

可辨識聯合的型別守衛

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

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

type Shape = Circle | Rectangle;

function isCircle(shape: Shape): shape is Circle {
  return shape.kind === 'circle';
}

function isRectangle(shape: Shape): shape is Rectangle {
  return shape.kind === 'rectangle';
}

// 泛型版本
function isOfKind<T extends Shape, K extends T['kind']>(
  shape: T,
  kind: K
): shape is Extract<T, { kind: K }> {
  return shape.kind === kind;
}

斷言函式 (Assertion Functions)

TypeScript 3.7+ 支援斷言函式,用 asserts 關鍵字:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function assertIsDefined<T>(value: T | null | undefined): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error('Value is null or undefined');
  }
}

// 使用
function process(value: unknown) {
  assertIsString(value);
  // 如果沒有拋出例外,value 被收窄為 string
  console.log(value.toUpperCase());
}

條件斷言

function assert(condition: boolean, message: string): asserts condition {
  if (!condition) {
    throw new Error(message);
  }
}

function process(value: string | null) {
  assert(value !== null, 'Value cannot be null');
  // value 被收窄為 string
  console.log(value.length);
}

泛型型別守衛

// 檢查值是否為特定型別
function isOfType<T>(value: unknown, check: (value: unknown) => boolean): value is T {
  return check(value);
}

// 檢查物件是否符合介面
function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

// 建立型別守衛工廠
function createTypeGuard<T>(check: (value: unknown) => boolean): (value: unknown) => value is T {
  return (value: unknown): value is T => check(value);
}

const isNumber = createTypeGuard<number>((v) => typeof v === 'number');
const isString = createTypeGuard<string>((v) => typeof v === 'string');

組合型別守衛

// AND 組合
function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
}

// OR 組合
function isStringOrNumber(value: unknown): value is string | number {
  return typeof value === 'string' || typeof value === 'number';
}

// 複雜物件
interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value &&
    'email' in value &&
    typeof (value as User).id === 'number' &&
    typeof (value as User).name === 'string' &&
    typeof (value as User).email === 'string'
  );
}

實用範例

API 資料驗證

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

function isSuccessResponse<T>(
  response: ApiResponse<T>
): response is ApiResponse<T> & { success: true; data: T } {
  return response.success === true && response.data !== undefined;
}

function isErrorResponse<T>(
  response: ApiResponse<T>
): response is ApiResponse<T> & { success: false; error: string } {
  return response.success === false && response.error !== undefined;
}

async function fetchUser(): Promise<ApiResponse<User>> {
  // ...
}

async function handleFetch() {
  const response = await fetchUser();

  if (isSuccessResponse(response)) {
    console.log(response.data.name); // 安全存取
  } else if (isErrorResponse(response)) {
    console.error(response.error); // 安全存取
  }
}

表單資料處理

interface TextInput {
  type: 'text';
  value: string;
  maxLength?: number;
}

interface NumberInput {
  type: 'number';
  value: number;
  min?: number;
  max?: number;
}

interface SelectInput {
  type: 'select';
  value: string;
  options: string[];
}

type FormInput = TextInput | NumberInput | SelectInput;

function isTextInput(input: FormInput): input is TextInput {
  return input.type === 'text';
}

function isNumberInput(input: FormInput): input is NumberInput {
  return input.type === 'number';
}

function isSelectInput(input: FormInput): input is SelectInput {
  return input.type === 'select';
}

function validateInput(input: FormInput): boolean {
  if (isTextInput(input)) {
    if (input.maxLength && input.value.length > input.maxLength) {
      return false;
    }
  } else if (isNumberInput(input)) {
    if (input.min !== undefined && input.value < input.min) {
      return false;
    }
    if (input.max !== undefined && input.value > input.max) {
      return false;
    }
  }
  return true;
}

錯誤處理

class ValidationError extends Error {
  constructor(
    public field: string,
    message: string
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NetworkError extends Error {
  constructor(
    public statusCode: number,
    message: string
  ) {
    super(message);
    this.name = 'NetworkError';
  }
}

function isValidationError(error: Error): error is ValidationError {
  return error instanceof ValidationError;
}

function isNetworkError(error: Error): error is NetworkError {
  return error instanceof NetworkError;
}

function handleError(error: Error) {
  if (isValidationError(error)) {
    console.log(`驗證錯誤 - 欄位: ${error.field}, 訊息: ${error.message}`);
  } else if (isNetworkError(error)) {
    console.log(`網路錯誤 - 狀態碼: ${error.statusCode}`);
  } else {
    console.log(`未知錯誤: ${error.message}`);
  }
}