【TS】类型进阶篇,进阶实战运用
当我第一次看到 TypeScript 的infer和条件类型时,反手就关了浏览器——这堆尖括号和问号到底想搞死谁?像T extends (infer U)[] ? U : never这种类型,我盯着看了 5 分钟,感觉自己像个没学过编程的傻子。
直到后来我发现:这些玩意儿根本不是黑魔法,只是穿了件吓人外套的纸老虎。只要掌握 3 条简单规则,你会和我一样拍大腿:“原来这么简单!”
先跟你说句掏心窝子的话:你不是一个人在战斗
我至今记得当初的崩溃:
- 看到
ReturnType<T>的定义T extends (...args: any[]) => infer R ? R : never,我以为这是外星语言; - 写
Exclude<T, U>时,联合类型突然“自动拆分”,我差点把键盘摔了; - 用
infer提取数组元素类型,我盯着T extends (infer U)[] ? U : never,连喝三瓶可乐才冷静下来。
但现在我想告诉你:这些恐惧都是纸老虎。TypeScript 的高级类型,本质是 3 条“说破了就没人信”的简单逻辑。
🔍 规则一:条件类型 = 给类型用的「if-else」
你写 JavaScript 时会用if (a === 1) { ... } else { ... }——条件类型就是 TypeScript 给类型写的“if-else”。
它的语法长这样:
type 类型名<T> = T extends 条件 ? 满足条件的类型 : 不满足的类型翻译成人话就是:
“嘿 TypeScript,如果这个类型符合条件,就给我返回 A 类型;不然就返回 B 类型。”
🌰 实战升级:Vue3 加载按钮的完整 Props 设计
在实际项目中,“加载状态的按钮”绝不是简单的disabled——还要处理样式类、默认文案、点击节流。我们用条件类型设计一个更真实的ButtonProps:
import type { PropType } from 'vue'
/** * 按钮Props类型:根据loading状态动态调整 * @param T 是否加载中(默认false) */type ButtonProps<T extends boolean = false> = { /** 按钮文字(必传) */ label: string /** 加载状态(控制禁用&文案) */ loading?: T /** 按钮尺寸(默认'medium') */ size?: 'small' | 'medium' | 'large'} & (T extends true ? { /** 加载时的文案(默认'加载中...') */ loadingText?: string /** 加载时的样式类(固定为'btn-loading') */ class?: 'btn-loading' /** 加载时禁用点击(必为never,防止误传) */ onClick?: never } : { /** 点击事件(非加载时必传) */ onClick: () => void /** 正常状态的样式类(默认'btn-normal') */ class?: 'btn-normal' | 'btn-primary' })
// Vue3组件中使用(Script Setup语法)const props = defineProps<ButtonProps>()const emit = defineEmits(['click'])
// 处理默认值(Vue3中defineProps的默认值需要用withDefaults)withDefaults(defineProps<ButtonProps>(), { size: 'medium', loadingText: '加载中...', class: 'btn-normal',})📝 为什么这样设计?
- 交叉类型
&的作用:将基础 Props(label、size)与“状态专属 Props”(加载/正常)合并,保证 Props 的完整性; never的意义:加载状态下onClick设为never,直接禁止传入点击事件,避免“加载时仍能点击”的 bug;- 默认值的处理:用
withDefaults统一设置默认值,符合 Vue3 的最佳实践。
🎩 规则二:裸类型的「分发魔术」——自动遍历联合类型
这条规则我花了最久才搞懂,但一旦悟透,处理联合类型的效率直接翻倍。
🔍 先明确:什么是“裸类型”?
“裸类型”是指泛型参数没有被包裹在任何类型构造器中(如[]、{}、() =>等)。比如:
- 裸类型:
T extends any(T直接出现); - 非裸类型:
[T] extends [any](T被包裹在[]中)。
🎩 分发魔术的核心:自动拆联合、逐个判
当条件类型作用于裸类型泛型时,TypeScript 会自动:
- 把联合类型拆成单个成员;
- 对每个成员应用条件类型;
- 把结果重新拼成联合类型。
🌰 实战升级:项目中的“路由权限过滤”
在 Vue3 项目中,我们常需要从路由列表中过滤出“需要登录”的路由。用分发魔术可以轻松实现:
/** 路由元信息类型(控制权限) */type RouteMeta = { /** 是否需要登录(默认false) */ requiresAuth?: boolean /** 路由标题(用于面包屑) */ title?: string}
/** 路由类型 */type Route = { path: string name: string meta: RouteMeta children?: Route[] // 支持嵌套路由}
// 项目中的路由列表(含嵌套路由)const routes: Route[] = [ { path: '/', name: 'Home', meta: { title: '首页' } }, { path: '/login', name: 'Login', meta: { title: '登录' } }, { path: '/profile', name: 'Profile', meta: { requiresAuth: true, title: '个人中心' }, children: [ { path: 'info', name: 'ProfileInfo', meta: { requiresAuth: true, title: '个人信息' }, }, ], }, { path: '/admin', name: 'Admin', meta: { requiresAuth: true, title: '后台管理' }, },]
// 🔥 过滤需要登录的路由(裸类型泛型实现分发)type FilterAuthRoutes<T extends Route> = T extends { meta: { requiresAuth: true }} ? T : never
// 提取所有需要登录的路由(递归处理嵌套路由)type AuthRoutes = FilterAuthRoutes<(typeof routes)[number]>// 结果:包含Profile(含子路由)、Admin的路由类型📝 为什么这样高效?
- 自动处理嵌套路由:即使路由有
children,分发魔术也会遍历每个子路由; - 类型安全:过滤后的
AuthRoutes仅包含需要登录的路由,避免“未登录用户访问受限页面”的 bug。
🔮 规则三:infer = 类型的「偷窥器」——提取内部信息
infer是 TypeScript 给你的“望远镜”——它能帮你从复杂类型中“偷窥”并提取内部的类型。
它的语法长这样:
type 类型名<T> = T extends 包含infer的结构 ? 提取的类型 : never翻译成人话就是:
“嘿 TypeScript,我不管这个类型里面是什么,你帮我把某个位置的类型提取出来,存到变量里,我好用它!”
🌰 实战升级:提取 Pinia Store 的 State 类型
Pinia 是 Vue3 官方推荐的状态管理库,我们常需要提取 Store 的 State 类型,用于组件中的类型提示。用infer可以轻松实现:
// src/stores/user.ts(Pinia Store)import { defineStore } from 'pinia'
// 定义User Storeconst useUserStore = defineStore('user', { state: () => ({ id: 0, name: '张三', email: 'zhangsan@example.com', isLogin: false, }), actions: { login(username: string, password: string) { // 登录逻辑 this.isLogin = true }, },})
// 🔥 提取State类型(用infer偷窥state函数的返回值)type StateOfStore<T> = T extends { state: () => infer S } ? S : never
// 结果:{ id: number; name: string; email: string; isLogin: boolean }type UserState = StateOfStore<typeof useUserStore>📝 为什么这样好用?
- 自动同步 State 变化:如果 Store 的 State 字段增加/修改,
UserState会自动更新,无需手动维护; - 类型提示更精准:在组件中使用
useUserStore()时,store.state会自动提示所有 State 字段,避免拼写错误。
🌰 再升级:提取函数的参数类型
TypeScript 内置的Parameters<T>也是用infer实现的——提取函数的参数类型,这在“封装 API 请求”时特别有用:
// 封装API请求函数async function fetchUser( id: number, options?: { cache: boolean }): Promise<UserState> { const res = await fetch(`/api/user/${id}`, { cache: options?.cache ?? false }) return res.json()}
// 提取fetchUser的参数类型type FetchUserParams = Parameters<typeof fetchUser>// 结果:[id: number, options?: { cache: boolean }]
// 使用时自动提示参数:const params: FetchUserParams = [123, { cache: true }]fetchUser(...params) // 正确,自动匹配参数类型🎁 彩蛋:映射类型 = 类型的「for 循环」
既然你已经掌握了前面 3 条规则,映射类型对你来说就是“小菜一碟”——它能帮你遍历类型的所有属性,批量修改。
🔍 语法回顾
type 类型名<T> = { [K in keyof T]: 修改后的类型}翻译成人话就是:
“嘿 TypeScript,遍历类型 T 的所有属性,每个属性都改成我要的类型。”
🌰 实战升级:给组件 Props 加“全局前缀”
在大型项目中,为了避免组件 Props 冲突,我们常给所有 Props 加一个全局前缀(比如app-)。用映射类型可以一键实现:
// 给Props加前缀的映射类型type AddGlobalPrefix<T, Prefix extends string = 'app'> = { // 用`as`重命名属性:将K转为`Prefix+首字母大写的K` [K in keyof T as `${Prefix}-${Capitalize<K & string>}`]: T[K]}
// 原始ButtonPropstype ButtonProps = { label: string onClick: () => void size: 'small' | 'medium' | 'large'}
// 加前缀后的Propstype AppButtonProps = AddGlobalPrefix<ButtonProps>// 结果:{// 'app-Label': string;// 'app-OnClick': () => void;// 'app-Size': 'small' | 'medium' | 'large'// }📝 为什么这样设计?
keyof T的作用:遍历原始 Props 的所有属性;as的重命名能力:用模板字面量类型${Prefix}-${Capitalize<K & string>}给属性加前缀,并将首字母大写(符合 HTML 属性的命名规范);- 泛型的灵活性:
Prefix设为默认值'app',也可以根据项目需求修改(比如'admin-')。
🚀 终极合体:用高级类型解决 Pinia 的“Action Payload 推导”
现在我们把 3 条规则合起来,解决Vue3 项目中最常见的需求:
根据 Pinia 的 Action 类型,自动推导对应的 Payload 结构
🌰 实战场景:Pinia 的 User Store
假设我们有一个 User Store,包含 3 个 Action:login(登录)、logout(退出)、updateProfile(更新资料)。我们需要:
- Dispatch Action 时,自动提示对应的 Payload 结构;
- 禁止传入不符合 Action 类型的 Payload。
🔧 实现步骤
1. 定义 Action 类型
type UserAction = | { type: 'user/login'; payload: { username: string; password: string } } | { type: 'user/logout'; payload: null } | { type: 'user/updateProfile'; payload: { name: string; email: string } }2. 提取 Action 的 Payload 类型(用条件类型+infer)
// 提取指定Action的Payload类型type PayloadOfAction<T extends UserAction['type']> = Extract< UserAction, { type: T }> extends { payload: infer P } ? P : never3. 在 Pinia Store 中使用
const useUserStore = defineStore('user', { state: () => ({ /* ... */ }), actions: { // 通用Dispatch方法(自动推导Payload类型) dispatch<T extends UserAction['type']>( type: T, payload: PayloadOfAction<T> ) { switch (type) { case 'user/login': // Payload自动推导为{ username: string; password: string } this.login(payload.username, payload.password) break case 'user/logout': // Payload自动推导为null this.logout() break case 'user/updateProfile': // Payload自动推导为{ name: string; email: string } this.updateProfile(payload.name, payload.email) break } }, // 具体Action实现 login(username: string, password: string) { /* ... */ }, logout() { /* ... */ }, updateProfile(name: string, email: string) { /* ... */ }, },})4. 组件中使用(自动提示 Payload)
const userStore = useUserStore()
// ✅ 正确:login需要{ username, password }userStore.dispatch('user/login', { username: 'zhangsan', password: '123456' })
// ✅ 正确:logout不需要Payload(payload为null)userStore.dispatch('user/logout', null)
// ❌ 错误:updateProfile缺少email字段userStore.dispatch('user/updateProfile', { name: '李四' })📝 用到了哪些规则?
- 条件类型:
Extract<UserAction, { type: T }>从联合类型中提取指定 Action; - infer 提取:
infer P提取 Action 的 Payload 类型; - 裸类型分发:
T extends UserAction['type']自动遍历 Action 类型的联合。
🎯 现在你该怎么做?
TypeScript 的高级类型不是“学了就能用”——你需要把规则“内化”成思维模式。
我的建议是:
- 先搞懂内置工具类型:从
Partial<T>、Required<T>、ReturnType<T>开始,手动实现一遍(比如用Partial<T>实现“将 Props 转为可选”); - 从小需求开始练:比如给你的 Vue3 组件写一个“根据状态动态调整 Props”的类型,或者提取 Pinia Store 的 State 类型;
- 遇到复杂类型先拆解:看到
T extends (infer U)[] ? U : never,先问自己:“这是条件类型吗?用了 infer 吗?是不是在提取数组元素?”
最后想说:高级类型不是为了秀,是为了少写 bug
我曾经以为“会写高级类型”是程序员的“装逼资本”,直到后来:
- 用
NonNullable<T>避免了生产环境的null报错; - 用
ReturnType<T>帮同事找到了函数返回值的类型错误; - 用“Action Payload 推导”减少了 Pinia 中 80%的 payload 拼写错误。
我才明白:高级类型是帮你“在编译时就把 bug 掐死”的工具。
现在回头看,那些曾经让我头皮发麻的类型,其实都是纸老虎。掌握这 3 条规则,你会发现:
TypeScript 的高级类型不是用来吓你的,是帮你更安心写代码的。
你正在卡哪个 TypeScript 类型?
评论区告诉我,我们一起拆解它!如果这篇文章帮你捅破了“高级类型”的窗户纸,点个赞让我知道——你不是一个人在战斗~
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!