TypeScript 的魅力在于把隐性约定变成显性约束——但随着业务复杂度上升,基础的interface和type已经不够用了。比如:
- 怎么优雅过滤 union 类型中的“内部成员”?
- 树状数据(导航栏、评论)如何定义类型?
- 怎么避免路由/类名的拼写错误?
趁热打铁,继上篇,今天分享 5 个我在项目中高频使用的 TypeScript 进阶技巧,每个都结合真实场景,帮你把 TS 从“能用”升级到“好用”~
一、分布式条件类型:union 类型的“过滤神器”
你有没有遇到过“从 union 类型中删掉某类成员”的需求?比如从string | number | boolean中排除string——这时候分布式条件类型(DCT)就是你的救星!
1. 基础逻辑:拆 union、逐个判
DCT 的核心是:对 union 类型的每个成员单独应用条件判断,像“拆快递”一样逐个检查。
比如我们想写一个ExcludeString类型,把string从 union 中删掉:
// 逻辑:如果T是string,就返回never(丢弃),否则返回T本身
type ExcludeString<T> = T extends string ? never : T
type Mixed = string | number | boolean
type NoString = ExcludeString<Mixed> // number | boolean(正确过滤掉string)TS 内置了两个常用的 DCT 工具类型,帮你省去自定义的麻烦:
Exclude<T, U>:从 T 中排除所有能赋值给 U 的类型;Extract<T, U>:从 T 中提取所有能赋值给 U 的类型。
比如从数字 union 中提取偶数:
type Numbers = 1 | 2 | 3 | 4
type Even = Extract<Numbers, 2 | 4> // 2 | 4(提取偶数)
type Odd = Exclude<Numbers, Even> // 1 | 3(排除偶数)2. 真实场景:过滤 Redux 的内部 Action
在 Redux 中,我们常定义“内部 Action”(比如日志、监控),这些 Action 不应该暴露给组件。用 DCT 可以轻松过滤:
// 所有Action类型:包含用户操作和内部操作
type Action =
| { type: 'user/login'; payload: { token: string } } // 用户登录
| { type: 'user/logout' } // 用户登出
| { type: 'internal/logger'; payload: { message: string } } // 内部日志
| { type: 'internal/monitor'; payload: { metric: string } } // 内部监控
// 提取所有内部Action(type以internal/开头)
type InternalAction = Extract<Action, { type: `internal/${string}` }>
// 过滤内部Action,得到公共Action类型
type PublicAction = Exclude<Action, InternalAction>
// 组件只能dispatch公共Action,避免误操作!
const dispatchPublic = (action: PublicAction) => dispatch(action)
dispatchPublic({ type: 'user/login', payload: { token: 'xxx' } }) // ✅ 允许
dispatchPublic({ type: 'internal/logger', payload: { message: 'test' } }) // ❌ 编译报错!二、递归类型:树状数据的“完美描述符”
树状数据(导航栏、评论、JSON Schema)的核心是嵌套递归——TypeScript 的递归类型能完美描述这种结构,避免重复定义。
1. 基础用法:定义复杂导航树
实际项目中的导航栏,往往包含icon(图标)、permissions(权限)、children(子菜单)等属性:
// 导航节点类型:支持图标、权限、展开状态
type NavNode = {
id: string // 唯一标识
name: string // 显示名称
icon?: string // 图标(比如"home")
permissions?: string[] // 访问权限(比如["admin", "editor"])
isExpanded?: boolean // 是否展开子菜单
children?: NavNode[] // 子节点(递归引用自己)
}
// 示例导航树:首页+产品中心+设置
const navTree: NavNode[] = [
{ id: 'home', name: '首页', icon: 'home', permissions: ['user', 'admin'] },
{
id: 'products',
name: '产品中心',
icon: 'box',
permissions: ['admin'],
children: [
{ id: 'products-list', name: '产品列表' },
{ id: 'products-add', name: '添加产品', permissions: ['admin'] },
],
},
{
id: 'settings',
name: '设置',
icon: 'gear',
children: [
{ id: 'settings-user', name: '用户设置' },
{ id: 'settings-system', name: '系统设置', permissions: ['admin'] },
],
},
]2. 真实场景:评论线程与 JSON Schema
评论系统的“回复嵌套”是典型的递归场景——用递归类型定义Comment,支持无限层级的回复:
// 评论类型:支持回复、点赞、作者信息
type Comment = {
id: string // 评论ID
content: string // 评论内容
author: { id: string; name: string } // 作者
likes: number // 点赞数
createdAt: string // 创建时间
replies?: Comment[] // 回复列表(递归)
}
// 示例评论:包含两条回复
const comments: Comment[] = [
{
id: 'c1',
content: '这篇文章写得真好!',
author: { id: 'u1', name: 'Alice' },
likes: 10,
createdAt: '2024-01-01',
replies: [
{
id: 'c2',
content: '同意!学到了很多。',
author: { id: 'u2', name: 'Bob' },
likes: 5,
createdAt: '2024-01-02',
},
{
id: 'c3',
content: '请问递归类型怎么处理无限嵌套?',
author: { id: 'u3', name: 'Charlie' },
likes: 3,
createdAt: '2024-01-03',
},
],
},
]三、索引访问类型:嵌套结构的“精准钥匙”
想取对象深层嵌套字段的类型?比如Product['details']['manufacturer']['country']——索引访问类型就是打开嵌套结构的“钥匙”。
1. 基础用法:取深层字段类型
以电商产品为例,我们需要取厂商所在国家的类型:
// 电商产品类型:包含多层嵌套
type Product = {
id: string
name: string
details: {
price: number // 价格
stock: number // 库存
manufacturer: {
name: string // 厂商名称
country: string // 厂商所在国家(目标字段)
}
}
}
// 用索引访问类型取厂商所在国家的类型
type ManufacturerCountry = Product['details']['manufacturer']['country'] // string2. 进阶:通用深层字段工具
如果嵌套层级更深,可以写一个递归的深层字段工具,支持任意层数的嵌套:
// 递归取深层字段:Path是路径数组(比如['details', 'manufacturer', 'country'])
type DeepField<T, Path extends string[]> = Path extends [
infer First,
...infer Rest
]
? First extends keyof T
? Rest extends string[]
? DeepField<T[First], Rest>
: never
: never
: T
// 用法:取Product→details→manufacturer→country的类型
type Country = DeepField<Product, ['details', 'manufacturer', 'country']> // string3. 真实场景:电商 SKU 筛选
电商详情页的 SKU 筛选,需要保证筛选条件与 SKU 的实际属性一致:
// 产品的SKU类型(来自Product['skus'][number])
type Sku = {
id: string
size: 'S' | 'M' | 'L' | 'XL' // 尺寸
color: 'red' | 'blue' | 'green' // 颜色
quantity: number // 库存
}
// 筛选条件类型:size和color来自SKU的类型
type FilterParams = {
size?: Sku['size'] // 只能选S/M/L/XL
color?: Sku['color'] // 只能选red/blue/green
}
// 筛选SKU的函数:确保条件合法
function filterSkus(skus: Sku[], params: FilterParams): Sku[] {
return skus.filter(
(sku) =>
(params.size ? sku.size === params.size : true) &&
(params.color ? sku.color === params.color : true)
)
}
// 正确示例:筛选尺寸M、颜色red的SKU
filterSkus(skus, { size: 'M', color: 'red' }) // ✅
// 错误示例:尺寸传XXL(SKU中没有)
filterSkus(skus, { size: 'XXL', color: 'red' }) // ❌ 编译报错!四、带智能回退的条件类型:API 输入的“格式统一器”
API 设计中常遇到“支持单个值或数组”的需求(比如id可以是 1 或[1,2])——带智能回退的条件类型能自动统一格式。
1. 基础逻辑:智能判断数组类型
定义MaybeArray类型:如果输入是数组,返回数组元素类型;否则返回原类型:
// 支持readonly数组的MaybeArray
type MaybeArray<T> = T extends readonly any[] ? T[number] : T
// 测试:
type ReadonlyArr = readonly string[]
type A = MaybeArray<ReadonlyArr> // string(正确)
type B = MaybeArray<number> // number(正确)2. 真实场景:Axios 参数统一
Axios 的params参数支持单个或多个值,比如id可以是 1 或[1,2]——用MaybeArray统一格式:
// 请求参数类型:id支持单个或多个
type RequestParams = {
id?: number | number[]
category?: string
}
// 统一参数格式:将所有值转为数组
function normalizeParams(params: RequestParams) {
return Object.entries(params).reduce((acc, [key, value]) => {
acc[key as keyof RequestParams] = Array.isArray(value)
? value
: value
? [value]
: []
return acc
}, {} as Record<keyof RequestParams, any[]>)
}
// 示例1:单个id
const params1 = { id: 123, category: 'electronics' }
const normalized1 = normalizeParams(params1) // { id: [123], category: ['electronics'] }
// 示例2:多个id
const params2 = { id: [123, 456], category: 'clothing' }
const normalized2 = normalizeParams(params2) // { id: [123,456], category: ['clothing'] }
// 用Axios发送请求(自动转换为id=123&id=456)
axios.get('/api/products', { params: normalized1 })五、模板字面量类型:字符串的“格式锁”
想避免路由/类名的拼写错误?比如把/home写成home,把bg-blue-700写成bg-blue-600——模板字面量类型能帮你“锁死”字符串格式。
1. 基础用法:约束动态路由
React Router/Vue Router 的动态路由(比如/user/:id),可以用模板字面量类型约束:
// 动态路由类型:支持/user/:id(id为数字)和/product/:id(id为字符串)
type DynamicRoute = `/user/${number}` | `/product/${string}`
// 正确示例:
const userRoute: DynamicRoute = '/user/123' // ✅
const productRoute: DynamicRoute = '/product/abc' // ✅
// 错误示例:
const invalidUser: DynamicRoute = '/user/abc' // ❌(id必须是数字)
const invalidProduct: DynamicRoute = '/product/123' // ❌(id必须是字符串)2. 真实场景:Tailwind 类名约束
Tailwind 的类名有严格格式(比如bg-${color}-${shade}),用模板字面量类型约束:
// 定义Tailwind的颜色和shade
type TailwindColor = 'red' | 'blue' | 'green'
type TailwindShade = 500 | 700 | 900 // 设计系统只用这三个shade
// 约束背景颜色类名:bg-${color}-${shade}
type BgColorClass = `bg-${TailwindColor}-${TailwindShade}`
// 正确示例:
const primaryBtn: BgColorClass = 'bg-blue-700' // ✅
const dangerBtn: BgColorClass = 'bg-red-500' // ✅
// 错误示例:
const invalidBtn: BgColorClass = 'bg-red-600' // ❌(shade不在允许的范围内)
const invalidColor: BgColorClass = 'bg-purple-700' // ❌(color不在允许的范围内)结语:TypeScript 的“进阶”是“用类型思维解决问题”
这 5 个技巧的核心,不是“炫技”,而是把业务规则转化为类型约束:
- 用分布式条件类型过滤内部 Action,避免误操作;
- 用递归类型描述导航树/评论,避免重复定义;
- 用索引访问类型取深层字段,避免拼写错误;
- 用智能回退的条件类型统一 API 参数,避免格式混乱;
- 用模板字面量类型约束字符串,避免拼写错误。
TypeScript 的进阶,从来不是学更复杂的语法,而是学会用类型描述业务——把原来写在注释里的约定,变成编译器能检查的类型,让 bug 在编译时就被消灭。
慢慢来,先把这 5 个技巧用熟,你会发现:TS 真的越来越“懂”你的业务了~
- 本文链接:https://fridolph.top/posts/2025-05-14__ts02
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。