很多人对泛型的印象停留在"那个尖括号 <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: stringT 是一个占位符,调用时由实参的类型自动推断。输入是什么类型,输出就是什么类型。泛型让类型跟着数据流动,这是它最核心的价值。
泛型约束: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 没有 lengthT 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> // stringnever 表示"不可能的类型",在联合类型里会被自动丢弃,所以 string | null 经过处理后只剩 string。
分布式条件类型
当条件类型作用于联合类型时,会逐个成员分别判断,这叫分布式特性:
type ToArray<T> = T extends any ? T[] : never
type D = ToArray<string | number> // string[] | number[]string | number 被拆成 string 和 number 分别套用,最后合并。这个特性是很多高级工具类型的基础。
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>> // stringinfer 是类型体操里最有"魔法感"的工具,本质就是"模式匹配 + 捕获"。
映射类型:批量改造对象类型
映射类型可以遍历一个类型的所有键,统一加工。语法是 [K in keyof T]:
// 把所有属性变成可选
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
// 把所有属性变成只读
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}它们其实就是内置的 Partial 和 Readonly。
结合修饰符还能反向操作,-? 去掉可选、-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 解决就别自己造。理解原理是为了看懂和调试,不是每次都手写一遍。
第四,复杂类型拆开写。一个巨大的嵌套条件类型很难读,拆成几个有名字的中间类型,可读性和可维护性都更好。
总结
泛型这条线可以这样串起来:
- 泛型
<T>:让类型跟着数据流动,告别any - 约束
extends:在灵活和安全之间取平衡 keyof+T[K]:安全地操作对象的键和值- 条件类型
T extends U ? X : Y:类型层面的判断,配合分布式特性处理联合类型 infer:从已有类型里捕获一部分,模式匹配的核心- 映射类型
[K in keyof T]:批量改造对象类型
把这六块拼起来,内置的工具类型你都能看懂、也能按需造出自己的。泛型的终极目标始终如一:让类型系统帮你提前发现错误,而不是碍手碍脚。