返回首页

TypeScript 泛型实战:从基础约束到条件类型

泛型不是炫技,而是让类型跟着数据流动的工具。这篇从"为什么需要泛型"讲起,逐步深入约束、keyof、条件类型、映射类型和 infer,最后用几个真实工具类型把它们串起来。

40 约 6 分钟 · 5739 字 前端

很多人对泛型的印象停留在"那个尖括号 <T>",会用但说不清为什么用。其实泛型解决的是一个很具体的问题:让类型在函数和数据之间保持联系,而不是退化成 any

这篇从最朴素的需求开始,一步步走到能写出实用工具类型。

为什么需要泛型

先看一个不用泛型的函数:

function identity(value: any): any {
  return value
}

const n = identity(42)   // n 的类型是 any,丢失了 number

用了 any,传进去是 number,出来却变成了 any,后面再也没有类型提示。这等于关掉了 TypeScript。

泛型的做法是引入一个类型变量,让输入和输出绑定在一起:

function identity<T>(value: T): T {
  return value
}

const n = identity(42)        // n: number
const s = identity('hello')   // s: string

T 是一个占位符,调用时由实参的类型自动推断。输入是什么类型,输出就是什么类型。泛型让类型跟着数据流动,这是它最核心的价值。

泛型约束:extends

有时你需要对泛型做一点限制。比如一个函数要读取参数的 .length,但不是所有类型都有 length:

function logLength<T>(value: T): T {
  console.log(value.length)  // 报错:T 上不一定有 length
  return value
}

extends 约束 T 必须有 length 属性:

interface HasLength {
  length: number
}

function logLength<T extends HasLength>(value: T): T {
  console.log(value.length)  // OK
  return value
}

logLength('hello')      // string 有 length,OK
logLength([1, 2, 3])    // 数组有 length,OK
logLength(42)           // 报错:number 没有 length

T extends HasLength 读作"T 必须是 HasLength 的子类型"。它在"保持灵活"和"保证有某些属性"之间取得平衡。

keyof 与索引访问:安全地取属性

keyof 拿到一个类型所有键组成的联合类型:

interface User {
  id: number
  name: string
  active: boolean
}

type UserKey = keyof User   // 'id' | 'name' | 'active'

把它和泛型结合,可以写出一个类型安全的属性读取函数:

function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user: User = { id: 1, name: 'Tom', active: true }

const id = getProp(user, 'id')      // id: number
const name = getProp(user, 'name')  // name: string
getProp(user, 'age')                // 报错:'age' 不是 User 的键

这里有两个关键点:

  • K extends keyof T 保证 key 只能是对象真实存在的键,拼错直接报错
  • T[K]索引访问类型,表示"T 中 K 对应的值类型",返回值类型精确到具体属性

这比返回 any 强太多了——拼错 key 在编译期就被拦下,返回值类型也是准的。

条件类型:类型层面的三元表达式

条件类型让类型可以"根据条件判断":

type IsString<T> = T extends string ? true : false

type A = IsString<'hello'>  // true
type B = IsString<42>       // false

语法就是类型版的三元运算符:T extends U ? X : Y

一个实用例子:把可能为 null 的类型剔除掉:

type NonNull<T> = T extends null | undefined ? never : T

type C = NonNull<string | null>   // string

never 表示"不可能的类型",在联合类型里会被自动丢弃,所以 string | null 经过处理后只剩 string

分布式条件类型

当条件类型作用于联合类型时,会逐个成员分别判断,这叫分布式特性:

type ToArray<T> = T extends any ? T[] : never

type D = ToArray<string | number>  // string[] | number[]

string | number 被拆成 stringnumber 分别套用,最后合并。这个特性是很多高级工具类型的基础。

infer:从类型里"抽"出一部分

infer 用来在条件类型里推断并捕获一个类型。最经典的例子是取函数返回值类型:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function createUser() {
  return { id: 1, name: 'Tom' }
}

type User = MyReturnType<typeof createUser>  // { id: number; name: string }

infer R 的意思是:"如果 T 是一个函数,把它的返回值类型捕获到 R 里,然后返回 R"。这其实就是内置工具类型 ReturnType 的实现原理。

同理可以抽数组的元素类型:

type ElementOf<T> = T extends (infer E)[] ? E : never

type E1 = ElementOf<number[]>     // number
type E2 = ElementOf<string[]>     // string

或者抽 Promise 里包裹的类型:

type Awaited<T> = T extends Promise<infer V> ? V : T

type R = Awaited<Promise<string>>  // string

infer 是类型体操里最有"魔法感"的工具,本质就是"模式匹配 + 捕获"。

映射类型:批量改造对象类型

映射类型可以遍历一个类型的所有键,统一加工。语法是 [K in keyof T]:

// 把所有属性变成可选
type MyPartial<T> = {
  [K in keyof T]?: T[K]
}

// 把所有属性变成只读
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

它们其实就是内置的 PartialReadonly

结合修饰符还能反向操作,-? 去掉可选、-readonly 去掉只读:

// 把所有属性变成必填
type MyRequired<T> = {
  [K in keyof T]-?: T[K]
}

再配合 as 子句,还能重映射键名,比如给每个属性生成一个 getter:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

interface Person {
  name: string
  age: number
}

type PersonGetters = Getters<Person>
// {
//   getName: () => string
//   getAge: () => number
// }

Capitalize 是内置的字符串工具类型,把首字母大写。这种"键名 + 模板字符串"的组合,在写表单、状态管理的类型时很常用。

把它们串起来:几个实用工具类型

单个特性看着抽象,组合起来就有威力了。看几个常见的实战类型。

1. 提取对象中值为某类型的键

type KeysOfType<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never
}[keyof T]

interface Form {
  name: string
  age: number
  email: string
  active: boolean
}

type StringKeys = KeysOfType<Form, string>  // 'name' | 'email'

思路:先用映射类型把"值是 V 的键"留成键名、其余变成 never,再用 [keyof T] 把所有值取出来组成联合,never 自动被丢弃。

2. 深度只读

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K]
}

通过递归,把嵌套对象的每一层都变成只读。

3. 给接口的部分字段设为可选

type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

interface Article {
  id: number
  title: string
  cover: string
}

// 新建文章时 id 还没有,cover 可不传
type ArticleDraft = PartialBy<Article, 'id' | 'cover'>

这个在实际项目里特别常用:同一个实体,创建时和读取时的必填字段不一样。

几条实战建议

第一,优先让 TS 自动推断,别手动标注泛型参数identity(42)identity<number>(42) 更自然,只有推断不出来时才手写。

第二,约束要恰到好处。约束太松(T)会丢类型信息,太紧(具体类型)又失去泛型意义,用 extends 给到刚好够用的约束。

第三,别为了体操而体操。业务代码里能用内置的 Partial / Pick / Omit / ReturnType 解决就别自己造。理解原理是为了看懂和调试,不是每次都手写一遍。

第四,复杂类型拆开写。一个巨大的嵌套条件类型很难读,拆成几个有名字的中间类型,可读性和可维护性都更好。

总结

泛型这条线可以这样串起来:

  1. 泛型 <T>:让类型跟着数据流动,告别 any
  2. 约束 extends:在灵活和安全之间取平衡
  3. keyof + T[K]:安全地操作对象的键和值
  4. 条件类型 T extends U ? X : Y:类型层面的判断,配合分布式特性处理联合类型
  5. infer:从已有类型里捕获一部分,模式匹配的核心
  6. 映射类型 [K in keyof T]:批量改造对象类型

把这六块拼起来,内置的工具类型你都能看懂、也能按需造出自己的。泛型的终极目标始终如一:让类型系统帮你提前发现错误,而不是碍手碍脚。