把 TypeScript 内置工具类型彻底搞懂,顺便自己封几个好用的

写这篇文章的起因很简单------某天 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 Remappingas 子句),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 是索引签名类型
条件类型总是"立即求值"的 泛型参数未确定时会延迟求值,可能导致意外行为

七、总结

从基础到进阶,核心就这几条:

  1. Partial/Required/Readonly --- 修饰符操作,改变属性的可选/必选/只读性
  2. Pick/Omit --- 属性筛选,从对象类型中选出或排除某些字段
  3. Exclude/Extract --- 联合类型过滤,适合处理枚举值、状态码等场景
  4. Record --- 构建键值映射,替代 { [key: string]: T } 且更类型安全
  5. ReturnType/Parameters --- 从函数推断类型,避免重复定义
  6. infer --- 条件类型的精髓,在类型层面做"模式匹配"

工具类型不是用来"炫技"的,而是减少重复类型定义、让类型随代码自动更新。如果你的类型和代码定义分离了,改了代码还要手动改类型,那就该考虑用工具类型重构一下了。


如果你在项目里有什么类型体操的实际场景,欢迎评论区交流。

相关推荐
维基框架1 小时前
当Prompt编辑变成训练过程:SkillOpt的逆直觉方案
前端
KaMeidebaby1 小时前
卡梅德生物技术快报|小 RNA 适配体合成 + 多方法亲和力表征全流程标准化操作手册
前端·网络·数据库·人工智能·算法
爸爸6191 小时前
07 ArkTS 基础类型与接口定义:从 TypeScript 到鸿蒙的类型升级
javascript·华为·typescript·harmonyos·鸿蒙系统
OpenTiny社区2 小时前
Coding Agent 到底是怎么工作的,它又为什么会在复杂代码库里失真?
前端·github·agent
奇奇怪怪的2 小时前
Graph RAG:知识图谱增强检索
前端
默_笙2 小时前
🌏 MCP 是 AI 界的 USB-C:一个协议让所有模型都能用上所有工具
前端·javascript
以和为贵2 小时前
🔥前端也能搞懂流式输出:从 SSE 到打字机效果
前端·人工智能·架构
阿成学长_Cain3 小时前
Linux telinit 命令详解:运行级别切换|关机重启|系统维护一站式掌握
linux·运维·前端·网络
Patrick_Wilson3 小时前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化