TypeScript 聯合與交集型別 (Union and Intersection Types)

聯合型別和交集型別是 TypeScript 中用來組合型別的兩種重要方式。它們讓我們可以更靈活地定義型別。

聯合型別 (Union Types)

聯合型別表示一個值可以是多種型別之一,使用 | 符號連接:

type StringOrNumber = string | number;

let value: StringOrNumber;
value = 'hello'; // OK
value = 42; // OK
// value = true;  // 錯誤:boolean 不在聯合型別中

常見用途

處理多種輸入型別

function formatValue(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();
  }
  return value.toFixed(2);
}

console.log(formatValue('hello')); // HELLO
console.log(formatValue(3.14159)); // 3.14

可空型別

type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;

let name: Nullable<string> = '小明';
name = null; // OK

function greet(name: Optional<string>): void {
  if (name) {
    console.log(`Hello, ${name}!`);
  } else {
    console.log('Hello, stranger!');
  }
}

聯合型別的屬性存取

只能存取所有型別共有的屬性:

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

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

function getAnimal(): Bird | Fish {
  // ...
}

let pet = getAnimal();
pet.layEggs(); // OK,兩者都有
// pet.fly();   // 錯誤,Fish 沒有 fly 方法

型別收窄 (Type Narrowing)

使用型別檢查來收窄聯合型別:

function process(value: string | number | boolean): void {
  if (typeof value === 'string') {
    // value 是 string
    console.log(value.toUpperCase());
  } else if (typeof value === 'number') {
    // value 是 number
    console.log(value.toFixed(2));
  } else {
    // value 是 boolean
    console.log(value ? 'Yes' : 'No');
  }
}

可辨識聯合 (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':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return (shape.base * shape.height) / 2;
  }
}

console.log(getArea({ kind: 'circle', radius: 5 })); // 78.54...
console.log(getArea({ kind: 'rectangle', width: 4, height: 5 })); // 20

窮盡檢查 (Exhaustiveness Checking)

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

function assertNever(x: never): never {
  throw new Error('Unexpected value: ' + x);
}

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:
      // 如果遺漏了某個 case,這裡會報錯
      return assertNever(shape);
  }
}

交集型別 (Intersection Types)

交集型別將多個型別合併為一個,使用 & 符號:

type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;

let person: Person = {
  name: '小明',
  age: 25,
};

交集型別必須同時滿足所有型別的要求。

合併介面

interface Printable {
  print(): void;
}

interface Loggable {
  log(message: string): void;
}

type PrintableAndLoggable = Printable & Loggable;

class Document implements PrintableAndLoggable {
  print(): void {
    console.log('Printing...');
  }

  log(message: string): void {
    console.log(message);
  }
}

擴展物件型別

type BaseUser = {
  id: number;
  name: string;
};

type UserWithEmail = BaseUser & {
  email: string;
};

type AdminUser = UserWithEmail & {
  role: 'admin';
  permissions: string[];
};

let admin: AdminUser = {
  id: 1,
  name: '管理員',
  email: 'admin@example.com',
  role: 'admin',
  permissions: ['read', 'write', 'delete'],
};

聯合型別 vs 交集型別

特性聯合型別 (|)交集型別 (&)
語意「或」- 其中之一「且」- 同時都是
屬性存取只能存取共有屬性可存取所有屬性
物件合併不合併合併所有屬性
// 聯合型別:A 或 B
type AorB = { a: string } | { b: number };
let x: AorB = { a: 'hello' }; // OK
let y: AorB = { b: 42 }; // OK

// 交集型別:A 且 B
type AandB = { a: string } & { b: number };
let z: AandB = { a: 'hello', b: 42 }; // 必須同時有 a 和 b

原始型別的交集

原始型別的交集通常會變成 never

type StringAndNumber = string & number; // never
// 沒有值可以同時是 string 和 number

但對於物件型別,交集會合併屬性:

type A = { shared: string; a: number };
type B = { shared: string; b: boolean };
type AB = A & B;
// { shared: string; a: number; b: boolean }

屬性衝突

當交集型別中有衝突的屬性時:

type A = { value: string };
type B = { value: number };
type AB = A & B;
// { value: string & number } = { value: never }

// AB 實際上無法使用,因為 value 必須同時是 string 和 number

但如果屬性是物件型別,會遞迴合併:

type A = { info: { a: string } };
type B = { info: { b: number } };
type AB = A & B;
// { info: { a: string; b: number } }

let ab: AB = {
  info: {
    a: 'hello',
    b: 42,
  },
};

實用範例

API 回應處理

type SuccessResponse<T> = {
  success: true;
  data: T;
};

type ErrorResponse = {
  success: false;
  error: string;
  code: number;
};

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  try {
    const user = await api.get(`/users/${id}`);
    return { success: true, data: user };
  } catch (e) {
    return { success: false, error: 'User not found', code: 404 };
  }
}

async function handleUserFetch() {
  const response = await fetchUser(1);

  if (response.success) {
    // response.data 可用
    console.log(response.data.name);
  } else {
    // response.error 可用
    console.error(response.error);
  }
}

狀態機

type IdleState = { status: 'idle' };
type LoadingState = { status: 'loading' };
type SuccessState<T> = { status: 'success'; data: T };
type ErrorState = { status: 'error'; error: string };

type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;

function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'idle':
      return '等待中';
    case 'loading':
      return '載入中...';
    case 'success':
      return `成功: ${JSON.stringify(state.data)}`;
    case 'error':
      return `錯誤: ${state.error}`;
  }
}

混入模式 (Mixin Pattern)

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    timestamp = new Date();
  };
}

function Activatable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    isActive = false;

    activate() {
      this.isActive = true;
    }

    deactivate() {
      this.isActive = false;
    }
  };
}

class User {
  constructor(public name: string) {}
}

// 組合多個 mixin
const TimestampedActivatableUser = Timestamped(Activatable(User));
const user = new TimestampedActivatableUser('小明');