写这篇文章的起因很简单------某天 code review,同事写了一堆
any,我问他为什么,他说:"我知道有工具类型,但记不住,用不熟。" 这让我意识到,很多人卡在的不是"不知道",而是"没真正用过"。所以这篇文章尽量少废话,多上代码,把每个工具类型的原理 + 场景 + 坑点都说清楚。
一、先把心态摆对
TypeScript 的工具类型本质上是泛型 + 条件类型 + 映射类型的组合。你不需要死记,但需要理解它们在干什么。
学之前先问自己:我知道这三件事吗?
ts
// 1. 泛型:让类型像函数一样接收参数
type Box<T> = { value: T }
const b: Box<string> = { value: 'hello' }
// 2. 条件类型:类型层面的三元运算
type IsString<T> = T extends string ? 'yes' : 'no'
type R = IsString<'hello'> // 'yes'
// 3. 映射类型:遍历对象类型的每个属性
type Readonly<T> = { readonly [K in keyof T]: T[K] }
这三个搞懂了,后面所有内置工具类型的原理你都能自己推出来。
二、内置工具类型逐个拆解
2.1 Partial<T> --- 把所有属性变可选
场景:表单编辑、局部更新接口参数
ts
interface User {
id: number
name: string
email: string
age: number
}
// 更新用户时,不一定每个字段都传
function updateUser(id: number, data: Partial<User>) {
// data.name 可能是 undefined,但 TS 允许你只传部分字段
}
updateUser(1, { name: '张三', email: 'zs@example.com' }) // ✅
updateUser(1, { name: '张三' }) // ✅
原理:
ts
type MyPartial<T> = {
[K in keyof T]?: T[K]
// ^ 这个问号就是把必选变可选
}
常见坑 :Partial 只浅处理一层,嵌套对象不会递归变可选。
ts
interface Config {
server: {
host: string
port: number
}
debug: boolean
}
type PartialConfig = Partial<Config>
// 结果是:
// {
// server?: { host: string; port: number } ← server 内部依然全必选!
// debug?: boolean
// }
const c: PartialConfig = { server: {} } // ❌ 报错,server 里 host/port 是必须的
想深度 Partial?后面「进阶封装」章节会写。
2.2 Required<T> --- 把所有属性变必选
和 Partial 反过来,用 -? 移除可选标记。
ts
interface FormState {
name?: string
age?: number
email?: string
}
// 表单提交前校验,确保所有字段都有值
function submitForm(data: Required<FormState>) {
// 这里 data.name / data.age / data.email 全是必须的
}
原理:
ts
type MyRequired<T> = {
[K in keyof T]-?: T[K]
// ^ 减号 + 问号 = 移除可选
}
实战场景 :常和类型守卫配合,先用 Partial 接收外部数据,校验后用 Required 断言:
ts
function validateForm(data: Partial<FormState>): data is Required<FormState> {
return !!(data.name && data.age && data.email)
}
function handleSubmit(raw: Partial<FormState>) {
if (validateForm(raw)) {
submitForm(raw) // ✅ 这里 TS 知道 raw 已经是 Required<FormState>
}
}
2.3 Readonly<T> --- 把所有属性变只读
ts
const config: Readonly<Config> = {
server: { host: 'localhost', port: 3000 },
debug: true
}
config.debug = false // ❌ 编译报错:Cannot assign to 'debug' because it is a read-only property
原理:
ts
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
实战:配置对象、常量数据结构,防止被意外修改。
ts
// 路由表不应该在运行时被修改
const ROUTES = Object.freeze({
home: '/',
login: '/login',
dashboard: '/dashboard'
} as const)
// as const 配合 Readonly 效果更好
type Routes = Readonly<typeof ROUTES>
2.4 Pick<T, K> --- 从类型里摘几个属性出来
场景:接口返回大对象,组件只需要部分字段。
ts
interface Article {
id: number
title: string
content: string
author: string
publishedAt: string
updatedAt: string
tags: string[]
viewCount: number
}
// 文章列表页只需要这几个字段
type ArticleListItem = Pick<Article, 'id' | 'title' | 'author' | 'publishedAt' | 'tags'>
function renderArticleList(articles: ArticleListItem[]) {
// 不会误用 content、viewCount 等字段
}
原理:
ts
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
// 只遍历 K 里指定的键,不是全部
}
K extends keyof T 这个约束很重要------它确保你 Pick 的键必须在 T 里存在,否则报错。
2.5 Omit<T, K> --- 从类型里排除几个属性
和 Pick 相反,"我不要这几个,其他都要"。
ts
// 创建文章时,id 和 publishedAt 由服务端生成,不需要传
type CreateArticleParams = Omit<Article, 'id' | 'publishedAt' | 'updatedAt' | 'viewCount'>
async function createArticle(params: CreateArticleParams) {
return await api.post('/articles', params)
}
原理:
ts
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
// 先用 Exclude 从所有 key 里排除 K,剩下的再用 Pick 取出来
Pick vs Omit 怎么选:
- 要保留的字段少 → 用
Pick - 要排除的字段少 → 用
Omit
2.6 Exclude<T, U> 和 Extract<T, U> --- 联合类型的过滤
这两个作用在联合类型上,不是对象类型。
ts
type Status = 'pending' | 'active' | 'inactive' | 'deleted'
// 排除掉 'deleted',只保留其他状态
type VisibleStatus = Exclude<Status, 'deleted'>
// 结果:'pending' | 'active' | 'inactive'
// 只取出 'active' | 'inactive'
type OnlineStatus = Extract<Status, 'active' | 'inactive'>
// 结果:'active' | 'inactive'
原理(条件类型 + 分布式特性):
ts
// Exclude:T 中排除能赋值给 U 的部分
type MyExclude<T, U> = T extends U ? never : T
// Extract:T 中保留能赋值给 U 的部分
type MyExtract<T, U> = T extends U ? T : never
TS 的分布式条件类型:当 T 是联合类型时,条件类型会对每个成员单独求值再合并。
ts
// 演示分布式:
type E = Exclude<'a' | 'b' | 'c', 'a'>
// 等同于:
// ('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b') | ('c' extends 'a' ? never : 'c')
// = never | 'b' | 'c'
// = 'b' | 'c'
实战:过滤掉 undefined/null
ts
type NonNullable<T> = T extends null | undefined ? never : T
// 这就是内置 NonNullable 的实现
type MaybeString = string | null | undefined
type DefinitelyString = NonNullable<MaybeString> // string
2.7 Record<K, V> --- 构建键值映射类型
ts
// 最常见用法:用字符串做 key 的字典
const userMap: Record<string, User> = {}
// 更严格:key 限定为特定联合类型
type RolePermissions = Record<'admin' | 'editor' | 'viewer', string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
editor: ['read', 'write'],
viewer: ['read']
}
原理:
ts
type MyRecord<K extends keyof any, V> = {
[P in K]: V
}
实战:状态机配置
ts
type OrderStatus = 'created' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
interface StatusConfig {
label: string
color: string
nextSteps: OrderStatus[]
}
const ORDER_STATUS_CONFIG: Record<OrderStatus, StatusConfig> = {
created: { label: '待付款', color: '#faad14', nextSteps: ['paid', 'cancelled'] },
paid: { label: '已付款', color: '#52c41a', nextSteps: ['shipped', 'cancelled'] },
shipped: { label: '已发货', color: '#1890ff', nextSteps: ['delivered'] },
delivered: { label: '已完成', color: '#52c41a', nextSteps: [] },
cancelled: { label: '已取消', color: '#ff4d4f', nextSteps: [] }
}
// TS 会确保你不会漏写某个状态,也不会写出不存在的状态
2.8 ReturnType<T> --- 获取函数的返回值类型
场景:函数返回值比较复杂,不想重复定义类型。
ts
function getUserInfo(id: number) {
return {
id,
name: '张三',
roles: ['admin', 'editor'] as string[],
createdAt: new Date()
}
}
// 不用手动定义这个类型了,直接从函数推断
type UserInfo = ReturnType<typeof getUserInfo>
// { id: number; name: string; roles: string[]; createdAt: Date }
原理 :用 infer 推断返回值:
ts
type MyReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never
// ↑ infer 在这里"捕获"返回值类型
实战:与 Promise 配合
ts
async function fetchUserList() {
const res = await fetch('/api/users')
return res.json() as Promise<User[]>
}
type UserListResponse = Awaited<ReturnType<typeof fetchUserList>>
// User[]
2.9 Parameters<T> --- 获取函数参数类型
ts
function createOrder(userId: number, productId: string, quantity: number) {
// ...
}
type CreateOrderParams = Parameters<typeof createOrder>
// [userId: number, productId: string, quantity: number]
// 可以取某个参数
type FirstParam = Parameters<typeof createOrder>[0] // number
原理:
ts
type MyParameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never
实战:包装函数时保持参数类型
ts
// 给任意函数加日志,参数类型不丢失
function withLog<T extends (...args: any[]) => any>(fn: T) {
return function (...args: Parameters<T>): ReturnType<T> {
console.log('调用:', fn.name, args)
const result = fn(...args)
console.log('返回:', result)
return result
}
}
const loggedCreate = withLog(createOrder)
loggedCreate(1, 'prod-001', 2) // ✅ 参数类型和 createOrder 完全一致
2.10 InstanceType<T> --- 获取构造函数实例类型
ts
class EventEmitter {
on(event: string, handler: Function) { /* ... */ }
emit(event: string, ...args: any[]) { /* ... */ }
}
type EmitterInstance = InstanceType<typeof EventEmitter>
// 等同于 EventEmitter,但在泛型场景下很有用
// 实用场景:工厂函数
function createInstance<T extends new (...args: any[]) => any>(
Cls: T,
...args: ConstructorParameters<T>
): InstanceType<T> {
return new Cls(...args)
}
三、infer 的原理与实战封装
infer 是 TS 条件类型里的"类型变量声明"关键字,只能出现在 extends 子句里,作用是推断并捕获某个位置的类型。
3.1 基础理解
ts
// 普通写法:知道是什么类型
type GetReturnType<T> = T extends () => string ? string : never
// infer 写法:不提前说是什么类型,让 TS 自己推
type GetReturnType2<T> = T extends () => infer R ? R : never
type A = GetReturnType2<() => number> // number
type B = GetReturnType2<() => boolean> // boolean
type C = GetReturnType2<string> // never(不是函数,走 else 分支)
3.2 实战封装一:解包 Promise
ts
// 内置的 Awaited 就是这个思路
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
type A = UnwrapPromise<Promise<string>> // string
type B = UnwrapPromise<Promise<Promise<number>>> // Promise<number>(只解一层)
type C = UnwrapPromise<string> // string(不是 Promise,原样返回)
// 递归版本(深度解包)
type DeepUnwrapPromise<T> = T extends Promise<infer U>
? DeepUnwrapPromise<U>
: T
type D = DeepUnwrapPromise<Promise<Promise<Promise<number>>>> // number
3.3 实战封装二:提取数组元素类型
ts
type ElementOf<T> = T extends (infer E)[] ? E : never
type A = ElementOf<string[]> // string
type B = ElementOf<[number, boolean]> // number | boolean(元组会变联合类型)
type C = ElementOf<string> // never
// 实用场景:函数返回数组,想要单个元素的类型
async function getUsers() {
return [] as User[]
}
type SingleUser = ElementOf<Awaited<ReturnType<typeof getUsers>>>
// User
3.4 实战封装三:提取函数第一个参数
ts
type FirstParameter<T extends (...args: any) => any> =
T extends (first: infer F, ...rest: any[]) => any ? F : never
function greet(name: string, age: number) {}
type Name = FirstParameter<typeof greet> // string
3.5 实战封装四:提取对象某个方法的返回值
ts
// 这个在处理 Vuex、Redux action 返回值时特别有用
type MethodReturnType<T, K extends keyof T> =
T[K] extends (...args: any) => infer R ? R : never
class UserService {
getUser(id: number): Promise<User> { return null as any }
getList(): Promise<User[]> { return null as any }
deleteUser(id: number): Promise<void> { return null as any }
}
type GetUserReturn = MethodReturnType<UserService, 'getUser'> // Promise<User>
type GetListReturn = MethodReturnType<UserService, 'getList'> // Promise<User[]>
3.6 实战封装五:条件类型 + infer 解析字符串模板
TS 4.1 之后支持模板字面量类型,配合 infer 可以做字符串"解析":
ts
// 提取路由参数名
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never
type Params = ExtractRouteParams<'/user/:userId/post/:postId'>
// 'userId' | 'postId'
// 应用:类型安全的路由参数
function navigate<T extends string>(
path: T,
params: Record<ExtractRouteParams<T>, string | number>
) {
// 参数必须包含路由里所有的动态段
}
navigate('/user/:userId/post/:postId', {
userId: 1,
postId: 'post-001'
}) // ✅
navigate('/user/:userId/post/:postId', {
userId: 1,
// ❌ 缺少 postId,TS 报错
})
四、进阶:组合封装常用工具
4.1 DeepPartial --- 深度可选
ts
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
interface AppConfig {
server: {
host: string
port: number
ssl: {
enabled: boolean
cert: string
}
}
database: {
url: string
poolSize: number
}
}
// 现在可以只传嵌套的部分字段了
function mergeConfig(override: DeepPartial<AppConfig>): AppConfig {
const defaults: AppConfig = {
server: { host: 'localhost', port: 3000, ssl: { enabled: false, cert: '' } },
database: { url: 'sqlite::memory:', poolSize: 5 }
}
return deepMerge(defaults, override) as AppConfig
}
mergeConfig({ server: { port: 8080 } }) // ✅ 只改 port,其余用默认值
4.2 DeepReadonly --- 深度只读
ts
type DeepReadonly<T> = T extends (infer E)[]
? ReadonlyArray<DeepReadonly<E>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
const config: DeepReadonly<AppConfig> = {
server: { host: 'prod.example.com', port: 443, ssl: { enabled: true, cert: '...' } },
database: { url: 'postgres://...', poolSize: 20 }
}
config.server.port = 80 // ❌ 报错
config.server.ssl.enabled = false // ❌ 也报错,深度只读生效了
4.3 PickByValue --- 按值类型筛选属性
ts
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K]
}
interface MixedObject {
id: number
name: string
age: number
email: string
isActive: boolean
score: number
}
type StringProps = PickByValue<MixedObject, string>
// { name: string; email: string }
type NumberProps = PickByValue<MixedObject, number>
// { id: number; age: number; score: number }
注意这里用了 Key Remapping (
as子句),TS 4.1 的特性,可以在映射类型时重命名 key。
4.4 Merge --- 合并两个对象类型(后者覆盖前者)
ts
type Merge<T, U> = Omit<T, keyof U> & U
interface Base {
id: number
name: string
createdAt: Date
}
interface Override {
name: string[] // 把 name 从 string 改成 string[]
extra: boolean // 新增字段
}
type Result = Merge<Base, Override>
// { id: number; createdAt: Date; name: string[]; extra: boolean }
4.5 Prettify --- 展开类型显示,方便 hover 查看
ts
// 有时候类型是一堆 & 拼接的,hover 看不清楚
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
type Messy = { a: string } & { b: number } & { c: boolean }
// hover 显示:{ a: string } & { b: number } & { c: boolean }
type Clean = Prettify<Messy>
// hover 显示:{ a: string; b: number; c: boolean }
这个没什么实际逻辑,纯粹是让 IDE 显示更清晰的小技巧,项目里挺实用的。
五、实战综合案例:封装类型安全的 API 客户端
把前面学的东西综合用一把,写一个小而完整的例子。
ts
// 定义接口规范
interface ApiEndpoints {
'/users': { GET: { response: User[] }; POST: { body: Omit<User, 'id'>; response: User } }
'/users/:id': { GET: { params: { id: number }; response: User }; DELETE: { params: { id: number }; response: void } }
'/users/:id/posts':{ GET: { params: { id: number }; response: Post[] } }
}
// 提取某个端点某个方法的 body 类型
type RequestBody<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path]
> = ApiEndpoints[Path][Method] extends { body: infer B } ? B : never
// 提取响应类型
type ResponseType<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path]
> = ApiEndpoints[Path][Method] extends { response: infer R } ? R : never
// 类型安全的请求函数
async function apiRequest<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path]
>(
path: Path,
method: Method,
options?: { body?: RequestBody<Path, Method> }
): Promise<ResponseType<Path, Method>> {
// 实际请求逻辑...
const res = await fetch(path as string, {
method: method as string,
body: options?.body ? JSON.stringify(options.body) : undefined
})
return res.json()
}
// 使用时:
const users = await apiRequest('/users', 'GET')
// 推断出 users 类型是 User[]
const newUser = await apiRequest('/users', 'POST', {
body: { name: '张三', email: 'zs@example.com' } // Omit<User, 'id'>,不需要传 id
})
// 推断出 newUser 类型是 User
六、常见误区速查
| 误区 | 正确理解 |
|---|---|
Partial 是深度递归的 |
只处理第一层,嵌套需要 DeepPartial |
Omit 可以 omit 不存在的 key |
不行,K 必须是 keyof T 的子集 |
infer 可以在任意位置使用 |
只能在 extends 条件子句中使用 |
Record<string, any> 等同于 object |
不同,Record 是索引签名类型 |
| 条件类型总是"立即求值"的 | 泛型参数未确定时会延迟求值,可能导致意外行为 |
七、总结
从基础到进阶,核心就这几条:
Partial/Required/Readonly--- 修饰符操作,改变属性的可选/必选/只读性Pick/Omit--- 属性筛选,从对象类型中选出或排除某些字段Exclude/Extract--- 联合类型过滤,适合处理枚举值、状态码等场景Record--- 构建键值映射,替代{ [key: string]: T }且更类型安全ReturnType/Parameters--- 从函数推断类型,避免重复定义infer--- 条件类型的精髓,在类型层面做"模式匹配"
工具类型不是用来"炫技"的,而是减少重复类型定义、让类型随代码自动更新。如果你的类型和代码定义分离了,改了代码还要手动改类型,那就该考虑用工具类型重构一下了。
如果你在项目里有什么类型体操的实际场景,欢迎评论区交流。