TypeScript 泛型进阶:约束、keyof、映射类型与工具类型
TypeScript 泛型进阶:约束、keyof、映射类型与工具类型
系列导航:TypeScript 7 现代开发指南 · 上一篇 · 下一篇
技术基线:TypeScript 7.0.2
泛型的价值不是“把类型写得更抽象”,而是描述多个位置之间的类型关系:输入是什么,输出就保留什么;传入哪个属性名,结果就是那个属性的类型。keyof、索引访问、映射类型和条件类型则把这种关系扩展到对象键、属性值与类型转换。
本篇从推断优先的泛型函数出发,逐步组合这些类型运算能力。目标不是追求复杂类型,而是用尽可能少的类型参数表达真实约束。
一、泛型描述输入与输出的关系
如果只用 unknown,函数虽然可以接收任何值,却会丢失调用处的具体类型:
function identityUnknown(value: unknown): unknown {
return value
}
const result = identityUnknown('TypeScript')
// result 是 unknown
泛型参数会在一次调用中代表同一个具体类型:
function identity<T>(value: T): T {
return value
}
const name = identity('Ada')
// name 推断为 "Ada"
const count = identity(3)
// count 推断为 3
这里的 T 没有表示“任意、不检查”,而是表示“由本次调用确定,并在参数和返回值之间保持一致的类型”。这正是泛型与 any 的根本区别。
二、优先让 TypeScript 推断类型参数
大多数泛型调用不需要手写尖括号:
function first<T>(items: readonly T[]): T | undefined {
return items[0]
}
const firstName = first(['Ada', 'Linus'])
// string | undefined
const firstPoint = first([
{ x: 1, y: 2 },
{ x: 3, y: 4 },
])
// { x: number; y: number } | undefined
只有推断信息不足,或需要主动选择一个更宽的类型时,才显式传入类型参数:
const queue = first<string>([])
// string | undefined
显式类型参数不是运行时校验。下面的写法只是告诉编译器结果类型,无法证明服务器真的返回了 User:
interface User {
id: string
name: string
}
function unsafeParse<T>(text: string): T {
return JSON.parse(text) as T
}
const user = unsafeParse<User>('null')
// 编译通过,运行时仍然是 null
外部数据应先以 unknown 接收,再通过校验函数或 schema 库验证,而不是让调用者用 <T>“许愿”。
三、用 extends 约束可用能力
无约束的 T 不能假定存在任何属性。约束用于声明泛型值至少具备什么结构:
interface HasId {
id: string
}
function findById<T extends HasId>(
items: readonly T[],
id: string,
): T | undefined {
return items.find((item) => item.id === id)
}
const products = [
{ id: 'p-1', name: 'Keyboard', price: 599 },
{ id: 'p-2', name: 'Mouse', price: 299 },
]
const product = findById(products, 'p-2')
// { id: string; name: string; price: number } | undefined
参数类型如果直接写成 HasId[],返回值就只剩 HasId;使用 T extends HasId,既能在实现中安全读取 id,又能为调用者保留 name、price 等额外属性。
约束不等于类型转换。T extends HasId 表示调用者传入的类型满足 HasId,并不表示函数可以随意构造一个 T。
四、多个类型参数只用于表达关系
多个类型参数适合描述不同位置之间的转换:
function mapItems<Input, Output>(
items: readonly Input[],
transform: (item: Input, index: number) => Output,
): Output[] {
return items.map(transform)
}
const labels = mapItems(products, (item) => `${item.name}: ¥${item.price}`)
// string[]
Input 连接数组元素和回调参数,Output 连接回调返回值和最终数组元素。两个参数都有明确关系。
如果类型参数只出现一次,通常不需要泛型:
// 不必要:T 没有连接两个位置
function printBad<T>(value: T): void {
console.log(value)
}
// 更直接
function print(value: unknown): void {
console.log(value)
}
同样,不要仅因为两个参数“可能是不同类型”就声明 <T, U>。如果实现不保留或约束它们的关系,直接写具体类型或 unknown 往往更清楚。
五、keyof 与索引访问:让键和值保持一致
keyof T 会得到对象类型 T 的键联合;T[K] 则取得键 K 对应的值类型。两者组合可以写出类型安全的属性读取函数:
function getProperty<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key]
}
const user = {
id: 'u-1',
name: 'Ada',
active: true,
}
const userName = getProperty(user, 'name')
// string
const active = getProperty(user, 'active')
// boolean
// getProperty(user, 'email')
// 报错:"email" 不是 user 的键
索引访问也可以直接拆取已有类型:
interface Order {
id: string
status: 'pending' | 'paid' | 'cancelled'
customer: {
id: string
email: string
}
}
type OrderStatus = Order['status']
type Customer = Order['customer']
type CustomerEmail = Order['customer']['email']
type OrderSummary = Order['id' | 'status']
对于数组,使用 number 可以取得元素类型:
const routes = [
{ path: '/', auth: false },
{ path: '/admin', auth: true },
]
type Route = (typeof routes)[number]
// { path: string; auth: boolean }
注意,带字符串索引签名的类型,其 keyof 可能包含 string | number,因为 JavaScript 对象的数字键最终也会转成字符串。
六、类型位置中的 typeof
值空间里的 typeof value 返回运行时字符串;类型位置中的 typeof value 则提取某个值的静态类型:
const defaultConfig = {
retries: 3,
mode: 'safe' as 'safe' | 'fast',
features: ['cache', 'metrics'],
}
type AppConfig = typeof defaultConfig
它经常与 as const、索引访问和工具类型组合:
const roles = ['admin', 'editor', 'viewer'] as const
type Role = (typeof roles)[number]
// "admin" | "editor" | "viewer"
function createSession(role: Role) {
return { role, createdAt: new Date() }
}
type Session = ReturnType<typeof createSession>
类型位置的 typeof 只能查询标识符或属性访问等可命名值,不能直接执行函数:
// type Bad = typeof createSession('admin')
// 应写为:
type Good = ReturnType<typeof createSession>
原则上,先定义运行时数据,再从数据提取类型,能减少值与类型维护两份名单的问题。
七、映射类型:逐个转换对象属性
映射类型遍历 keyof 得到的键,并为每个属性生成新类型:
interface Profile {
id: string
displayName: string
avatarUrl?: string
}
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type NullableProfile = Nullable<Profile>
可以用 +、- 增删 readonly 和可选修饰符:
type MutableRequired<T> = {
-readonly [K in keyof T]-?: T[K]
}
也可以通过 as 重映射键名:
type ChangeHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
value: T[K],
) => void
}
type ProfileHandlers = ChangeHandlers<Profile>
// onIdChange、onDisplayNameChange、onAvatarUrlChange
映射类型适合表达机械、统一的属性变换。若新类型只有三四个稳定字段,直接写接口通常更易读,不必强行映射。
八、条件类型:根据类型选择分支
条件类型的形式类似三元表达式:
type IdOf<T> = T extends { id: infer Id } ? Id : never
type UserId = IdOf<{ id: string; name: string }>
// string
type MissingId = IdOf<{ name: string }>
// never
当检查的是裸类型参数时,条件类型会对联合类型逐项分发:
type ToArray<T> = T extends unknown ? T[] : never
type Distributed = ToArray<string | number>
// string[] | number[]
如果希望把联合整体判断,用方括号包住两侧:
type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never
type NotDistributed = ToArrayWhole<string | number>
// (string | number)[]
分发行为很强大,也很容易让结果与直觉不同。遇到复杂联合类型时,应先确认自己要“逐项转换”还是“整体判断”。
九、infer:在条件类型中提取一部分
infer 只能出现在条件类型的 extends 分支中,用来为待匹配结构的一部分命名:
type ElementOf<T> = T extends readonly (infer Item)[] ? Item : never
type Tag = ElementOf<readonly ['news', 'tech']>
// "news" | "tech"
type UnwrapPromise<T> = T extends PromiseLike<infer Value>
? UnwrapPromise<Value>
: T
type Data = UnwrapPromise<Promise<Promise<{ id: string }>>>
// { id: string }
实际项目应优先使用标准库已提供的 Awaited<T>、ReturnType<T>、Parameters<T> 等工具,不必重复实现。自定义 infer 更适合提取项目特有结构,例如路由参数、事件载荷或响应包装中的数据。
十、常用工具类型
TypeScript 标准库已经用映射类型和条件类型实现了常见变换:
| 工具类型 | 作用 |
|---|---|
Partial<T> |
所有属性变为可选 |
Required<T> |
所有属性变为必选 |
Readonly<T> |
所有属性变为只读 |
Pick<T, K> |
保留指定键 |
Omit<T, K> |
排除指定键 |
Record<K, V> |
由键联合生成对象 |
Exclude<U, M> |
从联合中排除成员 |
Extract<U, M> |
从联合中提取成员 |
NonNullable<T> |
排除 null 与 undefined |
Parameters<F> |
提取函数参数元组 |
ReturnType<F> |
提取函数返回类型 |
ConstructorParameters<C> |
提取构造参数元组 |
InstanceType<C> |
提取构造器的实例类型 |
Awaited<T> |
递归展开 Promise-like 类型 |
一个更新接口可以组合 Pick 与 Partial:
interface Article {
id: string
title: string
body: string
publishedAt: Date | null
}
type ArticlePatch = Partial<Pick<Article, 'title' | 'body'>>
function updateArticle(id: Article['id'], patch: ArticlePatch) {
// 调用 API
}
Partial、Readonly 等默认只转换第一层属性,并不会递归处理嵌套对象。需要深层版本时,应先确认业务语义,再谨慎自定义,避免产生昂贵而难懂的递归类型。
十一、实战:安全地解析外部数据
不要让无来源的泛型返回类型替代运行时检查。一个更可靠的边界是返回 unknown,再使用类型守卫:
interface User {
id: string
name: string
}
function parseJson(text: string): unknown {
return JSON.parse(text)
}
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
return typeof record.id === 'string' && typeof record.name === 'string'
}
const value = parseJson('{"id":"u-1","name":"Ada"}')
if (isUser(value)) {
console.log(value.name)
}
如果项目使用 Zod、Valibot 等 schema 库,泛型应连接“schema 输入”和“验证后的输出”,而不是允许调用者任意指定结果:
interface Parser<T> {
parse(value: unknown): T
}
function parseWith<T>(text: string, parser: Parser<T>): T {
return parser.parse(JSON.parse(text))
}
此时 T 由实际解析器决定,类型关系有运行时行为支撑。
十二、常见陷阱
- 类型参数只出现一次:它没有表达关系,优先改成具体类型或
unknown。 - 调用者任意指定返回类型:
parse<T>()、request<T>()本身不提供验证,外部数据仍然不可信。 - 约束写得过宽:
T extends object几乎没有提供可用信息,应约束真正需要的属性。 - 类型参数过多:每增加一个参数,调用和错误信息都会更复杂;只保留会连接两个以上位置的参数。
- 条件类型意外分发:联合类型结果不符合预期时,检查是否需要
[T] extends [U]。 - 深层递归工具滥用:复杂递归可能降低编辑器性能,也可能抹平可选、只读等真实业务边界。
- 把类型运算当运行时逻辑:
keyof、条件类型和工具类型编译后都会消失,不能替代数据校验。
小结
泛型首先用于表达关系,类型推断则让调用代码保持简洁。keyof 和索引访问把键与值连接起来,typeof 从运行时声明提取静态类型,映射类型批量转换属性,条件类型与 infer 根据结构选择和提取类型。标准工具类型已经覆盖大部分常见需求。
判断一个泛型 API 是否合理,可以问三个问题:类型参数是否连接了两个以上位置?约束是否反映实现真正使用的能力?外部数据是否仍有运行时验证?如果答案是否定的,简单、具体的类型往往更安全。
官方资料
- Generics
- Keyof Type Operator
- Typeof Type Operator
- Indexed Access Types
- Mapped Types
- Conditional Types
- Utility Types