接手过不少别人留下的 TypeScript 项目,最头疼的往往不是类型写得多复杂,而是满屏的 any。类型检查形同虚设,IDE 提示一片空白,重构时改一个字段名,调用链断在哪儿全靠猜。最近我处理一个老项目时就踩了这么个坑:接口返回的用户对象被某处顺手标成了 any,上游把 age 改成了字符串,toFixed() 调用直接运行时崩溃,编译期完全没拦住。
今天这篇 TypeScript 高级技巧实战,主旨很简单:别再写 any 了。我会结合真实业务场景,把 unknown、泛型、条件类型、infer、keyof、映射类型和 satisfies 这些工具怎么用、为什么这样用、以及实在绕不过去时的保底方案一次性讲透。适合写过一阵子 TypeScript、开始在项目里体会到类型安全价值的同学。
1. 先看个反面教材:any 是怎么把类型保护变成摆设的
1.1 一次线上事故的完整复盘
那段闯祸的代码大概是这样的:
typescript复制// 伪代码:从接口拉取用户信息
const res: any = await fetchUser();
const birthday = new Date(res.user.profile.birthday);
console.log(birthday.toISOString());
看着也没什么大不了。但真实情况是:接口某次灰度调整后,profile.birthday 直接不返回了,只留了个 birthdayText。代码在本地 mock 数据下测不出来,上线后用户访问到这一屏,页面白屏。
因为 res 是 any,res.user.profile.birthday 这一整条链上的任何一步拿不到数据,都不会在编译期报错。等到了运行时,new Date(undefined) 结果是 Invalid Date,toISOString() 直接抛 RangeError。找根因时,你看到的是一个莫名其妙的白屏,背后却是类型系统完全失效。
这就是 any 的本质问题:它不是“没有类型”,而是把 TypeScript 的类型检查直接关掉了。你写 any 的地方,代码退回成了 JavaScript,但你还以为自己享受了类型安全的保护,这是最危险的错觉。
1.2 any 的三个隐性代价
结合这类事故,我总结了一下 any 在实际开发里容易被低估的三个代价:
- IDE 智能提示归零。写
res.user.后面不会再弹出profile、name这些字段建议,字段拼错了也不会有人提醒。类型系统之所以有价值,恰恰是它能把“名字”这件事固定下来。 - 重构等于走钢丝。你改了后端字段名,或者前端想把
user.nickname统一成user.name,全局搜索到的引用,在any上完全不受保护。改漏一个地方,运行时不炸则已,一炸就是线上事故。 - 代码可读性和可维护性一起崩。
any本质上是在给读代码的人传递一个信息:“这块我不关心,你自己小心。”但团队协作时,这句话等于没说。后来的接手者不仅不知道字段长什么样,连它是不是字符串、是不是可空都一无所知。
1.3 为什么“随手写 any”会上瘾,以及怎么强制戒掉
写 any 是有“爽感”的:遇到类型报错,直接 as any 压掉,编译立刻通过。这种即时满足会让人形成路径依赖,后面越写越多,直到整个项目的类型保护名存实亡。
我的建议是不要在“人”身上赌,直接在工具链上强制约束:
json复制// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
再配上 ESLint 规则:
json复制{
"rules": {
"@typescript-eslint/no-explicit-any": "error"
}
}
strict: true 会把 noImplicitAny 一并打开,也就是那些“隐式 any”——比如函数参数没有标注类型、TS 推断不出来时——直接报错。no-explicit-any 则禁止手动写 any。这两道闸门一上,新代码里基本不可能再混入裸 any。已经有存量 any 的项目,后面第 6 章我会给一套渐进式迁移方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 unknown 接管“未知类型”:让 JS 数据进入 TS 世界时有据可查
2.1 unknown 和 any 的一字之差
很多人知道 unknown,但不知道它到底比 any 好在哪。一句话就能说明白:any 可以接收任何值,也可以赋给任何类型;unknown 只能接收任何值,不能直接赋给其他类型,也不能直接访问成员。
typescript复制let anyValue: any;
let unknownValue: unknown;
anyValue.foo.bar; // 编译通过,运行时随时崩
unknownValue.foo.bar; // 编译报错,Object is of type 'unknown'
这个“编译报错”不是麻烦,而是保护。它逼着你先确认这个值到底是什么,然后再去使用它。这就是类型收窄(narrowing)的价值。
在实际业务里,unknown 最常见的用途是承接那些“来自外部世界”的数据:JSON.parse 的返回值、fetch 接口的响应体、第三方 SDK 回调里不知道结构的对象。这些数据在编译期确实是未知的,用 unknown 承接,比用 any 直接放行要安全得多。
2.2 类型收窄:让 unknown 变成精确类型
拿到一个 unknown 之后,怎么安全使用?TypeScript 提供了几种收窄手段:
typescript复制function dealWithUnknown(value: unknown) {
if (typeof value === "string") {
// 在这里 value 是 string
return value.trim();
}
if (Array.isArray(value)) {
// 在这里 value 是 any[],还需要进一步收窄
}
}
typeof 能处理原始类型,Array.isArray 能判断数组,instanceof 能判断类实例,in 能判断对象是否有某个属性。这些运行时判断本身是 JavaScript 的能力,但 TypeScript 能利用收窄之后的类型信息,让编译器和你的认知保持一致。
2.3 自定义类型守卫:把脏数据挡在系统入口
实际项目里,接口返回的数据通常是个嵌套对象,光靠 typeof 和 in 不够用。这时候就要写自定义类型守卫函数,用 is 关键字告诉 TypeScript:“只要这个函数返回 true,参数在这个分支里就是某个具体类型。”
typescript复制interface User {
id: number;
name: string;
email: string;
age?: number;
}
function isUser(obj: unknown): obj is User {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
typeof record.id === "number" &&
typeof record.name === "string" &&
typeof record.email === "string" &&
(record.age === undefined || typeof record.age === "number")
);
}
这个 obj is User 就是类型谓词。它把一个“运行时校验函数”和“编译期类型收窄”绑定在一起。用起来非常顺:
typescript复制const data: unknown = await res.json();
if (isUser(data)) {
console.log(data.name.toUpperCase()); // 此时 data 是 User
}
你可能会想:这不就是把 JSON Schema 校验那套东西搬过来了吗?对,思路完全一样。运行时校验是前端拿到外部数据之后的最后一道防线,类型守卫只是它的 TypeScript 表达方式。后端返回的数据一旦不合格,我们在入口处就能拦住,而不是在某个深层属性访问时崩溃。
2.4 实操经验:把 JSON.parse 的返回值从 any 改成 unknown
这里有个大家经常忽略的细节:JSON.parse 在 TypeScript 里的返回类型是 any。也就是说,哪怕你不在代码里写 any,一个普通的 const data = JSON.parse(str) 也会让类型检查形同虚设。所以去 any 化的第一步,就是把它显式标注成 unknown:
typescript复制function safeParse<T = unknown>(text: string): T | null {
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
const result = safeParse<User>(jsonText);
注意这里的 as T 是一个断言,它告诉 TypeScript:“我确定这段 JSON 解析出来的东西就是 T。”这只是一种优化开发体验的简化写法,严格来说断言仍然可能出错。更严谨的做法,是用上一小节的自定义类型守卫再校验一次:
typescript复制const result = safeParse<unknown>(jsonText);
if (isUser(result)) {
// 此时才真正拿到了 User
}
我个人的实践是:在公司内部封装一个 request 函数,接口返回的数据统一先放到 unknown 里,再根据接口文档写对应的守卫函数。虽然写守卫会多花一点时间,但它把后端契约用代码固定下来了,后端字段一旦变更,测试案例立刻能拉住。
3. 泛型:写一次、对所有类型生效的“类型函数”
3.1 泛型不是什么高深概念,就是“类型的参数”
很多同学一看到 <T> 就头皮发麻,觉得这是高级技巧里最劝退的部分。其实泛型的思路特别朴素:函数可以有参数,类型也可以有参数。你不确定这个函数会被什么类型调用,那就把类型留成一个“参数位”,等调用的时候再填。
typescript复制function identity<T>(value: T): T {
return value;
}
const a = identity("hello"); // a 是 string
const b = identity(123); // b 是 number
如果你用 any 写这个函数:
typescript复制function identity(value: any): any {
return value;
}
调用方拿到的返回值也是 any,类型信息就丢了。而泛型方案可以做到“传入什么类型、返回什么类型”,编译器自动推断,调用方完全不需要手动写 <string> 这种尖括号。
3.2 用泛型替代 any 的经典场景:请求封装
项目里最典型的一处 any 滥用,就是请求封装。很多人会写成:
typescript复制async function request(url: string): Promise<any> {
const res = await fetch(url);
return res.json();
}
表面上这个封装很通用,但调用方拿到的 Promise<any> 把类型保护全毁了。改用泛型之后:
typescript复制async function request<T>(url: string): Promise<T> {
const res = await fetch(url);
const data: unknown = await res.json();
return data as T;
}
const user = await request<User>("/api/user");
console.log(user.name); // 此时 user 是 User
注意两点:
res.json()本身返回Promise<any>,所以我在中间用unknown承接了一次,避免any继续传染。data as T是一个有意识的断言。它表示“我信任接口返回的结构,但你最好在入口层做校验”。如果团队要求更严格,可以结合上一章的类型守卫。
泛型真正的价值在于:同一个 request 函数,给十个接口用,就自动有十份不同的返回类型,不用为每个接口写一个封装函数。
3.3 泛型约束 extends 和默认值
泛型参数不是无限自由的。有时候你希望这个类型“必须是某个形状”,这时候就要用 extends 约束。
typescript复制interface HasId {
id: number;
}
async function fetchById<T extends HasId>(id: number): Promise<T> {
const res = await fetch(`/api/${id}`);
const data: unknown = await res.json();
return data as T;
}
const user = await fetchById<User>(1);
T extends HasId 的意思是:T 必须至少包含 id: number 这个属性。如果你传入一个没有 id 的类型,编译期直接报错。这相当于给泛型加了一道“门槛”,让函数内部可以放心访问 T 上的公共属性。
泛型也支持默认参数,和函数默认参数很像:
typescript复制interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
}
平时业务接口的 data 字段类型各不相同,ApiResponse<User>、ApiResponse<Order> 就是不同的响应体。不传泛型参数时,data 默认是 unknown,也比较安全。
3.4 实战:写一个类型安全的 createState
状态管理是最能体现泛型价值的地方。很多人在 useState、store 的类型上选择用 any,结果组件里到处是隐式类型转换错误。实际上一个带泛型的 createState 可以完全替代:
typescript复制function createState<T>(initial: T) {
let value = initial;
return {
get(): T {
return value;
},
set(next: T): void {
value = next;
},
update(fn: (prev: T) => T): void {
value = fn(value);
}
};
}
const counterState = createState({ count: 0, label: "counter" });
counterState.set({ count: 1, label: "counter" }); // 编译通过
counterState.set({ count: "1", label: "counter" }); // 编译报错:count 不应该是 string
我还用泛型写过事件总线,以前用 any 的时候,事件回调参数完全靠自觉,写错就运行时抓瞎。换成泛型后:
typescript复制interface EventMap {
userLogin: User;
pageView: { path: string; duration: number };
}
class TypedEventBus {
private listeners: { [K in keyof EventMap]?: Array<(payload: EventMap[K]) => void> } = {};
on<K extends keyof EventMap>(event: K, cb: (payload: EventMap[K]) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event]!.push(cb);
}
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
this.listeners[event]?.forEach((cb) => cb(payload));
}
}
这里 EventMap[K] 叫“索引访问类型”,它能在映射类型里动态取到不同事件对应的 payload 类型。这个例子已经触及了映射类型,下一章详细展开。
4. 条件类型与 infer:把类型推断变成一门可复用的手艺
4.1 条件类型到底解决了什么问题
条件类型的语法看起来像三元表达式:
typescript复制type IsArray<T> = T extends any[] ? true : false;
// 使用
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
它解决的核心问题,是“根据类型之间的关系,计算出另一个类型”。以前面对这种“数据形状不确定”的情况,很多人会选择用 any 一把梭。条件类型则可以在类型层面做分支判断。
举个例子,后端接口有时候返回 T,有时候返回 T[],你想写一个类型安全的转换工具:
typescript复制type Unwrap<T> = T extends Array<infer U> ? U : T;
type Item = Unwrap<string[]>; // string
type Other = Unwrap<number>; // number
这个 infer U 就是条件类型最精华的部分:它不直接写死类型,而是让 TypeScript 从 Array<...> 这个结构里“推断”出元素类型 U。类似地,Awaited<T> 可以从 Promise<T> 里提取出 T。
4.2 infer:类型版的“解构赋值”
infer 可以理解成类型层面的 const { a, b } = obj 解构。只不过解构的对象不是运行时数据,而是类型结构。内置类型 ReturnType 就是这样实现的:
typescript复制type MyReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
我知道这里出现了 any。这是 TypeScript 内置定义的标准写法,因为 JavaScript 函数参数类型极其灵活,必须用一个“能接受任意函数”的约束来描述“任意函数”。这里的 any 是受控的、封闭在类型工具内部的,不会污染业务代码的返回值。只要你不把它暴露给业务调用方,它是可以接受的“必要之恶”。
用起来:
typescript复制function getUser() {
return { id: 1, name: "Tom" };
}
type UserResult = MyReturnType<typeof getUser>; // { id: number; name: string }
再比如,想提取数组里的元素类型,可以自己写:
typescript复制type ElementOf<T> = T extends (infer E)[] ? E : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<(number | null)[]>; // number | null
这类工具类型写一次,整个项目通用。以前需要用 any 抹平差异的地方,现在可以精确到“数组里到底是什么”。
4.3 实战:封装 DeepReadonly
日常业务中,我们可以用条件类型加递归,写出功能非常强大的类型工具。比如 DeepReadonly,把对象的所有层级都加上 readonly:
typescript复制type DeepReadonly<T> = T extends (...args: any) => any
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
server: {
host: string;
port: number;
endpoints: string[];
};
}
type ReadonlyConfig = DeepReadonly<Config>;
// readonly server: { readonly host: string; readonly port: number; readonly endpoints: readonly string[] }
这个类型工具的逻辑很清楚:遇到函数类型原样返回,遇到对象类型就递归地把每一层 key 改成 readonly,遇到原始类型不变。比如你想在某个函数里接收配置对象但保证不修改它,用它就能在编译期强制约束。
类似的思路还可以写 DeepPartial、DeepRequired、DeepPick,都是同一套模式。
4.4 别过度设计:条件类型在什么场景下才值得用
条件类型是高级技巧,但我不建议为了炫技到处用。它们适合封装在“公共类型工具”里,而不是散落在业务代码的每个角落。业务代码里频繁出现复杂的条件类型,说明你的数据模型本身设计得不好。
我的判断标准很简单:如果一个类型工具,团队里其他成员看到之后需要超过两分钟才能理解它在做什么,那它就应该放在 types/ 目录下,配上一段注释,并且必须要有足够的复用场景。如果某个条件类型只在一个地方用一次,那大概率是过度设计,应该回到更朴素的写法。
5. keyof / typeof / 映射类型:日常业务里最被低估的替代方案
5.1 keyof:把对象键变成联合类型,替代 string 索引
很多业务代码里,为了通用性,会把参数写成 key: string,然后从对象里取值:
typescript复制// 反面教材
function getValue(obj: any, key: string): any {
return obj[key];
}
然后调用方传来传去,类型全丢,还可能在运行时访问到不存在的属性。keyof 就能很好地解决这个问题:
typescript复制function getValue<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Tom", age: 30 };
const name = getValue(user, "name"); // name 的类型是 string
const id = getValue(user, "id"); // id 的类型是 number
getValue(user, "email"); // 编译报错:email 不在 user 的 key 中
用 keyof 之后,调用方传错了 key,编译期就会被拦下。这也是最日常、替换 any 最实用的一招。类似的还有 keyof typeof 组合,后面会讲。
5.2 映射类型:批量修改对象字段类型
映射类型就是 [K in keyof T] 这种语法,它可以“遍历”一个对象类型的每个 key,生成一个新类型。内置的 Pick、Partial、Readonly、Record 都是这样实现的。
比如你想让所有字段可选:
typescript复制type MyPartial<T> = { [K in keyof T]?: T[K] };
所有字段只读:
typescript复制type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
把一组字符串字面量变成对象:
typescript复制type Permission = "read" | "write" | "admin";
type PermissionMap = Record<Permission, boolean>;
// { read: boolean; write: boolean; admin: boolean }
真实业务里,我常用映射类型做“字段脱敏”或者“字段类型转换”。比如后端返回的时间字段是字符串,前端的类型定义却希望是 Date:
typescript复制type UserRaw = {
id: number;
createdAt: string;
updatedAt: string;
};
type StringsToDates<T> = {
[K in keyof T]: T[K] extends string ? Date : T[K];
};
type User = StringsToDates<UserRaw>;
// { id: number; createdAt: Date; updatedAt: Date }
这个思路就是第 4 章的条件类型加第 5 章的映射类型结合起来,非常实用。把一个对象的字段类型批量替换,而不需要手写一个完整的 User 接口。
5.3 组合使用 typeof 和 as const:复用配置对象类型
很多时候我们不写接口,而是写一个配置对象:
typescript复制// 这样写的问题:routeMap 的 value 会被推断成 string,而不是字面量类型
const routeMap = {
home: "/home",
users: "/users",
settings: "/settings"
};
如果想让路由 key 和 value 都精确起来,可以加 as const,然后用 typeof 提取:
typescript复制const routeMap = {
home: "/home",
users: "/users",
settings: "/settings"
} as const;
type RouteKey = keyof typeof routeMap; // "home" | "users" | "settings"
type RoutePath = (typeof routeMap)[RouteKey]; // "/home" | "/users" | "/settings"
function navigate(path: RoutePath) {
// 实现
}
navigate("/home"); // 编译通过
navigate("/other"); // 编译报错
热搜词里有 “typescript const”,这里就是个典型场景。as const 告诉 TypeScript:“请按最精确的字面量类型推断,不要放宽成 string。”
typeof 在类型上下文里的作用,是把一个“值”变成“值的类型”。它特别适合和 as const 配合,让你不需要重复定义接口,就能复用配置对象的结构。
5.4 完整重构案例:一个 any 型 state 的拯救过程
我在实际项目里重构过一个模块,那个模块的 state 原本是这样的:
typescript复制const state: any = {
list: [],
current: null,
loading: false
};
因为 current 可能是 User,可能是 null,写代码的人嫌麻烦直接用 any 了。结果后续代码里 state.current.name 这种危险访问到处都是。
我的重构过程分三步:
第一步,定义精确的类型:
typescript复制interface User {
id: number;
name: string;
email: string;
}
interface ListState {
list: User[];
current: User | null;
loading: boolean;
}
第二步,把 state 标注成 ListState,让所有读取 current 的地方必须处理 null:
typescript复制const state: ListState = {
list: [],
current: null,
loading: false
};
if (state.current) {
console.log(state.current.name);
}
第三步,把组件里所有“曾经依赖 any 才通过的属性访问”逐一改成收窄后的安全写法。这一步涉及的代码量远比想象中大,但改完之后,很多隐藏的运行时错误在编译期就暴露了。
这个案例说明:告别 any 本质上不是“把 any 换成具体类型”这种简单替换,而是重新审视你的数据结构,把它讲清楚。类型定义清晰之后,代码自然好维护。
6. 实在绕不过去的场景:第三方库、接口联调与“最后的退路”
6.1 接口返回不明确时:unknown + schema 校验优于 any
有人会问:我调用的接口文档都还没定稿,返回结构天天变,不用 any 我怎么写?我的答案是:用 unknown,并且在入口处做校验。
哪怕结构真的每天都在变,一旦你写了 any,代码里所有字段访问都没有保护;你今天的代码能编译过,明天接口改了,你也不知道。如果你写了 unknown 并加了一个守卫函数,接口一变,守卫函数大概率会校验失败,你被迫回来修改,这就是“让错误早点暴露”。
如果团队的接口规范特别混乱,可以考虑引入 zod 这类运行时校验库,定义一个 schema。这比手动写守卫函数省事,还能生成类型:
typescript复制import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
});
type User = z.infer<typeof UserSchema>;
function parseUser(data: unknown): User {
return UserSchema.parse(data);
}
z.infer 能把 schema 自动推导成 TypeScript 类型。这样你甚至不需要手写 User 接口,schema 是唯一的“真相来源”,TS 类型和运行时校验都从它派生。这是我目前处理接口数据最推荐的方式。
6.2 第三方库没有类型:声明文件、模块扩充和受控 any
还有一种常见情况:npm 包根本没有类型定义,比如老旧的 jQuery 插件或者自研的内网库。这时候强行不用 any 会很难受。
我更推荐的做法是:为这个库单独写一个 d.ts 声明文件,声明你用到的那几个 API:
typescript复制// types/legacy-lib.d.ts
declare module "legacy-lib" {
export function init(config: { container: HTMLElement; theme?: string }): void;
export function destroy(instance: unknown): void;
}
这样在业务代码里就能正常获得类型提示了。声明文件写不全或者某个接口实在无法表达,才考虑用注释豁免:
typescript复制// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = legacyLib.doSomething();
注意,这里至少做了一件事:把 any 的使用限制在局部,并且明确用注释告诉后来的人——这个 any 是故意留下的,不是图省事。
6.3 satisfies 关键字:既要精确类型,又要收窄检查
satisfies 是 TypeScript 4.9 引入的一个操作符,它解决的是“既要字面量推断,又要检查结构是否符合某个类型”的矛盾。
举个例子,你想写一个配置,既要它符合 Record<string, number | string>,又不希望 "port" 被放宽成 string:
typescript复制type ServerConfig = {
host: string;
port: number;
maxConnections?: number;
};
const config = {
host: "localhost",
port: 8080,
maxConnections: 100
} satisfies ServerConfig;
没有 satisfies 时,如果直接标注 ServerConfig,字面量类型会被放宽,后续 config.host 就是 string;如果完全不加标注,TypeScript 又不会检查这个对象是否真的符合 ServerConfig。satisfies 让两者兼得:类型保持字面量精确,同时结构受约束。当你给第三方配置型接口传参时,这个工具很能减少 any 的出现。
6.4 渐进式迁移:老项目怎么“去 any 化”
最后聊存量项目。如果你想在项目里全面清理 any,千万不要“一口吃成胖子”。
我的迁移路线是:
- 先把 tsconfig 里
strict打开,让新增代码的隐式any暴露出来。如果项目太大,可以先只开noImplicitAny。 - 在 ESLint 里把
@typescript-eslint/no-explicit-any设为warn,让所有现存any以警告形式浮现,同时把它的严重级别逐步提升到error。 - 跑一遍全项目扫描,用一个脚本统计每个文件里
any的数量,按数量从多到少排序,挑出几个最严重的关键模块先处理。 - 每次重构一个模块,就把该模块内的
any清零。不要在一个 PR 里铺开全项目,否则 review 压力太大,也容易引入回归。 - 新代码强制零
any,code review 时如果看到新引入的any,必须让作者说明原因。没有合理解释的一律打回。
这个过程通常要持续几周甚至几个月。但它的收益是逐步累积的:每处理一个模块,那个模块的类型提示、重构安全性都会立刻改善。
我个人在实际操作中的体会是:绝大多数“没有 any 就写不下去”的时刻,其实都只是当下的思路没打开。先把值标成 unknown,再想想它到底是什么、有什么结构、哪些字段是可靠的,类型定义往往就水到渠成。真正需要 any 的场景,远比你想象中少。最后再分享一个小技巧:遇到让你特别想写 any 的地方,先停一下,去搜一下“TypeScript 提取函数返回值类型”或者“映射类型批量修改字段”,你会发现自己需要的类型工具早就有了。
