本节目标:读完这一节,你能写出泛型接口与泛型类,并说清类型参数在接口、类、方法三个层级上各自的作用域;能解释「静态成员不能用类的类型参数」这条限制背后的原因;能在「类级类型参数」与「方法级类型参数」之间做出正确选择;能用泛型把仓储、事件总线、链式构造器这三类常见抽象写得不失类型安全。
8.3 泛型在接口·类·函数中的协同
前两节我们把泛型讲成了「函数上的工具」:约束怎么加、默认值怎么给、推断怎么发生。但在真实项目里,泛型最出彩的地方其实是抽象的设计——一个泛型接口定义契约,一个泛型类实现它,若干个泛型方法把它缝起来。
这一节要回答的核心问题是:类型参数可以声明在哪几个层级,各层级的可见范围是什么,以及什么时候该把它放在哪一层。
泛型接口
接口的类型参数写在接口名之后,作用范围是整个接口体:
interface Box<T> {
value: T;
map<U>(fn: (value: T) => U): Box<U>;
}
interface Repository<T, ID = string> {
findById(id: ID): Promise<T | null>;
save(entity: T): Promise<T>;
}
注意 Box 里的 map:它是方法级类型参数 U,只在 map 的签名内可见;而 T 是接口级的,整个接口体都能用。这两层作用域的区别是理解泛型接口的关键:
| 声明位置 | 可见范围 | 由谁决定 |
|---|---|---|
interface Box<T> | 接口体内全部成员 | 使用 Box 的人 |
map<U>(...) | 仅该方法签名 | 调用 map 的人,且每次调用可不同 |
所以 Box<number> 的 T 固定为 number,但每次调用 map 都可以给出不同的 U:
declare const box: Box<number>;
const s = box.map((n) => n.toString()); // Box<string>
const b = box.map((n) => n > 0); // Box<boolean>
接口的类型参数同样支持约束与默认值,写法与函数完全一致。比如 interface Cache<K extends string | number, V = unknown> 把键限定成字面量联合,就实现了「缓存只有合法键」的约束——cache.set("a", 1) 通过,而 cache.set("c", 1) 会报 '"c"' 不满足 'K' 的约束。
泛型接口 vs 泛型类型别名
两者在多数场景下可以互换,但有两条实际差异值得记住。
第一,类型别名能表达接口不能表达的类型。 联合、元组、条件类型只能用 type:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
type Async<T> = Promise<T>;
type Dict<T> = Record<string, T>;
第二,接口可以声明合并,别名不行。 两个同名 interface ApiMap 会合并成员,这让库使用方可以继续补充类型;而 type 重复声明会直接报 Duplicate identifier。选择标准:描述对象形状用 interface,需要联合/条件/映射类型用 type。这与 5.2 type 别名、联合与交叉
的结论一致,只是多了一层「带类型参数」的维度。
泛型类
类上的类型参数写在类名之后,作用范围是整个类体,包括属性、方法、构造函数:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
}
const s = new Stack<number>();
s.push(1);
s.push("a");
// ❌ Argument of type 'string' is not assignable to parameter of type 'number'.
类的类型参数也可以有约束,语法位置与函数相同:
class EntityStore<T extends { id: string }> {
private map = new Map<string, T>();
add(entity: T): void {
this.map.set(entity.id, entity); // ✅ T 一定有 id
}
get(id: string): T | undefined {
return this.map.get(id);
}
}
泛型类在继承时要给出实参,或者把类型参数继续传递下去:
class TimestampedStore<T extends { id: string }> extends EntityStore<T> {} // 继续传递 T
class UserStore extends EntityStore<User> {} // 写死具体类型
子类如果不继续声明 <T>,就必须写死。这两条路在工程里都很常见——抽象基类传递类型参数,具体实现类写死。
静态成员不能用类的类型参数
这是泛型类唯一一条让新手困惑的硬性限制:
class Container<T> {
static defaultValue: T;
// ❌ Static members cannot reference class type parameters.
}
原因其实很直观:类的类型参数属于「实例」,而静态成员属于「类本身」。Container<T> 在实例化时才有具体的 T,而 Container.create() 是直接在类上调用的,那一刻根本没有 T 可绑定。用一个还不存在的类型参数去标注静态成员,编译器的处境就像一个「不知道要装什么就要求配好盒子」的订单。
解决办法是给静态方法自己声明类型参数:
class Container<T> {
private constructor(public readonly value: T) {}
static of<U>(value: U): Container<U> {
return new Container(value);
}
}
const c = Container.of("hello"); // Container<string>
注意 Container.of<U> 里的 U 与类的 T 没有任何关系——它是一个全新的、方法自己的类型参数。这个模式在标准库里随处可见(Array.of、Promise.resolve),配合 private constructor 还能实现「只能通过工厂方法创建」的设计。
泛型方法
方法可以自己声明类型参数,也可以复用类的类型参数,甚至可以两者混用:
class Collection<T> {
private items: T[] = [];
add(item: T): this { // 1. 只用类的类型参数
this.items.push(item);
return this;
}
map<U>(fn: (item: T) => U): Collection<U> { // 2. 把 T 映射成方法自己的 U
const c = new Collection<U>();
c.items = this.items.map(fn);
return c;
}
sortBy<K extends keyof T>(key: K): this { // 3. 约束跨两个作用域
this.items.sort((a, b) => (a[key] > b[key] ? 1 : -1));
return this;
}
}
第 3 个方法把上一节的 K extends keyof T 用在了类里:T 来自类、K 来自方法——约束表达式可以跨越两个作用域,这是泛型协同最直观的例子。
const users = new Collection<{ name: string; age: number }>();
users.sortBy("age"); // ✅
users.sortBy("email"); // ❌ '"email"' 不满足 'keyof T' 的约束
类级还是方法级:怎么选
这是本节最实用的一个判断标准。类型参数放在不同层级,语义差别很大:
| 维度 | 类级类型参数 class Box<T> | 方法级类型参数 map<U>() |
|---|---|---|
| 绑定时机 | 构造实例时确定,此后不变 | 每次调用独立确定 |
| 能否跨方法共享 | 能,所有方法看到同一个 T | 不能,只在本次调用内有效 |
| 存储到属性 | 可以 | 不可以 |
| 静态成员 | 不能用 | 可以用 |
判断规则可以浓缩成一句话:「这个类型需要被存下来吗?」
- 需要存进属性、在多个方法间流转 → 放类级。
- 只是本次调用的输入输出关系,调用完就丢 → 放方法级。
反过来判断也成立:如果一个类型参数只在某个方法里出现过一次、也没存进属性,那它放在类级就是多余的——它会让使用者被迫提前指定一个本该由方法自动推断的类型。
// ❌ T 只在一个方法里用,却放到了类级,使用者被迫写 Box<string> 才能调用
class BadBox<T> {
wrap(value: T): T[] { return [value]; }
}
// ✅ 放到方法级,调用时自动推断
class GoodBox {
wrap<T>(value: T): T[] { return [value]; }
}
new GoodBox().wrap(42); // T = number,无需任何手写
实战一:类型安全仓储
把接口与类拼起来,写一个真实项目里的通用仓储:
interface Identifiable {
id: string;
}
interface Repository<T extends Identifiable> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
remove(id: string): Promise<boolean>;
}
class InMemoryRepository<T extends Identifiable> implements Repository<T> {
private store = new Map<string, T>();
constructor(private readonly clone: (entity: T) => T = (e) => structuredClone(e)) {}
async findById(id: string): Promise<T | null> {
const found = this.store.get(id);
return found ? this.clone(found) : null;
}
async findAll(): Promise<T[]> {
return [...this.store.values()].map(this.clone);
}
async save(entity: T): Promise<T> {
this.store.set(entity.id, this.clone(entity));
return entity;
}
async remove(id: string): Promise<boolean> {
return this.store.delete(id);
}
}
这里有三个设计点值得说明:
其一,implements Repository<T> 把接口的类型参数原样传下去。 实现类可以自由增加约束,但至少要和接口声明的一致。
其二,constructor(private readonly clone: (entity: T) => T = ...) 是参数属性 + 默认值 + 函数类型的组合。 参数属性是 6.1 类、访问修饰符与参数属性
讲过的语法,这里额外给了默认实现,所以调用方可以完全不管它。
其三,返回值做了拷贝。 仓储不应该把内部对象的引用泄露出去,否则调用者改一下返回值就把「数据库」改了。类型系统对此无能为力,但它是泛型类设计里必须考虑的一环。
使用起来类型信息全程保留:new InMemoryRepository<User>().findById("1") 的返回类型是 User | null 而不是 any,所以 if (u) u.email.toUpperCase() 能直接通过编译。
实战二:泛型事件总线
事件系统是「用类型参数描述事件名到载荷的映射」的经典场景:
class EventBus<Events extends Record<string, unknown>> {
private handlers: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
(this.handlers[event] ??= []).push(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.handlers[event]?.forEach((h) => h(payload));
}
}
关键在 emit 的签名:event: K 与 payload: Events[K] 被 K 绑在一起,于是事件名决定了载荷类型:
interface AppEvents {
"user:login": { userId: string };
"cart:add": { sku: string; qty: number };
"app:ready": void;
}
const bus = new EventBus<AppEvents>();
bus.on("cart:add", ({ sku, qty }) => console.log(sku, qty)); // 参数自动推断
bus.emit("cart:add", { sku: "A-1", qty: 2 }); // ✅
bus.emit("cart:add", { sku: "A-1" }); // ❌ 缺 qty
bus.emit("user:login", { sku: "A-1", qty: 2 }); // ❌ 载荷形状不对
bus.emit("order:paid", { orderId: "o-1" }); // ❌ 事件名不在表里
注意 private handlers 用了映射类型 { [K in keyof Events]?: ... }——这是 9.2 映射类型与键重映射(as)
的主角,此处只需感受它「按事件表生成对应结构」的效果。这个模式比手写重载可维护得多:新增一个事件只需要往 AppEvents 加一行。想了解事件系统的更多工程细节,可延伸阅读站内的 TypeScript 类型安全的事件与流
。
实战三:链式 Builder
泛型类配合返回 this,可以做出「链式调用且类型随步骤收窄」的构造器:
class QueryBuilder<T, Selected extends keyof T = never> {
private fields: Selected[] = [];
constructor(private readonly table: string) {}
select<K extends keyof T>(...keys: K[]): QueryBuilder<T, Selected | K> {
const next = new QueryBuilder<T, Selected | K>(this.table);
next.fields = [...this.fields, ...keys] as Array<Selected | K>;
return next;
}
build(): string {
const cols = this.fields.length ? this.fields.join(", ") : "*";
return `SELECT ${cols} FROM ${this.table}`;
}
}
interface User { id: string; name: string; email: string }
new QueryBuilder<User>("users").select("id", "name").build();
// SELECT id, name FROM users
QueryBuilder<T, Selected | K> 把「已经选了哪些字段」记在第二个类型参数里,因此链式调用可以无限延长而类型不丢。这是类型级状态机的雏形,也是 ORM 类型(如 Prisma、Drizzle)能给出精确返回类型的基础原理。想看得更远,可延伸阅读站内的 TypeScript ORM 与数据访问
与 TypeScript 设计模式实践
。
常见报错速查
| 报错信息 | 含义 | 修法 |
|---|---|---|
Static members cannot reference class type parameters | 静态成员用了类的类型参数 | 给静态方法自己声明类型参数 |
Generic type 'Box' requires 1 type argument(s) | 泛型类/接口没给实参也没默认值 | 补实参或加默认值 |
Type 'X' does not satisfy the constraint 'Identifiable' | 实参类型缺了约束要求的成员 | 补上缺失属性 |
Property 'id' does not exist on type 'T' | 实现类忘了把约束写全 | 给 T 补 extends Identifiable |
Class 'X' incorrectly implements interface 'Y' | 实现签名与接口不符 | 对齐方法签名 |
'this' implicitly has type 'any' | 返回 this 的方法被当成普通函数传出 | 用箭头属性或显式 this 参数 |
最后一条在链式 API 里偶尔出现:如果把方法直接解构出来单独调用(const { add } = collection; add(1)),this 就丢了。返回 this 做链式调用时,方法不要解构使用。
泛型的边界
最后提醒一个判断:不是所有「重复」都该用泛型消除。如果两个函数除了类型之外行为也不同,硬抽成泛型只会得到一堆 if (typeof x === "string"),那是把类型系统的负担转嫁给了运行时。泛型适合表达「同样的逻辑,不同的类型」;一旦逻辑本身分叉,接口或重载往往是更好的选择。
小结
这一节我们把泛型从函数扩展到了完整的类型抽象:
- 类型参数可以声明在接口级、类级、方法级三个层级;接口级/类级由使用者一次性确定,方法级由每次调用独立确定。
- 泛型接口与泛型类型别名各有主场:对象形状用
interface(还能声明合并),联合/条件/映射用type。 - 静态成员不能引用类的类型参数,因为静态成员属于类本身、而类型参数属于实例;解法是让静态方法自带类型参数。
- 「类级还是方法级」的判断标准是「这个类型需要被存下来吗」;不存储的类型参数放类级是多余负担。
- 仓储、事件总线、链式 Builder 是泛型接口 + 泛型类 + 泛型方法协同的三大典型场景,共同套路都是「用一个类型参数把输入和输出绑在一起」。
- 泛型表达「同样的逻辑,不同的类型」;逻辑本身分叉时,接口或重载更合适。
到这里,泛型的三块拼图——约束、默认值与推断、跨层协同——就补齐了。不过你可能已经注意到,本节里反复出现的 keyof T、T[K]、{ [K in keyof T] } 其实都属于另一个更大的话题:类型层面的运算。下一章 9.1 keyof·typeof 与索引访问类型
会正式进入类型级编程,把「用值算类型」这件事讲成一套可组合的方法论。
阅读导航:上一节:8.2 泛型默认值与类型推断 · 下一节:9.1 keyof·typeof 与索引访问类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。