《TypeScript编程入门》8.3 泛型在接口·类·函数中的协同

本节把泛型从函数扩展到接口与类,讲清三者各自声明类型参数的位置与作用域,解释为什么静态成员不能用类的类型参数,并给出「类级类型参数还是方法级类型参数」的选择标准。随后用类型安全仓储、泛型事件总线、链式 Builder 三个实战把接口、类、函数串起来,配常见报错速查,完成从会用泛型到会设计泛型抽象的过渡。

本节目标:读完这一节,你能写出泛型接口与泛型类,并说清类型参数在接口、类、方法三个层级上各自的作用域;能解释「静态成员不能用类的类型参数」这条限制背后的原因;能在「类级类型参数」与「方法级类型参数」之间做出正确选择;能用泛型把仓储、事件总线、链式构造器这三类常见抽象写得不失类型安全。

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 与索引访问类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes