news 2026/9/15 12:56:33

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 = Pick<UserInfo, 'nickname' | 'avatar' | 'phone' | 'email'>;

从那天开始,改字段这件事变成了“编辑器教我改”。文档落后了,类型定义不会落后;人记性会骗人,类型系统不会。类型工具说到底,就是把这种“派生”从手工变成了自动。

1.2 类型工具的定位:编译期的“函数”

你可以把类型工具理解成一种运行在编译期的函数:给它输入一个类型,它给你输出一个新的类型。普通函数处理值,比如arr.map(x => x * 2),类型工具处理的是类型本身,比如Partial<UserInfo>UserInfo的所有字段变成可选。

这个类比很关键,因为一旦你接受了“类型工具 = 类型层面的函数”这个设定,后面所有问题都有了思考框架:

  • 普通函数有参数和返回值,类型工具也有“类型参数”和“返回类型”。
  • 普通函数能组合,类型工具也能组合。
  • 普通函数有边界条件,类型工具也有neverunknown这些边缘情况。

我见过很多同学背了一堆工具名,真到写业务的时候还是想不起来用。原因就是他们把类型工具当字典背,而不是当函数来理解。字典背的是词义,函数理解的是“它能接受什么、吐出什么、怎么搭档”。后面我拆解每个内置工具的时候,都会强调它输入是什么、输出是什么、底层实现长什么样。底层实现看懂了,名字根本不重要。

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不是直接映射出来的,它内部用PickExclude组合:

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

从类型里去掉nullundefined

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 状态相关,可能有loadingselectedexpanded这些 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 时,ReturnTypeAwaited就派上用场了:

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会把类型里的嵌套对象也变成响应式代理,官方工具类型UnwrapRefToRefs经常配合组合式 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

不过一般情况下我们希望它分发,因为联合类型分发正是ExcludeExtract这些工具能工作的原因。

理解了分发,再看一个经典场景:判断两个类型是否相等。这个需求在实际开发里比想象中频繁,比如判断“这个字段类型是不是默认的 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

再进一步,我们结合ReturnTypeAwaited,写一个从接口函数直接提取数据类型的工具:

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 的完整版本

最后,建议大家抄一遍官方内置工具的实现。我不建议背代码,而是理解思路。比如ParametersReturnTypeAwaited这些全部理解之后,你看到任何新的工具类型都不会慌,因为它们都是“映射类型 + 条件类型 + infer”的排列组合。

5. 当类型工具咬人:报错排查与项目性能优化

5.1 “JS/TS 语言服务已立即崩溃 5 次”全链路排查

这个报错应该有不少人见过:

JS/TS 语言服务已立即崩溃 5 次。不会重新启动该服务。

这个错误往往不是单个类型工具的语法错误,而是语言服务进程本身扛不住了。我遇到过几次,排查链路大概是这样:

第一步,看是不是单个文件类型太复杂。我曾经写过一个“字段类型二选一校验”的递归条件类型,嵌套层数很深,加上文件很大,语言服务直接崩。这种情况的解决思路是:把复杂类型拆到独立文件,限制泛型嵌套层级,尽量用缓存好的中间类型,而不是每次都在超大类型上做重复条件判断。

第二步,看项目整体规模,也就是热门词里说的“ts 分片”。当项目 src 下文件特别多,tsc 和 VSCode 语言服务默认会对整个项目做类型检查,内存飙升。解决办法是用incrementaltsBuildInfoFile以及项目引用(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'”,先检查tsconfigincludepaths配置:

{ "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满天飞的日子了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 12:53:32

自研富文本编辑器核心设计:从contenteditable到JSON数据模型

我前前后后用坏过好几个“editor”方案&#xff0c;最开始图省事直接引第三方富文本组件&#xff0c;结果到了项目后期菜单打架、样式污染、光标乱跳&#xff0c;改起来比重新写一个还费劲。后来我索性自己做了一个叫 editor 的文本编辑小组件&#xff0c;不追求功能大而全&…

作者头像 李华
网站建设 2026/9/15 12:52:46

图相似度模型实战:SimGNN工业落地全链路解析

1. 什么是图相似度模型&#xff1a;不是“看图说话”&#xff0c;而是让机器真正理解结构关系“图相似度模型”这六个字&#xff0c;乍一听像AI圈里又一个高冷术语&#xff0c;但其实它解决的是我们每天都在面对、却极少被意识到的底层问题——两个复杂系统之间&#xff0c;到底…

作者头像 李华