【TS】类型进阶篇,进阶实战运用

3023 字
15 分钟
【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

src/types/button.ts
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',
})

📝 为什么这样设计?#

  1. 交叉类型&的作用:将基础 Props(labelsize)与“状态专属 Props”(加载/正常)合并,保证 Props 的完整性;
  2. never的意义:加载状态下onClick设为never,直接禁止传入点击事件,避免“加载时仍能点击”的 bug;
  3. 默认值的处理:用withDefaults统一设置默认值,符合 Vue3 的最佳实践。

🎩 规则二:裸类型的「分发魔术」——自动遍历联合类型#

这条规则我花了最久才搞懂,但一旦悟透,处理联合类型的效率直接翻倍

🔍 先明确:什么是“裸类型”?#

“裸类型”是指泛型参数没有被包裹在任何类型构造器中(如[]{}() =>等)。比如:

  • 裸类型:T extends anyT直接出现);
  • 非裸类型:[T] extends [any]T被包裹在[]中)。

🎩 分发魔术的核心:自动拆联合、逐个判#

当条件类型作用于裸类型泛型时,TypeScript 会自动:

  1. 把联合类型拆成单个成员
  2. 对每个成员应用条件类型;
  3. 把结果重新拼成联合类型。

🌰 实战升级:项目中的“路由权限过滤”#

在 Vue3 项目中,我们常需要从路由列表中过滤出“需要登录”的路由。用分发魔术可以轻松实现:

src/types/route.ts
/** 路由元信息类型(控制权限) */
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 Store
const 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]
}
// 原始ButtonProps
type ButtonProps = {
label: string
onClick: () => void
size: 'small' | 'medium' | 'large'
}
// 加前缀后的Props
type AppButtonProps = AddGlobalPrefix<ButtonProps>
// 结果:{
// 'app-Label': string;
// 'app-OnClick': () => void;
// 'app-Size': 'small' | 'medium' | 'large'
// }

📝 为什么这样设计?#

  1. keyof T的作用:遍历原始 Props 的所有属性;
  2. as的重命名能力:用模板字面量类型${Prefix}-${Capitalize<K & string>}给属性加前缀,并将首字母大写(符合 HTML 属性的命名规范);
  3. 泛型的灵活性Prefix设为默认值'app',也可以根据项目需求修改(比如'admin-')。

🚀 终极合体:用高级类型解决 Pinia 的“Action Payload 推导”#

现在我们把 3 条规则合起来,解决Vue3 项目中最常见的需求

根据 Pinia 的 Action 类型,自动推导对应的 Payload 结构

🌰 实战场景:Pinia 的 User Store#

假设我们有一个 User Store,包含 3 个 Action:login(登录)、logout(退出)、updateProfile(更新资料)。我们需要:

  1. Dispatch Action 时,自动提示对应的 Payload 结构;
  2. 禁止传入不符合 Action 类型的 Payload。

🔧 实现步骤#

1. 定义 Action 类型#

src/stores/user.ts
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
: never

3. 在 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: '李四' })

📝 用到了哪些规则?#

  1. 条件类型Extract<UserAction, { type: T }>从联合类型中提取指定 Action;
  2. infer 提取infer P提取 Action 的 Payload 类型;
  3. 裸类型分发T extends UserAction['type']自动遍历 Action 类型的联合。

🎯 现在你该怎么做?#

TypeScript 的高级类型不是“学了就能用”——你需要把规则“内化”成思维模式

我的建议是:

  1. 先搞懂内置工具类型:从Partial<T>Required<T>ReturnType<T>开始,手动实现一遍(比如用Partial<T>实现“将 Props 转为可选”);
  2. 从小需求开始练:比如给你的 Vue3 组件写一个“根据状态动态调整 Props”的类型,或者提取 Pinia Store 的 State 类型;
  3. 遇到复杂类型先拆解:看到T extends (infer U)[] ? U : never,先问自己:“这是条件类型吗?用了 infer 吗?是不是在提取数组元素?”

最后想说:高级类型不是为了秀,是为了少写 bug#

我曾经以为“会写高级类型”是程序员的“装逼资本”,直到后来:

  • NonNullable<T>避免了生产环境的null报错;
  • ReturnType<T>帮同事找到了函数返回值的类型错误;
  • 用“Action Payload 推导”减少了 Pinia 中 80%的 payload 拼写错误。

我才明白:高级类型是帮你“在编译时就把 bug 掐死”的工具

现在回头看,那些曾经让我头皮发麻的类型,其实都是纸老虎。掌握这 3 条规则,你会发现:

TypeScript 的高级类型不是用来吓你的,是帮你更安心写代码的。

你正在卡哪个 TypeScript 类型?#

评论区告诉我,我们一起拆解它!如果这篇文章帮你捅破了“高级类型”的窗户纸,点个赞让我知道——你不是一个人在战斗~

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【TS】类型进阶篇,进阶实战运用
https://blog.fridolph.top/posts/2025-06-02__ts05/
作者
Fridolph
发布于
2025-06-02
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录