泛型、约束、推断、默认参数与变型

第一次看到 <T>,容易觉得它是一套额外的符号规则。其实先不用记符号:想想你有没有写过两个几乎一样的函数,一个处理数字数组,另一个处理用户数组,只因为参数类型不同,就复制了一份代码。

泛型要解决的是这种重复,同时保留具体的类型信息。我们先写一个取数组首项的函数,再逐步看约束、多个类型参数和变型。后半篇稍难,可以先把前几个例子运行明白。

1. 从“取第一项”看泛型在保留什么

如果只处理字符串,可以这样写:

TypeScript
function firstName(values: readonly string[]): string | undefined { return values[0] } console.log(firstName(["Ada", "Lin"])) // Ada console.log(firstName([])) // undefined

返回类型包含 undefined,因为空数组没有第一项。现在想把同样的逻辑用在数字数组上,函数体其实一行都不用改,只需要把“字符串”换成“这次传进来的元素类型”。

TypeScript
function first<T>(values: readonly T[]): T | undefined { return values[0] } const name = first(["Ada", "Lin"]) // string | undefined const score = first([95, 88]) // number | undefined if (score !== undefined) { console.log(score.toFixed(1)) // 95.0 }

读这个函数时,把 T 暂时念成“元素类型”:参数是这种元素组成的数组,结果是同一种元素,或者在数组为空时得到 undefined。

T 不是一个特殊的内置类型,改成 Element 也一样。重要的是它同时出现在输入和输出的位置,把两者连了起来。每次调用都能根据实参推断,不是第一次传了字符串,后面就永远只能用字符串。

换成 unknown 或 any 会怎样

参数写成 readonly unknown[],返回 unknown,虽然也能取第一项,但调用者拿到分数后还得重新判断类型,原来已经知道的信息丢了。

换成 any 则更宽松:连错误的方法调用也容易被放过。泛型不是为了更宽松,而是为了在复用同一段实现时,把已知的类型关系继续传下去。

你也可能见过更短的入门例子:

TypeScript
function identity<T>(value: T): T { return value } const state = identity("ready") // 这里推断为字面量类型 "ready" const label = identity<string>("ready") // 显式指定 string

<string> 是调用时传入的类型参数。能自动推断清楚时,通常省略更好;显式指定类型不是更高级,只是适合你确实想表达不同约束的时候。

2. 输入一种类型,输出另一种类型

取第一项只需要一个类型参数。如果要把数组中的每项变成另一种值,就需要分别描述输入和输出:

TypeScript
function transformAll<Input, Output>( values: readonly Input[], transform: (value: Input) => Output, ): Output[] { return values.map(transform) } const users = [{ name: "Ada" }, { name: "Lin" }] const lengths = transformAll(users, user => user.name.length) console.log(lengths) // [3, 3],类型是 number[]

编译器先从 users 知道输入元素有 name 字段,再从回调知道每项变成了数字,最后得到 number[]。Input、Output 不需要你手工填写。

这个例子借用原生 map 是为了把注意力放在类型关系上,真实项目通常直接调用数组的 map 就够了,不必为了用泛型再封装一层。

比起一开始就写 Object.entries 加上好几个 as,数组更适合作为这个阶段的例子。对象枚举还涉及数值键变成字符串、symbol 键不被 Object.entries 枚举等规则。如果一个函数声称“保留所有键”,却只遍历字符串键,即使断言让它编译通过,承诺也可能不成立。

3. 想访问某个属性,先说明这个属性应该存在

假设你想打印输入的长度。对任意 T,编译器没理由相信它一定有 length,因为传入的可能是数字。

可以加上约束:

TypeScript
function describeLength<T extends { length: number }>(value: T): T { console.log(`长度是 ${value.length}`) return value } const word = describeLength("hello") const list = describeLength([1, 2, 3]) // describeLength(42) // number 没有要求的 length 属性

这里的 extends 不是类继承。它是在要求:传入的类型至少要符合 { length: number } 这个结构。

函数返回的仍然是 T,所以传入数组时,返回值继续保留数组类型,而不是只剩下一个带 length 的普通对象类型。

另一个常见需求是按键读取属性:

TypeScript
function getProperty<T extends object, K extends keyof T>( object: T, key: K, ): T[K] { return object[key] } const user = { id: "u-1", age: 30 } const age = getProperty(user, "age") // number // getProperty(user, "email") // 这个对象没有 email 键

这行签名可以拆成三句:T 是对象类型;K 必须是这个对象已有的键;返回值是 T 在 K 这个键上的值类型。keyof 和 T[K] 后面还有专门章节,现在先通过这个例子理解它们怎样一起工作。

4. 默认类型参数和 const 类型参数

类型参数也可以有默认值,和函数参数的默认值有点相似,只是它们作用在类型层面:

TypeScript
type ApiResponse<TData, TMeta = undefined> = { data: TData meta: TMeta } const response: ApiResponse<string[]> = { data: ["Ada", "Lin"], meta: undefined, }

只传了 TData,所以 TMeta 使用默认类型 undefined。默认类型参数不会自动创建属性值。 上面的 meta 仍是必填属性,只是它的类型现在是 undefined。如果希望属性可以省略,需要另行设计可选属性。

const 类型参数则处理另一种问题:希望调用时写下的字面量保留得更精确。

TypeScript
function tuple<const T extends readonly unknown[]>(...values: T): T { return values } const command = tuple("git", "status") // readonly ["git", "status"]

这个结果记录了两个位置分别是什么值,而不是一个宽泛的字符串数组。这里的 const 是推断提示,不会在运行时冻结数组,也不会把一个提前声明、已经推断为宽类型的变量重新变回原来的字面量。

普通业务函数不一定需要这么精确。配置声明、固定参数列表等场景更容易用到它;当 string[] 已经足够时,让类型保持简单也是合理选择。

5. 泛型放进接口和类里,会发生什么

下面做一个很小的内存存储器。它只把对象放进 Map,并不连接数据库:

TypeScript
interface Repository<TEntity, TId> { findById(id: TId): Promise<TEntity | undefined> save(entity: TEntity): Promise<void> } class MemoryRepository<TEntity extends { id: string }> implements Repository<TEntity, string> { private readonly values = new Map<string, TEntity>() async findById(id: string): Promise<TEntity | undefined> { return this.values.get(id) } async save(entity: TEntity): Promise<void> { this.values.set(entity.id, entity) } } async function demo() { const users = new MemoryRepository<{ id: string; name: string }>() await users.save({ id: "u-1", name: "Ada" }) const user = await users.findById("u-1") console.log(user?.name) // Ada } void demo()

接口里的两个类型参数分别描述“存什么”和“用什么类型的编号查找”。具体实现选择字符串编号,要求实体至少有 id: string,否则保存时连键都取不到。

Promise<TEntity | undefined> 还保留了“查不到”的可能。即使你刚保存过同一个编号,通用接口仍允许查找失败,调用方应处理这个分支。

这个实现不负责持久化、拷贝对象或处理并发事务。保存的是对象引用,外部修改同一个对象也会影响读到的结果。把例子的范围说清楚,比把它叫成“生产级 Repository”更有用。

6. 变型:一种泛型实例能否替代另一种

先假设每只狗都是动物,但动物还包括猫。再问两个问题:

  1. 我需要一个“能提供动物”的函数,交给我一个“只提供狗”的函数,可以吗?可以,狗仍是动物。
  2. 我需要一个“能照顾狗”的函数,交给我一个“什么动物都能照顾”的函数,可以吗?也可以,狗在它的处理范围内。

第一个方向叫协变,第二个方向叫逆变。用代码会更明确:

TypeScript
type Animal = { name: string } type Dog = Animal & { bark(): void } type Producer<T> = () => T type Consumer<T> = (value: T) => void const dogProducer: Producer<Dog> = () => ({ name: "Mochi", bark() { console.log("汪") }, }) const animalProducer: Producer<Animal> = dogProducer const careForAnimal: Consumer<Animal> = animal => console.log(animal.name) const careForDog: Consumer<Dog> = careForAnimal careForDog(dogProducer())

反过来就不一定成立:只能照顾狗的函数可能调用 bark(),交给它一只普通动物就有问题。启用 strictFunctionTypes 后,函数参数类型会按更严格的方向检查。这项检查包含在 strict 中。

这里特意使用函数类型和函数属性,而不是方法签名。TypeScript 为兼容性保留了一些例外,方法参数的检查就有双变行为;可变数组的兼容性也不等于一个完全严格的不变模型。因此“既读又写就必须不变”适合作为设计上的警觉,不适合当成编译器在所有地方都会执行的定律。

还会看到这样的显式标注:

TypeScript
interface Producer<out T> { produce: () => T } interface Consumer<in T> { consume: (value: T) => void }

out、in 用来描述与类型实际结构相符的变型关系,不是随意改变兼容规则的开关。它们主要影响同一泛型声明的实例之间的比较,不会取代结构化比较。日常读懂方向比主动加标注更重要,先把函数设计清楚就好。

7. 为什么运行时没有一个可以 new 的 T

TypeScript
function create<T>(Constructor: new () => T): T { return new Constructor() } class Session { active = true } const session = create(Session) console.log(session.active) // true

这里真正被 new 的是传进来的构造函数值 Constructor。T 只用来表示它创建的实例类型,编译之后会被擦除。类型参数本身不是构造函数,也不是可以用来做 instanceof 的值。

这个签名还明确要求构造函数可以无参数调用。如果一个类必须接收用户名,应该把参数也设计进工厂函数,而不是用断言硬塞给这个版本。

如果你学过别的语言,可以这样做对照:Java 泛型也主要使用类型擦除,但类和接口通常按声明的名义关系来判断,运行环境是 JVM;C++ 模板则有模板实例化、非类型模板参数和编译期计算等机制。它们都在处理复用问题,但别把某种语言的实现细节直接搬到另一种语言上。

8. 什么时候值得写泛型

先问自己:这个类型参数连接了哪些位置? 比如数组元素和返回值、对象的键和值、仓库里保存和读出的实体。如果能说清楚这层联系,泛型通常就有实际作用。

如果参数只出现一次,也没有带来推断、约束或字面量保留等收益,普通类型往往更容易读。这是检查设计的经验,不是语法上的禁令。也别为了避免一个简单的重载,就引入一长串嵌套条件类型。

最后试着给前面的 first 改一版:数组为空时不要返回 undefined,而是抛错。签名应返回 T,但实现仍要检查是否真的存在首项。思考一下:如果 T 本身就包含 undefined,用 values[0] === undefined 判断“空数组”还准确吗?

这个问题能帮你区分“没有元素”和“有一个值为 undefined 的元素”。泛型写得正确,不只是符号能对上,还要让类型签名和运行中的行为说同一件事。

继续查阅:官方泛型章节、函数类型兼容性、const 类型参数。