TypeScript 泛型的实际例子

1. 概述

泛型是一种工具,可以帮助你写出可重用的代码,同时保持类型安全。与其重复编写 UserRepository、OrderRepository、ProductRepository,你可以写一个通用的 Repository<T>。本文将通过实际例子介绍:泛型函数、泛型类、约束、多个类型参数,以及如何与 utility types 结合。读完本文后,你将能够自信地在你的 TypeScript 项目中的 service 层、repository 层和 API response 中应用泛型。

2. 什么是泛型及为什么需要它

泛型允许你“参数化”类型,就像函数接收参数一样,但这是在类型级别上。

// 不使用泛型:必须为每个类型重新编写
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// 使用泛型:一个函数适用于所有类型
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string,自动推断
const age = first([30, 25]);          // T = number

<T> 是类型参数——一个变量,代表调用时实际传入的类型。TypeScript 根据参数自动推断 (infer) T,你不需要手动指定。

注意: T、U、K、V 只是命名约定,没有特殊含义。对于大型代码库,使用更有意义的名称如 TItem、TResponse 会更易读。

3. 实际中的泛型函数:groupBy 函数

一个常见的数据处理例子:按一个字段对对象进行分组。

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T 是数组中项的类型(Order)。
  • K extends string | number 限制 key 只能是 string 或 number——因为对象的 key 不能是其他类型。
  • 结果 byStatus 的类型是 Record<"pending" | "paid" | "cancelled", Order[]>,TypeScript 确切知道可能出现的 key。
byStatus.paid       -> Order[] (2 个元素)
byStatus.pending    -> Order[] (1 个元素)
byStatus.cancelled  -> Order[] (如果使用 strict 可能是 undefined)

4. 使用 extends 的泛型约束

Constraint 限制 T 必须满足某种形状,帮助你在泛型函数内安全地访问属性。

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

如果没有 extends HasId,item.id 这一行会报错,因为 TypeScript 不知道 T 是否有 id 属性。

error TS2339: Property 'id' does not exist on type 'T'.

这是学习泛型时最常见的错误:忘记添加约束,然后任意访问 T 上的属性。

5. 泛型类:Repository<T> 示例

这是一个经典的用例——一个通用的 repository,用于多个实体。

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "键盘", price: 500_000 });
productRepo.save({ id: 2, name: "鼠标", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> 确保所有被管理的实体都有 id,所以 Map<number, T> 使用 id 作为 key 是安全的。
  • 当声明 new Repository<Product>() 时,所有方法的返回类型都恰好是 Product——IDE 会自动完成 name 和 price。

注意: 如果你在整个项目中只为一个实体使用 Repository,泛型就是过度设计。应该先编写具体的类,只有当有 ≥ 2 个地方共享相同逻辑时才进行泛型化。

6. 多个类型参数:ApiResponse<TData, TError> 示例

当需要参数化多个类型时,你可以同时使用多个类型参数。

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "未找到用户",
});

TError = string 在接口声明行是默认泛型类型——如果你不传递第二个类型,TypeScript 会自动使用 string。这就是为什么 ApiResponse<User> 仍然有效,尽管只有 1 个类型参数。

7. 将泛型与 utility types 结合

诸如 Partial、Pick、Omit 的 Utility types 实际上也是在 TypeScript 中预定义的泛型类型。将它们与你自己的泛型结合起来对更新函数非常有效。

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">> 的意思是:获取 T 的所有字段除了 id,然后将所有这些字段变为可选。
  • 因此,updateEntity 不允许修改 id,调用者只需传递想要更改的字段——非常像 REST API 中的 PATCH 模式。

如果尝试将 { id: 2 } 传递给 patch,TypeScript 会立即报错,因为 id 已被 Omit 从允许的类型中排除:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. 泛型与条件类型:何时需要 infer

更进阶的一步:自动获取函数的返回类型,避免重复手写。

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer,不是 Promise<Customer>
  • infer U 让 TypeScript “猜测” Promise<...> 内的类型并将其分配给 U。
  • 你不需要提前知道 fetchCustomer 返回什么——类型是自动推断的,如果 fetchCustomer 的签名改变,FetchResult 会自动更新。

注意: 带有 infer 的 Conditional type 非常强大,但容易使代码难以阅读。只在你需要多次重用的 type utility(如 UnwrapPromise)时使用,不应该滥用于一次性逻辑。

9. 为泛型函数编写测试

泛型影响编译时的类型检查,但运行时的行为仍然需要像往常一样进行测试。安装 Vitest:

<!-- package.json (dependencies 部分,不是 pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("保存和按 id 查找正确的 product", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "键盘" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("键盘");
  });

  it("当 id 不存在时返回 undefined", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

运行 npx vitest run,预期结果:

✓ Repository<Product> > 保存和按 id 查找正确的 product
✓ Repository<Product> > 当 id 不存在时返回 undefined

Test Files  1 passed (1)
     Tests  2 passed (2)

此测试不检查类型(TypeScript 编译器已在构建时完成该工作),而是检查泛型类的运行时逻辑——确保 Map 无论 T 是什么都能正常工作。

10. 常见错误和何时不应使用泛型

  • 过度泛型化 (over-generic): 创建 Repository<T> 但整个项目只有一个实体。此时具体的类 CustomerRepository 更易读且更易维护。
  • 忘记约束: 写 function getProp<T>(obj: T, key: string) 然后访问 obj[key] 会导致类型 any 或索引签名错误。应该使用 <T, K extends keyof T>(obj: T, key: K): T[K]。
  • 使用 any 而不是泛型: function identity(x: any): any 会完全丧失类型安全——TypeScript 不会报任何错误。总是优先使用 <T>(x: T): T。
  • 泛型类型参数过多: 如果函数有 4-5 个类型参数,代码会非常难读。考虑将各种类型组合成单一的配置接口。
// 难以理解
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// 更易读
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

注意: 如果一个类型参数在整个函数签名中只被使用一次,你很可能不需要泛型——一个具体的类型或 unknown 就足够了。

11. 总结

泛型可以帮助你写出可重用的代码,而不牺牲类型安全:一个 Repository<T>、一个 ApiResponse<TData, TError> 代替数十个重复的类/接口。关键是掌握三点——基本的类型参数、带 extends 的约束,以及如何与现有的 utility types(Partial、Omit、Pick)结合。不要从一开始就泛型化一切;只有当你在代码库中至少两个不同的地方看到逻辑重复时,才提取泛型。

typescriptgenericsjavascripttype-safetyfrontend