尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TS类型工具实战:从Partial到infer,构建前端类型数据管道

TS类型工具实战:从Partial到infer,构建前端类型数据管道 前不久接了一个 Vue3 后台管理系统的活项目里有几十个接口返回类型混乱前端到处是any改一个字段名要在五个文件里翻。后来我把接口返回类型统一抽出来用 TS 的类型工具做了一层“派生”半小时改完编辑器还顺手把旧字段的引用全部标红。那一刻我意识到类型工具不是面试里拿来炫的技巧它就是你日常写业务代码时最该练的那只手。这篇文章我就围绕 TS 类型工具展开从内置的几个高频工具到底层原理再到 Vue3、React 和接口层怎么组合落地最后会聊一聊“类型工具咬人”时怎么排查。不管你是刚接触Partial的新手还是被infer绕晕的进阶选手应该都能捞到点东西。1. 为什么我把类型工具当成“第二语法”来学1.1 一个让我重新审视类型工具的场景先说点实在的。特别早期的项目里我从接口拿数据特别喜欢写这种let userInfo: any await getUserInfo();那会儿觉得any方便想取userInfo.name就取想塞给表单就塞。直到有一次后端把name改成了nickname我全局搜userInfo.name搜了七八个文件挨个改完还有几处漏网之鱼上线后在用户中心直接显示undefined。后来我把接口返回类型定义好再用类型工具做派生interface UserInfo { id: number; name: string; nickname?: string; avatar: string; phone: string; email?: string; createdAt: string; } // 表单场景只需要部分字段 type UserFormData PickUserInfo, nickname | avatar | phone | email;从那天开始改字段这件事变成了“编辑器教我改”。文档落后了类型定义不会落后人记性会骗人类型系统不会。类型工具说到底就是把这种“派生”从手工变成了自动。1.2 类型工具的定位编译期的“函数”你可以把类型工具理解成一种运行在编译期的函数给它输入一个类型它给你输出一个新的类型。普通函数处理值比如arr.map(x x * 2)类型工具处理的是类型本身比如PartialUserInfo把UserInfo的所有字段变成可选。这个类比很关键因为一旦你接受了“类型工具 类型层面的函数”这个设定后面所有问题都有了思考框架普通函数有参数和返回值类型工具也有“类型参数”和“返回类型”。普通函数能组合类型工具也能组合。普通函数有边界条件类型工具也有never、unknown这些边缘情况。我见过很多同学背了一堆工具名真到写业务的时候还是想不起来用。原因就是他们把类型工具当字典背而不是当函数来理解。字典背的是词义函数理解的是“它能接受什么、吐出什么、怎么搭档”。后面我拆解每个内置工具的时候都会强调它输入是什么、输出是什么、底层实现长什么样。底层实现看懂了名字根本不重要。2. 内置类型工具逐个拆解什么时候用、底层长什么样2.1 字段操作四件套Partial、Required、Pick、Omit这四个是日常频率最高的先说它们。Partial把一个对象类型的所有字段变成可选最典型的场景是“编辑表单”interface User { id: number; name: string; } // 更新用户资料时可能只改了昵称 type UpdateUserParams PartialUser;如果你只更新一个字段接口层用PartialUser就不用每次重新拼一整个对象。这个工具的本质是一个映射类型type MyPartialT { [K in keyof T]?: T[K]; };keyof T取出对象所有键的联合类型in遍历这个联合?把每个键变成可选的。这三行代码你吃透了后面所有内置工具基本都能手写。Required和Partial完全相反把可选字段变成必填。场景很典型用户部分字段是选填的但提交给后端前必须校验完整。interface DraftOrder { userName?: string; address?: string; goodsList?: GoodsItem[]; } type SubmitOrder RequiredDraftOrder;Required的底层实现方式就是把?去掉type MyRequiredT { [K in keyof T]-?: T[K]; };注意-?这个语法它表示“删除可选标记”。类似的还有-readonly。这个语法在编辑器里不常用到但在自定义工具类型里很管用。Pick从对象类型里挑出若干字段。我常常用它做“接口返回类型到页面展示类型”的裁剪interface Product { id: number; name: string; price: number; desc: string; tags: string[]; stock: number; } // 列表页只需要部分字段 type ProductCard PickProduct, id | name | price | tags; // 详情页几乎要全部字段 type ProductDetail OmitProduct, stock;Pick底层也简单type MyPickT, K extends keyof T { [P in K]: T[P]; };这里K extends keyof T是类型约束意思是“K 必须是 T 的键构成的联合类型的子集”。这个约束很重要它保证了你只能挑真正存在的字段。Omit和 Pick 互补排除若干字段。OmitProduct, stock等价于“除了 stock 之外全要”。注意Omit不是直接映射出来的它内部用Pick和Exclude组合type MyOmitT, K extends keyof any PickT, Excludekeyof T, K;Excludekeyof T, K先算出“T 的键集合去掉 K”之后剩下的联合类型再交给 Pick。所以排错的时候看到Omit报错有时候问题出在K没有真正匹配keyof T尤其是传了字面量联合类型时大小写不一致会绕一下。2.2 集合运算与函数推导Exclude、Extract、NonNullable、RecordExclude 与 ExtractExclude 是从联合类型里剔除某些成员Extract 是提取两个联合类型的交集。它们处理的对象不是对象类型而是联合类型。举个例子type Status success | error | loading | idle; // 去掉 loading 和 idle用来做请求成功的状态判断 type FinalStatus ExcludeStatus, loading | idle; // 结果是 success | error // 从两个联合里找都有的 type A a | b | c; type B b | c | d; type Common ExtractA, B; // 结果是 b | c这两个工具底层都是条件类型type MyExcludeT, U T extends U ? never : T; type MyExtractT, U T extends U ? T : never;这里是重点当T是一个联合类型时T extends U会触发“分布”也就是联合的每个成员分别去判断最后把结果再组合成联合。这是 TypeScript 条件类型最核心的行为。理解了分布式条件类型后面很多高级工具你都能自己推出来。NonNullable从类型里去掉null和undefinedtype MaybeName string | null | undefined; type Name NonNullableMaybeName; // 结果是 string底层实现type MyNonNullableT T extends null | undefined ? never : T;因为它也是条件类型所以对联合类型同样是分布式处理的。RecordRecordK, V用来构造一个键类型为 K、值类型为 V 的对象类型。键通常是一个字面量联合值往往是统一的类型。我用它最多的是字典映射type DictKey name | age | email; // 每个 key 都对应一个表单项描述 type FormItemConfig RecordDictKey, { label: string; visible: boolean; placeholder?: string; }; const config: FormItemConfig { name: { label: 名字, visible: true, placeholder: 请输入名字 }, age: { label: 年龄, visible: true }, email: { label: 邮箱, visible: false, placeholder: 请输入邮箱 }, };这里有个细节RecordK, V要求 K 里的每个键都必须出现否则会报缺字段。这个特性在配置表场景下反而是保护。如果某些键确实可以缺可以组合PartialRecordK, V这样键还是受限的但值允许缺。2.3 函数与异步世界的钥匙Parameters、ReturnType、Awaited函数类型也有“结构”。TypeScript 内置了从函数类型里提取参数和返回值的工具。Parameters提取函数参数的类型元组function searchUsers(keyword: string, page: number): PromiseUserInfo[] { // ... } type SearchParams Parameterstypeof searchUsers; // 结果是 [keyword: string, page: number]这个typeof searchUsers是取函数值的类型再用Parameters提取参数。它的一个实战用途是接口请求函数定义好之后自动生成对应 hooks 的入参类型不用写两遍。ReturnType提取函数返回值类型function createUser(payload: UserFormData) { return http.post(/user/create, payload); } type CreateUserResponse ReturnTypetypeof createUser; // PromiseAxiosResponse{ id: number }底层实现靠infertype MyReturnTypeT extends (...args: any) any T extends (...args: any) infer R ? R : any;infer R是在条件类型里声明一个类型变量让 TypeScript 自动推断出返回值类型。这个语法是整个类型工具进阶的最重要基石后面自定义工具那一章我还会展开。AwaitedAwaited是后来新增的工具专门递归解包 Promise 类型type APIResponse PromisePromise{ code: number; data: string[] }; type Data AwaitedAPIResponse; // 结果是 { code: number; data: string[] }在接口层经常有这种场景函数返回PromiseResultT而你要的是ResultT或者Tasync function fetchUserList() { return http.getPageResultUserInfo(/user/list); } type UserListData AwaitedReturnTypetypeof fetchUserList;这一行是我接口数据管道里出场率最高的组合。它把“函数返回的 Promise 解开拿到真实类型”这件事自动化了。官方Awaited的处理比手工递归更严谨它还会解包PromiseLike能在 thenable 场景下正常工作建议优先用官方版本。3. 从接口数据到页面状态类型工具的“数据管道”组合用法3.1 后端返回类型到前端类型的映射单个工具用起来不难难的是组合。我习惯在项目里把接口的数据管道分成三层后端返回类型、前端领域类型、页面状态类型。第一层定义后端返回的原始结构。这层尽量贴接口文档不加修饰interface ApiResponseT { code: number; message: string; data: T; } interface RawOrder { orderId: string; userNick: string; goodsList: RawGoods[]; totalPrice: number; createTime: string; }第二层前端领域类型。后端字段可能命名不规范、层级过深、还有不需要下发的内部字段这层负责“净化”// 不要把整个 RawOrder 直接塞给页面 type OrderCard { id: string; buyerName: string; goodsName: string[]; amount: number; placedAt: Date; };第三层页面状态类型。和 UI 状态相关可能有loading、selected、expanded这些 UI 字段type OrderCardState OrderCard { selected: boolean; expanded: boolean; };这个管道不是靠手写一整套而是用工具从第一层派生。最常见的方式// 列表接口返回 RawOrder[]页面只需要一部分字段 type OrderCard PickRawOrder, orderId | userNick | totalPrice { placedAt: Date; // 转换时间类型 goodsCount: number; // 计算派生字段 };如果后端返回的结构经常变化多包一层的收益会很高。别小看这个Pick它让“接口变更”的影响范围从“全项目搜索”收缩到“一个类型定义”。3.2 Vue3 组合式 API 中的典型用法Vue3 的组合式 API 和 TS 的契合度很高类型工具在里面能省掉大量重复类型定义。defineProps 与 Pick/Omit 组合子组件接收父组件传入的 props最常见的做法是interface Props { id: number; name: string; price: number; desc?: string; } const props definePropsProps();但有一种很烦的重复父组件已经把某个对象的完整类型定义好了子组件只需要其中一部分。这时候用Pick// 定义在某个共享 types 文件里 interface Product { id: number; name: string; price: number; desc: string; stock: number; category: string; } // 子组件只需要展示基础信息 const props definePropsPickProduct, id | name | price();如果子组件需要大部分字段但要去掉一两个用Omit反向操作。这样保证 props 和源类型永远同步源类型加字段props 类型不会漏。ref 包裹后的类型工具ref会给值套一层 Ref 类型。有时候你把一个 API 返回类型塞进 ref 时会遇到嵌套问题const userList refUserInfo[]([]);大多数时候没问题。但当你想把一个“接口函数返回值”直接塞进 ref 时ReturnType和Awaited就派上用场了function fetchUserList() { return http.getApiResponseUserInfo[](/user/list); } const userList refAwaitedReturnTypetypeof fetchUserList | null(null); // 如果不想要 ApiResponse 这层壳还可以继续 Pick写得太长会显得啰嗦那你可以抽一个自定义类型工具比如ApiReturnT在自定义类型工具那一章我会给实现。reactive 与 ReplaceVue3 的reactive会把类型里的嵌套对象也变成响应式代理官方工具类型UnwrapRef和ToRefs经常配合组合式 API 使用。比如你想把一个 reactive 对象变成 refsimport { reactive, toRefs } from vue; const state reactive({ user: null as UserInfo | null, loading: false, }); const { user, loading } toRefs(state);toRefs的返回类型是ToRefsT它会在内部把每个字段拆成 Ref。这里不需要手写类型但理解ToRefs的实现逻辑映射类型 Ref包裹有助于排查“为什么拿到的类型是RefUserInfo | null”这类问题。3.3 React TS 里的常用工具组合React 团队的类型包types/react里也有一堆内建类型工具配合生态里的类型工具很好用。ComponentProps 从组件提取 props这个工具不是 TS 核心库的是types/react导出的ComponentProps它可以从任意组件类型提取 props 类型import type { ComponentProps } from react; type ButtonProps ComponentPropstypeof Button; // 封装一个带 loading 的按钮 type LoadingButtonProps OmitButtonProps, onClick { loading?: boolean; onClick?: (event: React.MouseEventHTMLButtonElement) void; };这样封装组件的时候不需要手动声明原组件所有的 props少写一大段重复代码原组件 props 变化时封装层也自动跟着变。事件对象类型React 事件处理的类型名很长用React.MouseEventHTMLButtonElement这种写起来很吃力。可以先用ComponentPropsbutton提取或者给事件处理器单独封装import type { ChangeEvent } from react; function handleInputChange(e: ChangeEventHTMLInputElement) { const value e.target.value; }如果你处理的是自定义组件的事件ComponentPropstypeof CustomInput[onChange]这种索引访问也特别实用。useRef 与初始值类型useRef 有重载初始值是null时 ref 的类型通常是RefObjectT | null。在 React 19 的类型体系里useRef也做了一些调整如果你在做 React 19 TS 项目建议升级后重点看一下useRef相关类型的 diff。这里有一个通用排查思路遇到 ref 类型报错先确认你传给useRef的初始值是不是null再确认你这个 ref 是给 DOM 元素用还是保存可变值用的两种场景选型完全不同。4. 自定义类型工具用条件类型和 infer 写自己的工具函数4.1 条件类型思维extends 不是限制是自动机内置工具终究有限业务里总有“Partial 不够深、Pick 不够灵活”的情况。自己写工具类型核心是理解条件类型的行为。最基础的条件类型长这样type IsStringT T extends string ? true : false; type R1 IsStringhello; // true type R2 IsString123; // false type R3 IsStringstring | number; // boolean最后一行要特别注意当T是联合类型时条件类型会分发。string | number中的每个成员分别判断结果是boolean也就是true | false。这是新手最容易懵的地方。避免分发的方法是把泛型参数包一层type NoDistributeT T extends any ? (T extends string ? true : false) : never; type R4 NoDistributestring | number; // false不过一般情况下我们希望它分发因为联合类型分发正是Exclude、Extract这些工具能工作的原因。理解了分发再看一个经典场景判断两个类型是否相等。这个需求在实际开发里比想象中频繁比如判断“这个字段类型是不是默认的 string”。type IsEqualA, B (T() T extends A ? 1 : 2) extends T() T extends B ? 1 : 2 ? true : false;这个实现借助了函数参数逆变性的技巧初看像魔法但它解决的是普通条件类型无法区分any与其他类型的问题。在你纠结“为什么我的T extends U判断不准确”的时候可以想到这个方案。4.2 infer类型世界里的模式匹配infer是在条件类型中声明一个“待推断类型变量”的机制。你可以把它理解成正则表达式的捕获组T extends SomePatterninfer R ? R : never就是从 T 里按模式拆出某一部分。前端开发最常见的 infer 模式是提取数组元素类型type ElementOfT T extends Arrayinfer E ? E : never; type A ElementOfstring[]; // string type B ElementOfArray{ id: number }; // { id: number }这个工具在表格分页场景特别常用。你有一个分页接口返回{ list: T, total: number }你想从PageResultUserInfo里把UserInfo单独拿出来type PageResultT { list: T[]; total: number; }; type ExtractPageItemT extends PageResultany T extends PageResultinfer U ? U : never; type User ExtractPageItemPageResultUserInfo; // UserInfo再进一步我们结合ReturnType和Awaited写一个从接口函数直接提取数据类型的工具type ApiDataT extends (...args: any) any AwaitedReturnTypeT extends { data: infer D } ? D : never;这样上一章那个refAwaitedReturnTypetypeof fetchUserList | null就能简化成type UserListData ApiDatatypeof fetchUserList; const userList refUserListData | null(null);接口函数改返回结构这个类型自动跟着变。这就是 infer 的威力不需要手动声明让编译器自己拆。4.3 我维护的一套高复用自定义工具这里分享几个我长期在项目里用的、不依赖任何框架的自定义工具类型。每个都附上用途和踩坑点。DeepPartialPartial对嵌套对象只做一层数据初始化场景往往需要递归地把嵌套也变成可选type DeepPartialT T extends object ? { [K in keyof T]?: DeepPartialT[K] } : T;注意函数类型和 Date、Map 这类特殊对象它们也满足extends object所以可以加一层过滤type DeepPartialT T extends (...args: any) any ? T : T extends Mapany, any ? T : T extends Setany ? T : T extends object ? { [K in keyof T]?: DeepPartialT[K] } : T;实际项目里如果表单数据里没有 Map/Set第一版就够。但要是你的表单里出现过Date递归会把Date也展开成{ getTime?: ... }那就要小心了。所以我一般保留特殊对象的过滤逻辑。ValueOf取出对象所有属性值的联合类型type ValueOfT T[keyof T]; interface Dict { a: number; b: string; c: boolean; } type V ValueOfDict; // number | string | boolean这个工具在做枚举映射、把对象的 value 当作联合类型校验时很有用。比如表单规则的 config 对象你有若干 key对应的校验函数类型各不相同ValueOf 就能派上用场。Nullable传入 null/undefined生成一个可空的类型和 NonNullable 正好相反。这个工具适合写“还没有初始值但将来会赋值”的状态type NullableT T | null | undefined; const currentUser refNullableUserInfo(null);其实这种直接用T | null写在字面上更直白。但如果你有统一风格或者想给团队定一个“显式允许空值”的口径包装成 Nullable 语义更清楚。注意刻意把“不必要”的类型变复杂也是负担工具类型优先服务于表达清晰而不是追求抽象。StartsWith 字面量前缀判断配合路由路径或枚举字符串使用时可以用条件类型做字面量层面的判断type StartsWithT extends string, P extends string T extends ${P}${string} ? true : false; type R1 StartsWith/user/list, /user; // true type R2 StartsWith/order/list, /user; // false这个工具文本比较场景不多但在写路由 guards 类型约束或者事件命名检查时挺有意思。它展示了 TS 在模板字面量类型上的能力也展示了infer在字符串模式匹配中的应用。手写 Partial 的完整版本最后建议大家抄一遍官方内置工具的实现。我不建议背代码而是理解思路。比如Parameters、ReturnType、Awaited这些全部理解之后你看到任何新的工具类型都不会慌因为它们都是“映射类型 条件类型 infer”的排列组合。5. 当类型工具咬人报错排查与项目性能优化5.1 “JS/TS 语言服务已立即崩溃 5 次”全链路排查这个报错应该有不少人见过JS/TS 语言服务已立即崩溃 5 次。不会重新启动该服务。这个错误往往不是单个类型工具的语法错误而是语言服务进程本身扛不住了。我遇到过几次排查链路大概是这样第一步看是不是单个文件类型太复杂。我曾经写过一个“字段类型二选一校验”的递归条件类型嵌套层数很深加上文件很大语言服务直接崩。这种情况的解决思路是把复杂类型拆到独立文件限制泛型嵌套层级尽量用缓存好的中间类型而不是每次都在超大类型上做重复条件判断。第二步看项目整体规模也就是热门词里说的“ts 分片”。当项目 src 下文件特别多tsc 和 VSCode 语言服务默认会对整个项目做类型检查内存飙升。解决办法是用incremental、tsBuildInfoFile以及项目引用Project References把大项目切成多个小块增量编译{ compilerOptions: { incremental: true, tsBuildInfoFile: ./node_modules/.tmp/tsconfig.app.tsbuildinfo } }第三步排除插件干扰。VSCode 的 TS 插件、ESLint 插件版本冲突也可能导致语言服务崩溃。可以临时禁用所有扩展只保留 TS 核心看是否复现。最后一步才是升级 TypeScript 版本。很多语言服务崩溃是旧版本编译器在解析某些新语法时失手升级后会修复。我自己的经验是类型工具本身不是性能杀手滥用类型工具尤其是深层递归条件类型才是杀手。判断标准很简单同一个工具类型你在几个地方复用 vs 在一个地方构造 500 行巨型条件类型后者对语言服务的压力完全不同。优化方向永远是“简化类型结构 拆分文件 增量构建”。5.2 一个 Vue3 TS 类型报错的完整排查另一个高频场景是模板里直接报类型错误项目是 Vite Vue3 TS 搭的脚手架。如果你刚用脚手架创建项目发现defineProps里字段类型不识别或者tsc一直报“找不到模块 ./types”先检查tsconfig的include和paths配置{ compilerOptions: { strict: true, jsx: preserve, moduleResolution: bundler, baseUrl: ., paths: { /*: [src/*] } }, include: [src/**/*.ts, src/**/*.d.ts, src/**/*.vue] }我踩过一次比较典型的坑接口函数写好了ReturnTypetypeof fetchUserList拿到的是一个PromiseApiResponseUserInfo[]结果在 store 里想用它初始化userList编辑器报了类似这样的错误Type UserInfo | null is not assignable to type UserInfo | undefined.原因在于ref的类型参数推断和可选链、非空断言混在一起。解决办法是把“接口返回值类型”拆清楚先AwaitedReturnType...解包 Promise再Pick出 data 字段最后才给ref。这类报错有个共同特征错误信息字面上说的是某两个类型不匹配但真正的问题是你在类型数据管道中间少做了一步转换。排查的顺序应该是先看报错行引用的类型是从哪里派生的再倒推到接口返回类型最后对齐中间转换。5.3 类型工具误用最容易翻车的 3 个点我总结三个高发坑都是实际项目里遇过的。第一把 Omit 用错在联合类型上。Omit是针对对象类型的如果你对一个联合类型使用可能拿不到预期结果。应该先把这个联合类型拆开或者用Exclude处理联合。第二过度使用 DeepPartial 导致真正的必填校验失效。表单场景用 DeepPartial 很爽但提交时你可能需要“全部必填”。如果一直拿 DeepPartial 当参数类型提交函数内部拿到的所有字段都是可选的每个字段都要再判空。正确做法是编辑时用 Partial/DeepPartial提交时用 Required或者干脆定义两个类型代码里逐层转换。第三条件类型里的裸类型参数看似没问题实则分发结果出乎意料。比如判断某个字段是否可选类型时如果你用了T extends undefined ? ...碰到string | undefined会发分成两次判断结果可能是boolean而不是预期的一个明确值。遇到这种场景先把联合类型归一化再判断。这些坑的共同点是它们不一定让编辑器报错但会让你的类型推导结果不对劲等到运行时才发现。所以类型工具写完之后建议用一两行“类型测试”快速验证type AssertT extends true T; // 验证一下 Pick 得到的位置 type Test AssertIsEqualPickProduct, id | name, { id: number; name: string };自己维护一个小型type-tests.ts把所有重要的工具类型断言放里面每次改完类型定义跑一下编辑器检查成本低收益稳定。说回最开始那个项目。我把接口层到页面层的类型数据管道理顺之后连续几个月几乎没有因为字段名改动出过线上问题。类型工具真正的作用不是让你显得很懂 TypeScript而是把你从“手工记忆字段名、猜接口结构”的泥潭里拽出来让编译器替你做那件最容易漏的事。后面我每次新建项目第一件事就是先定一套“接口函数 返回值提取工具 页面状态映射”的类型地基后面写业务基本就一路绿灯了。身体还是很诚实的——用顺手之后就再也回不到any满天飞的日子了。
返回列表