跳至主要內容
Skip to content

TypeScript 泛型約束與預設值

泛型非常靈活,但有時候我們需要限制它的範圍。本篇將介紹如何約束和控制泛型。


一、 為什麼需要約束?

1.1 問題場景

typescript
function getLength<T>(value: T): number {
  return value.length; // 錯誤!T 不一定有 length
}

1.2 使用 extends 約束

typescript
interface HasLength {
  length: number;
}

function getLength<T extends HasLength>(value: T): number {
  return value.length; // 現在可以了!
}

getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength({ length: 10 }); // 10
// getLength(123)      // 錯誤!number 沒有 length

二、 泛型約束

2.1 基本語法

typescript
// T 必須符合某個類型
function fn<T extends SomeType>(value: T): T {
  return value;
}

2.2 約束為物件

typescript
function printInfo<T extends { name: string }>(obj: T) {
  console.log(obj.name);
}

printInfo({ name: "John", age: 30 }); // OK
printInfo({ name: "Jane" }); // OK
// printInfo({ age: 30 })            // 錯誤!缺少 name

2.3 約束為函式

typescript
function call<T extends (...args: any[]) => any>(
  fn: T,
  ...args: Parameters<T>
): ReturnType<T> {
  return fn(...args);
}

const result = call(Math.max, 1, 2, 3); // 3

三、 keyof 運算子

3.1 基本用法

keyof 取得物件類型的所有鍵:

typescript
interface User {
  id: number;
  name: string;
  email: string;
}

type UserKeys = keyof User; // 'id' | 'name' | 'email'

3.2 搭配泛型

typescript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "John", email: "john@example.com" };

const id = getProperty(user, "id"); // number
const name = getProperty(user, "name"); // string
// getProperty(user, 'age')              // 錯誤!'age' 不存在

3.3 實作 pick 函式

typescript
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach((key) => {
    result[key] = obj[key];
  });
  return result;
}

const user = { id: 1, name: "John", email: "john@example.com" };
const partial = pick(user, ["name", "email"]);
// { name: string, email: string }

四、 多重約束

4.1 交叉約束

typescript
interface HasId {
  id: number;
}

interface HasName {
  name: string;
}

// T 必須同時有 id 和 name
function printEntity<T extends HasId & HasName>(entity: T) {
  console.log(`${entity.id}: ${entity.name}`);
}

printEntity({ id: 1, name: "John", email: "john@example.com" });

4.2 類別約束

typescript
class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  breed: string;
  constructor(name: string, breed: string) {
    super(name);
    this.breed = breed;
  }
}

function createAnimal<T extends Animal>(
  AnimalClass: new (name: string) => T,
  name: string
): T {
  return new AnimalClass(name);
}

五、 泛型預設值

5.1 基本語法

typescript
interface ApiResponse<T = unknown> {
  success: boolean;
  data: T;
}

// 使用預設值
const response1: ApiResponse = { success: true, data: null };
// data: unknown

// 指定類型
const response2: ApiResponse<User> = {
  success: true,
  data: { id: 1, name: "John" },
};
// data: User

5.2 函式泛型預設值

typescript
function createContainer<T = string>(value: T): { value: T } {
  return { value };
}

const container1 = createContainer("hello"); // { value: string }
const container2 = createContainer<number>(42); // { value: number }

5.3 多參數預設值

typescript
interface State<TData = null, TError = Error> {
  data: TData;
  error: TError | null;
  loading: boolean;
}

// 使用全部預設
const state1: State = { data: null, error: null, loading: false };

// 只指定第一個
const state2: State<User> = { data: null, error: null, loading: false };

// 指定全部
const state3: State<User, string> = { data: null, error: null, loading: false };

六、 實用模式

6.1 可選鍵

typescript
function getOptionalProperty<T, K extends keyof T>(
  obj: T,
  key: K
): T[K] | undefined {
  return obj[key];
}

6.2 部分更新

typescript
function updateObject<T extends object>(target: T, updates: Partial<T>): T {
  return { ...target, ...updates };
}

const user = { id: 1, name: "John", age: 30 };
const updated = updateObject(user, { name: "Jane" });
// { id: 1, name: 'Jane', age: 30 }

6.3 類型安全的事件發射器

typescript
type EventMap = {
  click: { x: number; y: number };
  keydown: { key: string };
};

class EventEmitter<T extends Record<string, object>> {
  private listeners: Partial<{ [K in keyof T]: ((data: T[K]) => void)[] }> = {};

  on<K extends keyof T>(event: K, listener: (data: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }

  emit<K extends keyof T>(event: K, data: T[K]) {
    this.listeners[event]?.forEach((listener) => listener(data));
  }
}

const emitter = new EventEmitter<EventMap>();
emitter.on("click", (data) => console.log(data.x, data.y));
emitter.emit("click", { x: 10, y: 20 });

總結

語法說明範例
extends類型約束T extends HasLength
keyof取得鍵keyof User
T[K]索引存取obj[key]
= Type預設值T = string

> **設計原則**:

  • 約束越精確越好
  • 善用 keyof 保證鍵安全
  • 預設值讓 API 更彈性

進階挑戰

  1. 實作一個類型安全的 omit 函式
  2. 實作一個 merge 函式,合併兩個物件並保持類型
typescript
// 練習 2 提示
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  // 實作...
}
  1. fetch 封裝一個類型安全的包裝函式

延伸閱讀與資源