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)结合。不要从一开始就泛型化一切;只有当你在代码库中至少两个不同的地方看到逻辑重复时,才提取泛型。