【TS】从基础到进阶,总结业务中的实践运用
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 | booleantype NoString = ExcludeString<Mixed> // number | boolean(正确过滤掉string)TS 内置了两个常用的 DCT 工具类型,帮你省去自定义的麻烦:
Exclude<T, U>:从 T 中排除所有能赋值给 U 的类型;Extract<T, U>:从 T 中提取所有能赋值给 U 的类型。
比如从数字 union 中提取偶数:
type Numbers = 1 | 2 | 3 | 4type 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的SKUfilterSkus(skus, { size: 'M', color: 'red' }) // ✅
// 错误示例:尺寸传XXL(SKU中没有)filterSkus(skus, { size: 'XXL', color: 'red' }) // ❌ 编译报错!四、带智能回退的条件类型:API 输入的“格式统一器”
API 设计中常遇到“支持单个值或数组”的需求(比如id可以是 1 或[1,2])——带智能回退的条件类型能自动统一格式。
1. 基础逻辑:智能判断数组类型
定义MaybeArray类型:如果输入是数组,返回数组元素类型;否则返回原类型:
// 支持readonly数组的MaybeArraytype 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:单个idconst params1 = { id: 123, category: 'electronics' }const normalized1 = normalizeParams(params1) // { id: [123], category: ['electronics'] }
// 示例2:多个idconst 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的颜色和shadetype 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 真的越来越“懂”你的业务了~
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!