TypeScript这些工具类型,够你应对90%的业务场景了

  告别手写类型,8个工具组合,轻松应对90%业务场景。

  咳咳,大伙看见TypeScript也别急着划走,我知道这玩意儿在简历上写的是“熟悉”,但真到业务里手写类型的时候,估计不少小伙伴都是一顿any走天下,或者疯狂ctrl+c、ctrl+v现成的类型。今天咱们来聊聊TypeScript里的工具类型(Utility Types),这玩意儿简直就是类型世界的瑞士军刀,掌握好了,能让你的代码从“能用”直接升级到“优雅”。

  要知道以前我们写 TypeScript,光是定义类型就占了一大半时间,一个接口写下来,字段多的能绕地球三圈。但其实 TypeScript 早就给我们准备好了很多现成的工具类型,用起来那叫一个酸爽。本文咱们就挑几个最常用的来好好唠唠,保证你看完之后,见人说人话,见类型写类型。

什么是工具类型?

  经常对接接口的小伙伴都知道,后端接口返回的数据结构一变,我们就得跟着改 TypeScript 类型定义。改动一个字段,后面一串地方都要跟着改,一个不小心漏了,线上就开始报红。

  我自己就踩过这个坑。有一次后端把userName改成了nickname,我自认为全局替换得很彻底,结果上线后一个隐藏很深的日志上报组件直接炸了——控制台飘红的那一刻,产品经理就站在我身后。那一刻我深刻体会到:手写类型就是在给自己埋雷

  工具类型存在的意义就是:让你用组合的方式构建类型,而不是每次都从头手写。就像乐高积木,你不需要每次都从塑料粒子开始,直接拼就行了。举个例子,假设你有个用户信息接口:

1
2
3
4
5
6
7
interface User {
id: number
name: string
email: string
age: number
avatar: string
}

  现在产品经理说,用户资料页面只需要展示这些字段,但都可以不填(都是可选的)。正常写法是:

1
2
3
4
5
6
7
interface UserForm {
id?: number
name?: string
email?: string
age?: number
avatar?: string
}

  这有两个问题:第一,重复代码,看着就烦;第二,如果哪天 User 加了一个字段 phoneUserForm 不会自动同步,还得手动加。用 Partial 工具类型,一行搞定:

1
type UserForm = Partial<User>

  以后 User 加字段,UserForm 自动跟上,舒服。

Record——键值映射的利器

  先来聊聊 Record,这玩意儿在业务里出场率贼高,经常用来做数据字典或者配置映射。

基本用法

  Record<K, V>接受两个类型参数,K作为键的类型,V作为值的类型。咱们直接看例子:

1
2
3
4
5
6
7
8
9
// 场景:配置某个菜单的权限状态
type MenuKey = 'home' | 'profile' | 'settings';
type MenuLabel = string;

const menuLabels: Record<MenuKey, MenuLabel> = {
home: '首页',
profile: '个人中心',
settings: '设置',
};

  这下明白了吧?Record<MenuKey, MenuLabel> 就相当于 { home: string; profile: string; settings: string; },但是比手写清晰多了,而且当你的MenuKey多了一个选项时,TypeScript会直接报错提醒你menuLabels还差一个没填。

  Record的键不一定是联合类型,也可以是 string 或 number。比如你要存一个用户 ID 到用户信息的映射:

1
2
3
4
5
type UserInfo = { name: string; age: number };
const userMap: Record<string, UserInfo> = {
'user_001': { name: '张三', age: 25 },
'user_002': { name: '李四', age: 30 },
};

  这里 Record<string, UserInfo> 等价于 { [key: string]: UserInfo }。但用 Record 写起来更简洁,语义也更明确——“这是一个键值映射”。

Recordable

  我们经常会在项目中看到Recordable这个类型,虽然看着和上面的Record长得差不多,包括笔者刚开始看到的时候也是一脸懵,这两者到底有什么区别?于是笔者也替大家去查了查。

  其实Recordable并不是 TypeScript 官方内置的工具类型,而是各大开源项目(尤其是 Vue 3 生态和某些后台管理模板)中自定义的一个常见类型。我们在项目的global.d.ts全局声明文件中能看到它的身影,定义通常长这样:

1
declare type Recordable<T = unknown> = Record<string, T>

  也就是说,Recordable 等价于 { [key: string]: T }。它和 Record<string, T> 本质上是一模一样的,只是换了一个名字。那为什么大家要费劲造这个词呢?

  我猜有两个原因:第一,Record<string, T> 写起来有点啰嗦,每次都要敲string和逗号;第二,Recordable这个词更“语义化”——表示“可以用字符串键去记录的”对象。在动态性很强的场景(比如处理 API 返回的任意 JSON 数据)中,你往往不知道对象里具体有哪些键,只知道值大概是什么类型,这时Recordable就非常顺手。

  举个例子,后端有时候会返回一个结构不确定的配置对象:

1
2
3
4
5
6
// 后端返回的额外数据,键名无法预知
const extraData: Recordable = {
someRandomKey: 'hello',
anotherKey: 123,
nested: { foo: 'bar' },
};

  用 Recordable 你可以快速声明“这是一个键为字符串、值可以为任意类型的对象”,而不用写冗长的索引签名;总结一下:

  • Record<K, V>:键和值都需要显式声明,适合结构固定的场景。
  • Recordable<T>:自定义的便捷类型,键固定为string,值默认为 any,适合处理不确定键名的动态对象。

实际业务场景

  经常写后台管理系统的同学肯定知道,这种配置写法简直不要太常见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 接口响应状态码映射
type StatusCode = 200 | 400 | 401 | 403 | 500;

const statusMessage: Record<StatusCode, string> = {
200: '请求成功',
400: '参数错误',
401: '未授权',
403: '禁止访问',
500: '服务器错误',
};

// 获取提示信息
function getMessage(code: StatusCode): string {
return statusMessage[code];
}

  而且这里还有个好处,如果你漏写了一个状态码,TypeScript会在编译阶段就给你标红,再也不用担心线上少了个判断导致用户体验崩了。

Record和Recordable

Partial和Required——字段的开关

  接下来这两个工具类型,简直就是应对“可选”和“必填”需求的最佳拍档。我第一次见到它们的时候,脑子里只有一个想法:以前手写那么多可选接口,简直是在浪费生命。

Partial:把属性都变成可选的

  Partial 的作用很简单:把类型 T 中的所有属性都变成可选的。用 TypeScript 官方文档的说法,这叫“makes all properties optional”。用人话说,就是让每个字段后面都悄悄加一个问号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface User {
id: number;
name: string;
email: string;
avatar: string;
}

// 更新用户信息的时候,可能只传其中几个字段
function updateUser(id: number, data: Partial<User>) {
// ...更新逻辑
}

// 调用的时候,随便传几个字段就行
updateUser(1, { name: '张三' }); // ✅
updateUser(1, { email: 'zhangsan@example.com' }); // ✅
updateUser(1, { name: '李四', email: 'lisi@example.com' }); // ✅

  可以看到,我们没有为“部分更新”专门写一个 UserUpdatePayload 接口,Partial<User>一行就搞定了。特别适合那种 PATCH 请求——传什么就更新什么,没传的字段保持不变。

  不过这里有个小坑要注意:Partial只会处理一层属性。如果你的类型嵌套了对象,内部对象的属性不会被变成可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface Product {
name: string;
price: number;
detail: {
description: string;
stock: number;
};
}

type PartialProduct = Partial<Product>;
// 等价于:
// {
// name?: string;
// price?: number;
// detail?: { description: string; stock: number }; // detail整体可选,但里面的字段还是必填
// }

// 这样写会有问题:
const p: PartialProduct = {
detail: {} // ❌ 报错:缺少 description 和 stock
};

  要处理深层可选,你需要用到递归的 DeepPartial,不过那是进阶话题了,我们等一下再聊。

Required:把属性都变成必填的

  有打开的就有关上的,Required<T> 的作用正好相反,把所有可选属性变成必填的。这个在表单提交场景特别有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface FormData {
username?: string;
password?: string;
verifyCode?: string;
}

// 提交之前,需要确保所有字段都有值
function submitForm(data: Required<FormData>) {
// ...提交逻辑
}

// 你必须传完整的表单数据,否则编译报错
submitForm({
username: 'user1',
password: '123456',
verifyCode: 'abcd',
}); // ✅

组合使用

  有时候,你会遇到更变态的需求:某个对象的大部分字段可选,但某几个字段必须填。比如更新用户信息时,id必须传,其他字段可选。这时候可以把 Partial 和 Pick / Omit 组合起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface User {
id: number;
name: string;
email: string;
age: number;
}

// id 必填,其他字段可选
type UpdateUserData = {
id: number;
} & Partial<Omit<User, 'id'>>;

function updateUser(data: UpdateUserData) {
// data.id 一定存在,其他字段可有可无
}

updateUser({ id: 1, name: '张三' }); // ✅
updateUser({ id: 2, age: 25 }); // ✅
updateUser({ name: '李四' }); // ❌ 缺少 id

  这种“半开半关”的组合,在实战中非常常见。用熟了之后,你会发现 TypeScript 的类型系统就像乐高——你能用几块基础积木拼出各种奇怪的形状。

源码解读

  扒开 TypeScript 的源码(其实是看内置类型定义),Partial 的实现非常干净:

1
2
3
type Partial<T> = {
[P in keyof T]?: T[P];
};

  就三行代码。核心就是映射类型 [P in keyof T] 遍历 T 的所有属性,然后在后面加一个?,把它变成可选的。没了。这大概就是“少即是多”的典范。

  Required 的实现也类似,不过它是把可选标记移除:

1
2
3
type Required<T> = {
[P in keyof T]-?: T[P];
};

  注意那个 -?——这个减号用于移除可选修饰符。TypeScript 在映射类型中允许你用 + 或 - 来添加或移除 readonly 和 ? 修饰符。-? 的意思是“把这个属性的可选标记给我去掉”,于是所有属性都变成必填了。

  如果你好奇,+? 也可以显式添加可选标记,不过因为 ? 本身默认就是添加,所以一般直接写 ? 就够了。但 -? 这种写法,第一次见的时候可能会愣一下——我刚看到的时候还以为是什么正则表达式。

Pick和Omit——挑挑拣拣选属性

  这两个工具类型,用一句话概括就是:Pick 是挑你要的,Omit 是排除你不要的。就像你在食堂打菜——Pick 是“我要番茄炒蛋和红烧肉”,Omit 是“除了香菜和姜,其他都给我来点”。

Pick:选出一部分属性

  Pick<T, K>从类型 T 中挑出一组属性 K,生成一个新的类型。非常适合用来“裁剪”大对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface User {
id: number;
name: string;
email: string;
phone: string;
createdAt: Date;
// 假设后面还有 10 个字段...
}

// 只保留登录需要的字段
type LoginForm = Pick<User, 'email' | 'password'>;

// 等等,User 里没有 password
// 咱们换一个
type PublicUserInfo = Pick<User, 'id' | 'name' | 'email'>;

// 这个类型等价于:
// {
// id: number;
// name: string;
// email: string;
// }

  看到这里你可能会问:要是 User 里没有 password 怎么办?别急,Pick 只能挑已经存在的属性。如果你需要额外加字段,可以跟 & 合并:

1
type LoginPayload = Pick<User, 'email'> & { password: string };

  这个组合在实际项目中非常常见——接口返回的 User 可能有敏感字段,你挑出需要的,再补上额外参数。

Omit:排除某些属性

  Omit<T, K>从类型 T 中排除一组属性 K,剩下的全部保留。适合“去掉敏感字段”或“去掉冗余字段”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface User {
id: number;
name: string;
email: string;
phone: string;
createdAt: Date;
}

// 对外暴露的用户信息,排除手机号和创建时间
type PublicUser = Omit<User, 'phone' | 'createdAt'>;

// 这个类型等价于:
// {
// id: number;
// name: string;
// email: string;
// }

  看到区别了吗?当你要去掉的字段很少,保留的字段很多时,Omit 比 Pick 写起来更省事。反之,如果要保留的字段很少,就用 Pick。

怎么选?

  至于什么时候用 Pick 还是 Omit,这个主要看业务逻辑。如果你排除的字段少,那就用 Omit;如果你要的字段少,那就用 Pick。没有固定标准,灵活选就行。

1
2
3
4
5
// 场景1:大部分字段都要,排除个别 -> Omit
type UpdateUserDto = Omit<User, 'id' | 'createdAt'>;

// 场景2:只要几个字段,剩下不要 -> Pick
type UserPreview = Pick<User, 'id' | 'name'>;

真实业务场景

  真实世界的需求总是变态的。比如:更新用户信息时,id 必须传,其他字段可选,但createdAt不能传(只读)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}

type UpdateUserData = {
id: number;
} & Partial<Omit<User, 'id' | 'createdAt'>>;

function updateUser(data: UpdateUserData) {
// data.id 必填,data.name 和 data.email 可选
}

  拆解一下:

  • Omit<User, ‘id’ | ‘createdAt’> → 得到 { name: string; email: string }
  • Partial<…> → { name?: string; email?: string }
  • 再交叉 { id: number } → 最终类型

  这种组合练习多了,你会发现自己越来越像在用函数式编程写类型——每个小工具都不复杂,叠在一起就能解决复杂问题。

Pick和Omit

Nullable和NonNullable——null 的过滤器

  接下来这两个工具类型,专门处理一个让所有前端头疼的问题:null 和 undefined。说实话,我写 TypeScript 这两年,报错有一半都是”对象可能为 null”或者”类型 undefined 不能赋值给类型 string”。这两个工具就是专门治这个的

NonNullable:去掉null和undefined

  这个工具类型从名字上就能看出来,它负责从联合类型中踢掉null和undefined。

1
2
3
4
5
6
type A = string | null | undefined;
type B = NonNullable<A>; // string

// 多个类型也一样,只留"有值"的类型
type C = string | number | null | undefined;
type D = NonNullable<C>; // string | number

  这玩意儿在哪个场景最有用?处理 API 响应里的可选字段。后端经常把没有值的字段返回成 null,前端处理起来烦得要死,满屏都是 if (data.field === null) return。用 NonNullable 可以帮你把类型收紧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 后端返回的配置,有些字段可能没有
type ApiConfig = {
theme: 'light' | 'dark' | null;
language: 'zh' | 'en' | null;
timezone: string | null;
// ... 其他字段
};

// 前端已经做了默认值处理,保证这些字段一定有值
function applyConfig(config: {
theme: NonNullable<ApiConfig['theme']>; // 'light' | 'dark'
language: NonNullable<ApiConfig['language']>; // 'zh' | 'en'
}) {
// 这里放心用,再也不用 ?. 或者 || 了
document.documentElement.setAttribute('data-theme', config.theme);
}

  不过我得说句实话——NonNullable 只在类型层面起作用。它帮你把 string | null 收窄成 string,但如果运行时真的传了 null 进来,该崩还是得崩。它就像一个”只查票不抓人”的检票员——确保你的代码逻辑上说得通,但不保证运行时数据真的干净。

场景一:filter(Boolean) 的简写形式

  我们看下下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
type User = { id: number; name: string };
const users: (User | null | undefined)[] = [
{ id: 1, name: '张三' },
null,
{ id: 2, name: '李四' },
undefined,
];

// 很多人喜欢用 filter(Boolean) 的简写
const filtered1 = users.filter(Boolean);
// 问题来了:这里的类型是 (User | null | undefined)[]
// TypeScript 可没聪明到能理解 Boolean 构造函数的行为

  filter(Boolean) 是很多老手爱用的简写,但 TypeScript 并不会自动收窄类型,它只是把 Boolean 当成一个普通的函数;如果不借助NonNullable,我们就需要使用类型断言来收窄类型。

1
const filtered2 = users.filter(Boolean) as User[];

  我们使用NonNullable来主动告诉编译器:“过滤之后,空值已经没了,放心吧。”

1
2
// 这时候 NonNullable 作为类型守卫的一部分,帮我们明确了过滤后的类型
const filtered3 = users.filter((u): u is NonNullable<typeof u> => Boolean(u));

  不过说实话,我个人更倾向于写filter(u => u !== null && u !== undefined) 而不是 filter(Boolean)——多敲几个字,换来 TypeScript 自动收窄,我觉得这笔买卖挺划算的。

源码解读

  NonNullable的实现极其简洁,用到了TypeScript的条件类型:

1
type NonNullable<T> = T extends null | undefined ? never : T;

  翻译成人话:如果 T 是 null 或 undefined,就把它替换成 never(永远不存在的类型),否则保留 T 本身。在联合类型中,never 会被自动过滤掉——所以string | null | undefined 经过这个处理后,只剩下string。

  这个写法非常巧妙:它利用了条件类型的分配律——当T是联合类型时,TypeScript会对联合中的每个成员分别应用条件,最后把结果再组合成新的联合。

Nullable:把属性变成可选的且可为null

  看完NonNullable,你可能想问:那反过来呢?有没有一个工具可以把所有属性都变成可空的?答案是——TypeScript 官方没有,但我们可以自己造。这就是大家常说的Nullable,先来看最朴素的版本:

1
2
3
4
5
// 基础版:将类型本身变成 T | null
type Nullable<T> = T | null;

type A = Nullable<string>; // string | null
type B = Nullable<number>; // number | null

  这个版本相当于给类型”增加了一个 null 的可能性”。但它只能处理单个类型,没法处理对象里的每个属性。所以更常见的是这个版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 进阶版:把对象的所有属性都变成 T | null
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};

interface User {
name: string;
age: number;
email: string;
}

type NullableUser = Nullable<User>;
// 等价于:
// {
// name: string | null;
// age: number | null;
// email: string | null;
// }

  经常处理数据库映射的同学,对这种类型肯定不陌生。很多 ORM 库(比如 TypeORM、Prisma)在映射数据库字段的时候,会把那些 DEFAULT NULL 的字段映射成可空类型。用 Nullable 来处理,比手写一百个 | null 省力太多了。

Nullable 和 NonNullable

Exclude和Extract——联合类型的筛选器

  这两个工具类型专门用于处理联合类型,用好了能帮你解决很多看似复杂的类型问题。我第一次见到它们的时候,脑子里浮现的画面是那种带筛网的淘金盘——Exclude 是把沙子筛掉留下金子,Extract 是直接捞起金子。本质上都是筛选,只是视角不同。

注意Exclude/Extract和Pick/Omit的区别,前者是筛选联合类型,后者是筛选对象属性。

Exclude:排除联合类型中的某些成员

1
2
3
4
5
6
7
8
9
10
type Event = 'click' | 'scroll' | 'mousemove' | 'keypress' | 'change';

// 排除掉鼠标相关的事件
type KeyboardEvent = Exclude<Event, 'click' | 'mousemove'>;
// 结果:'scroll' | 'keypress' | 'change'
// 你看,click 和 mousemove 被踢出去了

// 实际场景:只处理输入事件
type InputEvent = Exclude<Event, 'scroll' | 'mousemove'>;
// 'click' | 'keypress' | 'change'

  有次我在写一个事件总线,需要把鼠标事件和键盘事件分开处理。一开始我手写了一个新类型,把所有事件名重新列了一遍。后来同事 review 代码的时候说:”你这不是有 Event 了吗?用Exclude筛一下不就行了?”我当场愣住——确实,我列了 8 个事件名,其实只需要排除 3 个。用 Exclude 一行搞定,还少敲了 20 个字符。

Extract:选出联合类型中的某些成员

  Extract正好相反,它是把符合条件的成员捞出来:

1
2
3
4
5
6
7
8
9
type Event = 'click' | 'scroll' | 'mousemove' | 'keypress' | 'change';

// 选出所有鼠标和键盘相关的事件
type InteractionEvent = Extract<Event, 'click' | 'keypress' | 'mousemove'>;
// 'click' | 'mousemove' | 'keypress'

// 场景:根据配置过滤可用事件
const availableEvents: InteractionEvent[] = ['click', 'keypress'];
// 只能放 click、mousemove、keypress 中的任意组合

源码解读

  把 Exclude 和 Extract 的源码放在一起对比,你就能发现 TypeScript 设计者的巧思:

1
2
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

  看到了吗?这两行代码唯一的区别就是 never 和 T 的位置调换了。一个把符合条件的变成 never(扔掉),一个把不符合条件的变成 never(扔掉)。

  要真正理解这两行代码,你得先搞明白TypeScript 的条件类型分配律。用人话解释就是:当T是一个联合类型时,TypeScript 会把条件类型分别应用到联合的每一个成员上,然后把所有结果再拼成一个新的联合。

  never在这个机制里扮演的角色就是”丢弃”。条件为真时丢弃(Exclude)还是条件为假时丢弃(Extract),决定了这两个工具的行为刚好相反。

ReturnType和Parameters——函数的解构师

  这两个工具类型专门用来从函数类型中提取信息,用好了能让你的代码解耦很多,再也不用在”改了一个函数的返回类型,结果十个地方都要手动改”这种事上浪费时间了。

ReturnType:获取函数返回值类型

  ReturnType<T>接受一个函数类型,然后返回它的返回值类型。注意,它接受的是类型,不是函数本身——所以得用typeof先把函数转成类型。

1
2
3
4
5
6
7
8
9
10
11
function fetchUser() {
return {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
};
}

// 获取返回值类型
type UserResponse = ReturnType<typeof fetchUser>;
// { id: number; name: string; email: string; }

  这个语法初次看起来有点绕:typeof fetchUser是”fetchUser 这个函数的类型”,ReturnType<…> 是”从这个类型里提取返回值”。两层套在一起才拿到最终的结果。

实际场景:API 封装

  后端接口返回的数据类型,是前端最常需要复用的。用 ReturnType 可以做到定义一次,到处使用:

1
2
3
4
5
6
7
8
9
10
11
12
async function getUserList() {
const response = await fetch('/api/users');
return response.json(); // 假设返回 { id: number, name: string }[]
}

// 不用自己手写一个 UserList 接口
type UserList = ReturnType<typeof getUserList>;
// 这里的类型是 Promise<{ id: number; name: string }[]>

// 如果想把 Promise 拆掉,配合 Awaited
type UserListData = Awaited<ReturnType<typeof getUserList>>;
// 直接拿到 { id: number; name: string }[]

  Awaited 是 TypeScript 4.5 引入的工具类型,专门用来”拆”Promise。配合 ReturnType 使用,可以干净地拿到异步函数实际返回的数据类型。以前我都是先写一个 interface User { … },再写 type UserList = User[],再写函数用 Promise,三个地方重复定义。现在一行搞定。

  说实话,用好这个组合有个小前提:你的函数必须显式声明返回类型,或者 TypeScript 能自动推断出足够具体的类型。如果函数返回的是 any 或者一个很大很宽泛的类型,那 ReturnType 也没办法给你精确的类型信息。

Parameters:获取函数参数类型

  Parameters<T>返回的是一个元组类型,包含了函数所有参数的类型。对于函数重载,它会取最后一个重载的参数列表(通常是实现签名)。

1
2
3
4
5
6
7
8
9
10
11
12
13
function updateUser(id: number, data: { name: string; email: string }) {
// ...
}

// 获取参数类型,返回的是元组类型
type UpdateUserParams = Parameters<typeof updateUser>;
// [id: number, data: { name: string; email: string }]

// 取第一个参数类型
type FirstParam = UpdateUserParams[0]; // number

// 取第二个参数类型
type SecondParam = UpdateUserParams[1]; // { name: string; email: string }

实际场景:把函数参数传给另一个函数

  有一种场景特别适合Parameters:你有一个高阶函数,它接受一个函数作为参数,然后需要调用这个函数——但你不想把参数类型再重新定义一遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 一个通用的防抖函数
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
) {
let timer: NodeJS.Timeout | null = null;
return function (...args: Parameters<T>) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}

// 原始函数
function search(keyword: string, page: number) {
console.log(`搜索:${keyword},第 ${page} 页`);
}

// 防抖后的函数,参数类型自动匹配
const debouncedSearch = debounce(search, 300);
debouncedSearch('TypeScript', 1); // ✅ 类型安全
debouncedSearch(123, 1); // ❌ 报错:第一个参数应该是 string

  以前写这种通用函数的时候,我常常用…args: any[]糊弄过去,把类型检查的事抛给调用方。有了Parameters,通用函数也能精准地保留参数类型,调用方传错参数时 TypeScript 会直接报红——这就把”谁该对类型负责”的问题从调用方转移到了函数定义方,更加合理。

总结

  好啦,今天咱们聊了这么多工具类型,最后来总结一下。Record用于键值映射,Partial/Required处理属性的可选和必填,Pick/Omit让你挑选属性,NonNullable过滤null和undefined,Exclude/Extract处理联合类型,ReturnType/Parameters 提取函数信息。这些工具类型就像是TypeScript给我们准备好的乐高积木,掌握了这些,你就能搭出各种造型的代码。

  说实话,用了这些工具类型之后,我明显感觉自己的 TypeScript 代码质量上了一个台阶——类型定义文件少了将近一半,重复代码也少了。更重要的是,改代码的时候胆子大了,因为类型系统帮我兜着底,漏改的地方TypeScript会直接报红,而不是等到上线后才炸。

  说到底,工具类型就是这么一种东西——它不会让你变成一个更好的程序员,但会让你变成一个更懒、也更不容易犯错的程序员。而在这个行业里,懒和稳,往往是同义词。

  快去把你项目里的any和重复接口删了吧,它们早该退休了。

参考

官方TypeScript工具类型


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。