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,又能为调用者保留 nameprice 等额外属性。

约束不等于类型转换。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> 排除 nullundefined
Parameters<F> 提取函数参数元组
ReturnType<F> 提取函数返回类型
ConstructorParameters<C> 提取构造参数元组
InstanceType<C> 提取构造器的实例类型
Awaited<T> 递归展开 Promise-like 类型

一个更新接口可以组合 PickPartial

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
}

PartialReadonly 等默认只转换第一层属性,并不会递归处理嵌套对象。需要深层版本时,应先确认业务语义,再谨慎自定义,避免产生昂贵而难懂的递归类型。

十一、实战:安全地解析外部数据

不要让无来源的泛型返回类型替代运行时检查。一个更可靠的边界是返回 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 由实际解析器决定,类型关系有运行时行为支撑。

十二、常见陷阱

  1. 类型参数只出现一次:它没有表达关系,优先改成具体类型或 unknown
  2. 调用者任意指定返回类型parse<T>()request<T>() 本身不提供验证,外部数据仍然不可信。
  3. 约束写得过宽T extends object 几乎没有提供可用信息,应约束真正需要的属性。
  4. 类型参数过多:每增加一个参数,调用和错误信息都会更复杂;只保留会连接两个以上位置的参数。
  5. 条件类型意外分发:联合类型结果不符合预期时,检查是否需要 [T] extends [U]
  6. 深层递归工具滥用:复杂递归可能降低编辑器性能,也可能抹平可选、只读等真实业务边界。
  7. 把类型运算当运行时逻辑keyof、条件类型和工具类型编译后都会消失,不能替代数据校验。

小结

泛型首先用于表达关系,类型推断则让调用代码保持简洁。keyof 和索引访问把键与值连接起来,typeof 从运行时声明提取静态类型,映射类型批量转换属性,条件类型与 infer 根据结构选择和提取类型。标准工具类型已经覆盖大部分常见需求。

判断一个泛型 API 是否合理,可以问三个问题:类型参数是否连接了两个以上位置?约束是否反映实现真正使用的能力?外部数据是否仍有运行时验证?如果答案是否定的,简单、具体的类型往往更安全。

官方资料

系列导航:返回目录 · 上一篇 · 下一篇