前不久接了一个 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 = Pick<UserInfo, 'nickname' | 'avatar' | 'phone' | 'email'>;从那天开始,改字段这件事变成了“编辑器教我改”。文档落后了,类型定义不会落后;人记性会骗人,类型系统不会。类型工具说到底,就是把这种“派生”从手工变成了自动。
1.2 类型工具的定位:编译期的“函数”
你可以把类型工具理解成一种运行在编译期的函数:给它输入一个类型,它给你输出一个新的类型。普通函数处理值,比如arr.map(x => x * 2),类型工具处理的是类型本身,比如Partial<UserInfo>把UserInfo的所有字段变成可选。
这个类比很关键,因为一旦你接受了“类型工具 = 类型层面的函数”这个设定,后面所有问题都有了思考框架:
- 普通函数有参数和返回值,类型工具也有“类型参数”和“返回类型”。
- 普通函数能组合,类型工具也能组合。
- 普通函数有边界条件,类型工具也有
never、unknown这些边缘情况。
我见过很多同学背了一堆工具名,真到写业务的时候还是想不起来用。原因就是他们把类型工具当字典背,而不是当函数来理解。字典背的是词义,函数理解的是“它能接受什么、吐出什么、怎么搭档”。后面我拆解每个内置工具的时候,都会强调它输入是什么、输出是什么、底层实现长什么样。底层实现看懂了,名字根本不重要。
2. 内置类型工具逐个拆解:什么时候用、底层长什么样
2.1 字段操作四件套:Partial、Required、Pick、Omit
这四个是日常频率最高的,先说它们。
Partial
把一个对象类型的所有字段变成可选,最典型的场景是“编辑表单”:
interface User { id: number; name: string; } // 更新用户资料时,可能只改了昵称 type UpdateUserParams = Partial<User>;如果你只更新一个字段,接口层用Partial<User>就不用每次重新拼一整个对象。这个工具的本质是一个映射类型:
type MyPartial<T> = { [K in keyof T]?: T[K]; };keyof T取出对象所有键的联合类型,in遍历这个联合,?把每个键变成可选的。这三行代码你吃透了,后面所有内置工具基本都能手写。
Required
和Partial完全相反,把可选字段变成必填。场景很典型:用户部分字段是选填的,但提交给后端前必须校验完整。
interface DraftOrder { userName?: string; address?: string; goodsList?: GoodsItem[]; } type SubmitOrder = Required<DraftOrder>;Required的底层实现方式就是把?去掉:
type MyRequired<T> = { [K in keyof T]-?: T[K]; };注意-?这个语法,它表示“删除可选标记”。类似的还有-readonly。这个语法在编辑器里不常用到,但在自定义工具类型里很管用。
Pick
从对象类型里挑出若干字段。我常常用它做“接口返回类型到页面展示类型”的裁剪:
interface Product { id: number; name: string; price: number; desc: string; tags: string[]; stock: number; } // 列表页只需要部分字段 type ProductCard = Pick<Product, 'id' | 'name' | 'price' | 'tags'>; // 详情页几乎要全部字段 type ProductDetail = Omit<Product, 'stock'>;Pick底层也简单:
type MyPick<T, K extends keyof T> = { [P in K]: T[P]; };这里K extends keyof T是类型约束,意思是“K 必须是 T 的键构成的联合类型的子集”。这个约束很重要,它保证了你只能挑真正存在的字段。
Omit
和 Pick 互补,排除若干字段。Omit<Product, 'stock'>等价于“除了 stock 之外全要”。注意Omit不是直接映射出来的,它内部用Pick和Exclude组合:
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;Exclude<keyof T, K>先算出“T 的键集合去掉 K”之后剩下的联合类型,再交给 Pick。所以排错的时候,看到Omit报错,有时候问题出在K没有真正匹配keyof T,尤其是传了字面量联合类型时大小写不一致,会绕一下。
2.2 集合运算与函数推导:Exclude、Extract、NonNullable、Record
Exclude 与 Extract
Exclude 是从联合类型里剔除某些成员,Extract 是提取两个联合类型的交集。它们处理的对象不是对象类型,而是联合类型。举个例子:
type Status = 'success' | 'error' | 'loading' | 'idle'; // 去掉 loading 和 idle,用来做请求成功的状态判断 type FinalStatus = Exclude<Status, 'loading' | 'idle'>; // 结果是 'success' | 'error' // 从两个联合里找都有的 type A = 'a' | 'b' | 'c'; type B = 'b' | 'c' | 'd'; type Common = Extract<A, B>; // 结果是 'b' | 'c'这两个工具底层都是条件类型:
type MyExclude<T, U> = T extends U ? never : T; type MyExtract<T, U> = T extends U ? T : never;这里是重点:当T是一个联合类型时,T extends U会触发“分布”,也就是联合的每个成员分别去判断,最后把结果再组合成联合。这是 TypeScript 条件类型最核心的行为。理解了分布式条件类型,后面很多高级工具你都能自己推出来。
NonNullable
从类型里去掉null和undefined:
type MaybeName = string | null | undefined; type Name = NonNullable<MaybeName>; // 结果是 string底层实现:
type MyNonNullable<T> = T extends null | undefined ? never : T;因为它也是条件类型,所以对联合类型同样是分布式处理的。
Record
Record<K, V>用来构造一个键类型为 K、值类型为 V 的对象类型。键通常是一个字面量联合,值往往是统一的类型。我用它最多的是字典映射:
type DictKey = 'name' | 'age' | 'email'; // 每个 key 都对应一个表单项描述 type FormItemConfig = Record<DictKey, { label: string; visible: boolean; placeholder?: string; }>; const config: FormItemConfig = { name: { label: '名字', visible: true, placeholder: '请输入名字' }, age: { label: '年龄', visible: true }, email: { label: '邮箱', visible: false, placeholder: '请输入邮箱' }, };这里有个细节:Record<K, V>要求 K 里的每个键都必须出现,否则会报缺字段。这个特性在配置表场景下反而是保护。如果某些键确实可以缺,可以组合Partial<Record<K, V>>,这样键还是受限的,但值允许缺。
2.3 函数与异步世界的钥匙:Parameters、ReturnType、Awaited
函数类型也有“结构”。TypeScript 内置了从函数类型里提取参数和返回值的工具。
Parameters
提取函数参数的类型元组:
function searchUsers(keyword: string, page: number): Promise<UserInfo[]> { // ... } type SearchParams = Parameters<typeof searchUsers>; // 结果是 [keyword: string, page: number]这个typeof searchUsers是取函数值的类型,再用Parameters提取参数。它的一个实战用途是:接口请求函数定义好之后,自动生成对应 hooks 的入参类型,不用写两遍。
ReturnType
提取函数返回值类型:
function createUser(payload: UserFormData) { return http.post('/user/create', payload); } type CreateUserResponse = ReturnType<typeof createUser>; // Promise<AxiosResponse<{ id: number }>>底层实现靠infer:
type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;infer R是在条件类型里声明一个类型变量,让 TypeScript 自动推断出返回值类型。这个语法是整个类型工具进阶的最重要基石,后面自定义工具那一章我还会展开。
Awaited
Awaited是后来新增的工具,专门递归解包 Promise 类型:
type APIResponse = Promise<Promise<{ code: number; data: string[] }>>; type Data = Awaited<APIResponse>; // 结果是 { code: number; data: string[] }在接口层经常有这种场景:函数返回Promise<Result<T>>,而你要的是Result<T>或者T:
async function fetchUserList() { return http.get<PageResult<UserInfo>>('/user/list'); } type UserListData = Awaited<ReturnType<typeof fetchUserList>>;这一行是我接口数据管道里出场率最高的组合。它把“函数返回的 Promise 解开,拿到真实类型”这件事自动化了。官方Awaited的处理比手工递归更严谨,它还会解包PromiseLike,能在 thenable 场景下正常工作,建议优先用官方版本。
3. 从接口数据到页面状态:类型工具的“数据管道”组合用法
3.1 后端返回类型到前端类型的映射
单个工具用起来不难,难的是组合。我习惯在项目里把接口的数据管道分成三层:后端返回类型、前端领域类型、页面状态类型。
第一层,定义后端返回的原始结构。这层尽量贴接口文档,不加修饰:
interface ApiResponse<T> { 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 = Pick<RawOrder, '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 = defineProps<Props>();但有一种很烦的重复:父组件已经把某个对象的完整类型定义好了,子组件只需要其中一部分。这时候用Pick:
// 定义在某个共享 types 文件里 interface Product { id: number; name: string; price: number; desc: string; stock: number; category: string; } // 子组件只需要展示基础信息 const props = defineProps<Pick<Product, 'id' | 'name' | 'price'>>();如果子组件需要大部分字段但要去掉一两个,用Omit反向操作。这样保证 props 和源类型永远同步,源类型加字段,props 类型不会漏。
ref 包裹后的类型工具
ref会给值套一层 Ref 类型。有时候你把一个 API 返回类型塞进 ref 时,会遇到嵌套问题:
const userList = ref<UserInfo[]>([]);大多数时候没问题。但当你想把一个“接口函数返回值”直接塞进 ref 时,ReturnType和Awaited就派上用场了:
function fetchUserList() { return http.get<ApiResponse<UserInfo[]>>('/user/list'); } const userList = ref<Awaited<ReturnType<typeof fetchUserList>> | null>(null); // 如果不想要 ApiResponse 这层壳,还可以继续 Pick写得太长会显得啰嗦?那你可以抽一个自定义类型工具,比如ApiReturn<T>,在自定义类型工具那一章我会给实现。
reactive 与 Replace
Vue3 的reactive会把类型里的嵌套对象也变成响应式代理,官方工具类型UnwrapRef和ToRefs经常配合组合式 API 使用。比如你想把一个 reactive 对象变成 refs:
import { reactive, toRefs } from 'vue'; const state = reactive({ user: null as UserInfo | null, loading: false, }); const { user, loading } = toRefs(state);toRefs的返回类型是ToRefs<T>,它会在内部把每个字段拆成 Ref。这里不需要手写类型,但理解ToRefs的实现逻辑(映射类型 +Ref包裹)有助于排查“为什么拿到的类型是Ref<UserInfo | null>”这类问题。
3.3 React + TS 里的常用工具组合
React 团队的类型包@types/react里也有一堆内建类型工具,配合生态里的类型工具很好用。
ComponentProps 从组件提取 props
这个工具不是 TS 核心库的,是@types/react导出的ComponentProps,它可以从任意组件类型提取 props 类型:
import type { ComponentProps } from 'react'; type ButtonProps = ComponentProps<typeof Button>; // 封装一个带 loading 的按钮 type LoadingButtonProps = Omit<ButtonProps, 'onClick'> & { loading?: boolean; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; };这样封装组件的时候不需要手动声明原组件所有的 props,少写一大段重复代码,原组件 props 变化时封装层也自动跟着变。
事件对象类型
React 事件处理的类型名很长,用React.MouseEvent<HTMLButtonElement>这种写起来很吃力。可以先用ComponentProps<'button'>提取,或者给事件处理器单独封装:
import type { ChangeEvent } from 'react'; function handleInputChange(e: ChangeEvent<HTMLInputElement>) { const value = e.target.value; }如果你处理的是自定义组件的事件,ComponentProps<typeof CustomInput>['onChange']这种索引访问也特别实用。
useRef 与初始值类型
useRef 有重载,初始值是null时 ref 的类型通常是RefObject<T | null>。在 React 19 的类型体系里,useRef也做了一些调整,如果你在做 React 19 + TS 项目,建议升级后重点看一下useRef相关类型的 diff。这里有一个通用排查思路:遇到 ref 类型报错,先确认你传给useRef的初始值是不是null,再确认你这个 ref 是给 DOM 元素用还是保存可变值用的,两种场景选型完全不同。
4. 自定义类型工具:用条件类型和 infer 写自己的工具函数
4.1 条件类型思维:extends 不是限制,是自动机
内置工具终究有限,业务里总有“Partial 不够深、Pick 不够灵活”的情况。自己写工具类型,核心是理解条件类型的行为。
最基础的条件类型长这样:
type IsString<T> = T extends string ? true : false; type R1 = IsString<'hello'>; // true type R2 = IsString<123>; // false type R3 = IsString<string | number>; // boolean最后一行要特别注意:当T是联合类型时,条件类型会分发。string | number中的每个成员分别判断,结果是boolean,也就是true | false。这是新手最容易懵的地方。
避免分发的方法是把泛型参数包一层:
type NoDistribute<T> = T extends any ? (T extends string ? true : false) : never; type R4 = NoDistribute<string | number>; // false不过一般情况下我们希望它分发,因为联合类型分发正是Exclude、Extract这些工具能工作的原因。
理解了分发,再看一个经典场景:判断两个类型是否相等。这个需求在实际开发里比想象中频繁,比如判断“这个字段类型是不是默认的 string”。
type IsEqual<A, 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 SomePattern<infer R> ? R : never,就是从 T 里按模式拆出某一部分。
前端开发最常见的 infer 模式是提取数组元素类型:
type ElementOf<T> = T extends Array<infer E> ? E : never; type A = ElementOf<string[]>; // string type B = ElementOf<Array<{ id: number }>>; // { id: number }这个工具在表格分页场景特别常用。你有一个分页接口,返回{ list: T, total: number },你想从PageResult<UserInfo>里把UserInfo单独拿出来:
type PageResult<T> = { list: T[]; total: number; }; type ExtractPageItem<T extends PageResult<any>> = T extends PageResult<infer U> ? U : never; type User = ExtractPageItem<PageResult<UserInfo>>; // UserInfo再进一步,我们结合ReturnType和Awaited,写一个从接口函数直接提取数据类型的工具:
type ApiData<T extends (...args: any) => any> = Awaited<ReturnType<T>> extends { data: infer D } ? D : never;这样上一章那个ref<Awaited<ReturnType<typeof fetchUserList>> | null>就能简化成:
type UserListData = ApiData<typeof fetchUserList>; const userList = ref<UserListData | null>(null);接口函数改返回结构,这个类型自动跟着变。这就是 infer 的威力:不需要手动声明,让编译器自己拆。
4.3 我维护的一套高复用自定义工具
这里分享几个我长期在项目里用的、不依赖任何框架的自定义工具类型。每个都附上用途和踩坑点。
DeepPartial
Partial对嵌套对象只做一层,数据初始化场景往往需要递归地把嵌套也变成可选:
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;注意函数类型和 Date、Map 这类特殊对象,它们也满足extends object,所以可以加一层过滤:
type DeepPartial<T> = T extends (...args: any) => any ? T : T extends Map<any, any> ? T : T extends Set<any> ? T : T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;实际项目里如果表单数据里没有 Map/Set,第一版就够。但要是你的表单里出现过Date,递归会把Date也展开成{ getTime?: ... },那就要小心了。所以我一般保留特殊对象的过滤逻辑。
ValueOf
取出对象所有属性值的联合类型:
type ValueOf<T> = T[keyof T]; interface Dict { a: number; b: string; c: boolean; } type V = ValueOf<Dict>; // number | string | boolean这个工具在做枚举映射、把对象的 value 当作联合类型校验时很有用。比如表单规则的 config 对象,你有若干 key,对应的校验函数类型各不相同,ValueOf 就能派上用场。
Nullable
传入 null/undefined,生成一个可空的类型,和 NonNullable 正好相反。这个工具适合写“还没有初始值,但将来会赋值”的状态:
type Nullable<T> = T | null | undefined; const currentUser = ref<Nullable<UserInfo>>(null);其实这种直接用T | null写在字面上更直白。但如果你有统一风格,或者想给团队定一个“显式允许空值”的口径,包装成 Nullable 语义更清楚。注意,刻意把“不必要”的类型变复杂也是负担,工具类型优先服务于表达清晰,而不是追求抽象。
StartsWith 字面量前缀判断
配合路由路径或枚举字符串使用时,可以用条件类型做字面量层面的判断:
type StartsWith<T 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"] }我踩过一次比较典型的坑:接口函数写好了,ReturnType<typeof fetchUserList>拿到的是一个Promise<ApiResponse<UserInfo[]>>,结果在 store 里想用它初始化userList,编辑器报了类似这样的错误:
Type 'UserInfo | null' is not assignable to type 'UserInfo | undefined'.原因在于ref的类型参数推断和可选链、非空断言混在一起。解决办法是把“接口返回值类型”拆清楚:先Awaited<ReturnType<...>>解包 Promise,再Pick出 data 字段,最后才给ref。
这类报错有个共同特征:错误信息字面上说的是某两个类型不匹配,但真正的问题是你在类型数据管道中间少做了一步转换。排查的顺序应该是:先看报错行引用的类型是从哪里派生的,再倒推到接口返回类型,最后对齐中间转换。
5.3 类型工具误用最容易翻车的 3 个点
我总结三个高发坑,都是实际项目里遇过的。
第一,把 Omit 用错在联合类型上。Omit是针对对象类型的,如果你对一个联合类型使用,可能拿不到预期结果。应该先把这个联合类型拆开,或者用Exclude处理联合。
第二,过度使用 DeepPartial 导致真正的必填校验失效。表单场景用 DeepPartial 很爽,但提交时你可能需要“全部必填”。如果一直拿 DeepPartial 当参数类型,提交函数内部拿到的所有字段都是可选的,每个字段都要再判空。正确做法是:编辑时用 Partial/DeepPartial,提交时用 Required,或者干脆定义两个类型,代码里逐层转换。
第三,条件类型里的裸类型参数看似没问题,实则分发结果出乎意料。比如判断某个字段是否可选类型时,如果你用了T extends undefined ? ...,碰到string | undefined会发分成两次判断,结果可能是boolean,而不是预期的一个明确值。遇到这种场景,先把联合类型归一化,再判断。
这些坑的共同点是:它们不一定让编辑器报错,但会让你的类型推导结果不对劲,等到运行时才发现。所以类型工具写完之后,建议用一两行“类型测试”快速验证:
type Assert<T extends true> = T; // 验证一下 Pick 得到的位置 type Test = Assert<IsEqual<Pick<Product, 'id' | 'name'>, { id: number; name: string }>>;自己维护一个小型type-tests.ts,把所有重要的工具类型断言放里面,每次改完类型定义跑一下编辑器检查,成本低收益稳定。
说回最开始那个项目。我把接口层到页面层的类型数据管道理顺之后,连续几个月几乎没有因为字段名改动出过线上问题。类型工具真正的作用不是让你显得很懂 TypeScript,而是把你从“手工记忆字段名、猜接口结构”的泥潭里拽出来,让编译器替你做那件最容易漏的事。后面我每次新建项目,第一件事就是先定一套“接口函数 + 返回值提取工具 + 页面状态映射”的类型地基,后面写业务基本就一路绿灯了。身体还是很诚实的——用顺手之后,就再也回不到any满天飞的日子了。