泛型、约束、推断、默认参数与变型
第一次看到 <T>,容易觉得它是一套额外的符号规则。其实先不用记符号:想想你有没有写过两个几乎一样的函数,一个处理数字数组,另一个处理用户数组,只因为参数类型不同,就复制了一份代码。
泛型要解决的是这种重复,同时保留具体的类型信息。我们先写一个取数组首项的函数,再逐步看约束、多个类型参数和变型。后半篇稍难,可以先把前几个例子运行明白。
1. 从“取第一项”看泛型在保留什么
如果只处理字符串,可以这样写:
function firstName(values: readonly string[]): string | undefined {
return values[0]
}
console.log(firstName(["Ada", "Lin"])) // Ada
console.log(firstName([])) // undefined
返回类型包含 undefined,因为空数组没有第一项。现在想把同样的逻辑用在数字数组上,函数体其实一行都不用改,只需要把“字符串”换成“这次传进来的元素类型”。
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 则更宽松:连错误的方法调用也容易被放过。泛型不是为了更宽松,而是为了在复用同一段实现时,把已知的类型关系继续传下去。
你也可能见过更短的入门例子:
function identity<T>(value: T): T {
return value
}
const state = identity("ready") // 这里推断为字面量类型 "ready"
const label = identity<string>("ready") // 显式指定 string
<string> 是调用时传入的类型参数。能自动推断清楚时,通常省略更好;显式指定类型不是更高级,只是适合你确实想表达不同约束的时候。
2. 输入一种类型,输出另一种类型
取第一项只需要一个类型参数。如果要把数组中的每项变成另一种值,就需要分别描述输入和输出:
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,因为传入的可能是数字。
可以加上约束:
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 的普通对象类型。
另一个常见需求是按键读取属性:
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 类型参数
类型参数也可以有默认值,和函数参数的默认值有点相似,只是它们作用在类型层面:
type ApiResponse<TData, TMeta = undefined> = {
data: TData
meta: TMeta
}
const response: ApiResponse<string[]> = {
data: ["Ada", "Lin"],
meta: undefined,
}
只传了 TData,所以 TMeta 使用默认类型 undefined。默认类型参数不会自动创建属性值。 上面的 meta 仍是必填属性,只是它的类型现在是 undefined。如果希望属性可以省略,需要另行设计可选属性。
const 类型参数则处理另一种问题:希望调用时写下的字面量保留得更精确。
function tuple<const T extends readonly unknown[]>(...values: T): T {
return values
}
const command = tuple("git", "status") // readonly ["git", "status"]
这个结果记录了两个位置分别是什么值,而不是一个宽泛的字符串数组。这里的 const 是推断提示,不会在运行时冻结数组,也不会把一个提前声明、已经推断为宽类型的变量重新变回原来的字面量。
普通业务函数不一定需要这么精确。配置声明、固定参数列表等场景更容易用到它;当 string[] 已经足够时,让类型保持简单也是合理选择。
5. 泛型放进接口和类里,会发生什么
下面做一个很小的内存存储器。它只把对象放进 Map,并不连接数据库:
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. 变型:一种泛型实例能否替代另一种
先假设每只狗都是动物,但动物还包括猫。再问两个问题:
- 我需要一个“能提供动物”的函数,交给我一个“只提供狗”的函数,可以吗?可以,狗仍是动物。
- 我需要一个“能照顾狗”的函数,交给我一个“什么动物都能照顾”的函数,可以吗?也可以,狗在它的处理范围内。
第一个方向叫协变,第二个方向叫逆变。用代码会更明确:
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 为兼容性保留了一些例外,方法参数的检查就有双变行为;可变数组的兼容性也不等于一个完全严格的不变模型。因此“既读又写就必须不变”适合作为设计上的警觉,不适合当成编译器在所有地方都会执行的定律。
还会看到这样的显式标注:
interface Producer<out T> {
produce: () => T
}
interface Consumer<in T> {
consume: (value: T) => void
}
out、in 用来描述与类型实际结构相符的变型关系,不是随意改变兼容规则的开关。它们主要影响同一泛型声明的实例之间的比较,不会取代结构化比较。日常读懂方向比主动加标注更重要,先把函数设计清楚就好。
7. 为什么运行时没有一个可以 new 的 T
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 类型参数。