【TypeScript】让类型体操更简单:TypeScript 实用工具类型精讲
【TypeScript】让类型体操更简单:TypeScript 实用工具类型精讲
所属专栏: 《前端小技巧集合:让你的代码更优雅高效》
上一篇: 【Git】Git 骚操作:rebase, cherry-pick 和 reflog 让你成为团队高手
作者: 码力无边
✨ 引言:当你的类型定义,也需要“七十二变”
嘿,各位在类型世界里追求严谨与优雅的道友们,我是码力无边!
在我们的前端修行之路上,TypeScript 就像是我们获得的一本“天书”,它为我们原本自由不羁的 JavaScript 带来了“秩序”与“法则”。通过类型定义,我们能在编码阶段就消灭掉大量的潜在 bug,让大型应用的维护变得前所未有的轻松。
我们勤勤恳恳地为数据定义 interface 和 type,就像是在为我们的代码世界构建精准的“模型”。
interface User {
id: number;
name: string;
email: string;
avatarUrl?: string; // 可选属性
createdAt: Date;
}
这个 User 模型很完美。但很快,新的“妖魔”(需求)就出现了:
- 场景一:我们需要创建一个“更新用户”的函数
updateUser。这个函数接收一个user对象,但这个对象里的属性都是可选的,因为用户可能只更新name,也可能只更新avatarUrl。难道我们要重新定义一个所有属性都带?的UpdateUserPayload类型吗? - 场景二:我们需要渲染一个“用户头像列表”,这个组件只需要用户的
id和avatarUrl。难道我们要为此再定义一个只包含这两个属性的UserAvatar类型吗? - 场景三:我们需要一个“用户注册”的表单数据类型,它应该包含
User的所有属性,除了id和createdAt,因为这两个字段是后端生成的。难道我们又要手动复制粘贴,然后删掉两个属性来创建一个UserRegistration类型吗?
如果每遇到一个新场景,我们都要通过“复制粘贴大法”来创造一个新的、略有差异的类型,那么我们的类型定义很快就会变得冗余、难以维护、且彼此之间失去了关联。一旦源头 User 接口发生变化(比如增加一个 age 属性),我们就必须手动去同步所有由它“衍生”出来的类型,这简直是一场灾难。
为了避免这种“类型爆炸”,TypeScript 的创造者们早已为我们准备好了一套“七十二变”的仙法——实用工具类型 (Utility Types)。
这些内置的工具类型,就像是类型世界里的“高阶函数”。它们接收一个已有的类型作为“输入”,然后经过一番“点化”,生成一个符合我们需求的“新类型”。
今天,码力无边就将带你深入学习几个最常用、最强大的实用工具类型,包括 Partial, Required, Readonly, Pick, Omit, Record 等。掌握它们,你将能够像做“类型体操”一样,灵活地组合、变换、裁剪你的类型,写出更简洁、更智能、更具可维护性的 TypeScript 代码。
一、让属性“可选”或“必选”:Partial<T> 与 Required<T>
1. Partial<T>:全体起立,变成可选!
Partial<T> (Partial: 部分的) 能将一个类型 T 的所有属性都变成可选的(在属性名后加上 ?)。
这完美地解决了我们引言中的场景一。
interface User {
id: number;
name: string;
email: string;
}
// 使用 Partial<User> 生成一个新类型
type UserUpdatePayload = Partial<User>;
// 相当于:
// type UserUpdatePayload = {
// id?: number;
// name?: string;
// email?: string;
// }
function updateUser(id: number, payload: UserUpdatePayload) {
// ... a.pi.updateUser(id, payload)
}
// 现在,你可以传入只包含部分属性的对象,类型检查会通过!
updateUser(1, { name: '码力加强版' });
updateUser(2, { email: 'new@example.com' });
2. Required<T>:一个都不能少!
Required<T> 与 Partial<T> 相反,它能将一个类型 T 的所有属性(包括原本可选的)都变成必选的。
interface UserProfile {
name: string;
avatarUrl?: string; // 原本是可选的
bio?: string;
}
// 经过 Required 处理后
type CompleteUserProfile = Required<UserProfile>;
// 相当于:
// type CompleteUserProfile = {
// name: string;
// avatarUrl: string; // 变成必选了!
// bio: string;
// }
function processProfile(profile: CompleteUserProfile) {
// 函数内部可以放心地访问 profile.avatarUrl,无需做非空判断
console.log(profile.avatarUrl.toUpperCase());
}
Required<T> 在你需要确保一个对象的所有字段都已被填充的场景下非常有用。
二、属性的“生杀大权”:Pick<T, K> 与 Omit<T, K>
这两个工具类型,给了我们从一个已有类型中“挑拣”或“剔除”属性的能力。
1. Pick<T, K>:精准“挑拣”我想要的
Pick<T, K> (Pick: 挑选) 能从类型 T 中,挑选出 K 中指定的键 (Keys),来构造一个新的类型。K 必须是 T 中存在的键,通常是一个联合类型字符串。
这完美地解决了我们的场景二。
interface User {
id: number;
name: string;
email: string;
avatarUrl?: string;
}
// 只挑选 'id' 和 'avatarUrl' 两个属性
type UserAvatarData = Pick<User, 'id' | 'avatarUrl'>;
// 相当于:
// type UserAvatarData = {
// id: number;
// avatarUrl?: string;
// }
const avatars: UserAvatarData[] = [
{ id: 1, avatarUrl: '...' },
{ id: 2, avatarUrl: '...' }
];
2. Omit<T, K>:果断“剔除”我不想要的
Omit<T, K> (Omit: 忽略,删除) 与 Pick 相反,它会构造一个新类型,这个新类型拥有 T 的所有属性,除了 K 中指定的那些键。
这完美地解决了我们的场景三。
interface User {
id: number;
name: string;
email: string;
passwordHash: string;
createdAt: Date;
}
// 剔除 'id', 'passwordHash', 'createdAt' 这三个后端管理的字段
type UserRegistrationForm = Omit<User, 'id' | 'passwordHash' | 'createdAt'>;
// 相当于:
// type UserRegistrationForm = {
// name: string;
// email: string;
// }
const form: UserRegistrationForm = {
name: '新用户',
email: 'user@example.com'
};
Pick 和 Omit 是处理接口数据 DTO (Data Transfer Object) 转换时最得力的助手。它们让你能够基于一个“全量”的基础模型,轻松地派生出用于不同业务场景的“精简”模型。
三、其他常用工具类型速览
1. Readonly<T>:筑起“不可变”的长城
Readonly<T> 能将一个类型 T 的所有属性都设为只读。任何尝试修改这些属性的行为,都会在编译时被 TypeScript 拦下。
interface Config {
apiKey: string;
apiUrl: string;
}
const config: Readonly<Config> = {
apiKey: 'abc-123',
apiUrl: 'https://api.example.com'
};
// 下面这行代码会直接报错!
// Cannot assign to 'apiKey' because it is a read-only property.
// config.apiKey = 'new-key';
这在处理应用配置、或者在函数式编程中强调数据不可变性时非常有用。
2. Record<K, T>:创建“字典”类型的捷径
Record<K, T> (Record: 记录) 是一个非常强大的工具,用于快速创建一个对象类型,它的键 (Keys) 是类型 K,值 (Values) 是类型 T。
场景: 我们需要一个对象,它的键是字符串(比如页面路由),值是对应的页面配置对象。
interface PageInfo {
title: string;
requiresAuth: boolean;
}
type Pages = 'home' | 'about' | 'contact';
// 使用 Record 创建一个类型,键必须是 'home', 'about', 'contact' 之一
// 值必须符合 PageInfo 接口
const pagesConfig: Record<Pages, PageInfo> = {
home: { title: '首页', requiresAuth: false },
about: { title: '关于我们', requiresAuth: false },
contact: { title: '联系我们', requiresAuth: true },
// 如果你写了不存在的键,比如 `dashboard: { ... }`,会报错
// 如果值的类型不对,比如 `home: { title: 123 }`,也会报错
};
Record 为创建结构统一的“字典”或“哈希表”类型提供了极大的便利和类型安全。
3. Exclude<T, U> 和 Extract<T, U>:联合类型的“交集”与“差集”
这两个工具专门用于处理联合类型 (Union Types)。
Exclude<T, U>(Exclude: 排除): 从T中排除所有可以赋值给U的类型。相当于计算T和U的差集。Extract<T, U>(Extract: 提取): 从T中提取所有可以赋值给U的类型。相当于计算T和U的交集。
type AllStatus = 'pending' | 'processing' | 'success' | 'failed';
type FinalStatus = 'success' | 'failed';
// 从所有状态中,排除掉最终状态,得到正在进行的状态
type OngoingStatus = Exclude<AllStatus, FinalStatus>; // 'pending' | 'processing'
// 从所有状态中,提取出数字类型的状态(假设有)
type NumberStatus = Extract<All-Status | 1 | 2, number>; // 1 | 2
写在最后:从“类型定义者”到“类型 arquitetos”
TypeScript 的实用工具类型,是引领我们从一个单纯的“类型定义者”,走向一个更高级的“类型 arquitetos”的桥梁。
我们不再需要为每一个细微的差异而去重复地创造类型。相反,我们学会了基于已有的基础类型,通过组合、变换、裁剪等“体操动作”,以一种声明式的方式,派生出我们所需要的新类型。
这种编程方式,我们称之为类型编程 (Type Programming)。它让我们的类型定义变得:
- DRY (Don’t Repeat Yourself):避免了冗余。
- 可维护:修改源头类型,所有衍生类型自动更新。
- 表达力强:类型本身就在讲述它们之间的关系和派生逻辑。
所以,道友们,请将这些强大的“仙法”融入你的日常修行。当你下一次需要创建一个新类型时,请先扪心自问:“我能否通过这些实用工具,从一个已有的类型‘变’出来?”
当你开始这样思考时,你就真正地掌握了 TypeScript 的精髓,你的代码也将达到一个新的、严谨与优雅并存的境界。
专栏预告与互动:
我们已经掌握了 TypeScript 的一些实用技巧。但要真正发挥 TS 的威力,我们需要理解它最核心、最强大的特性之一——泛型 (Generics)。泛型是如何让我们创建出可复用的、类型安全的组件和函数的?
- 下一篇,我们将深入探索 TypeScript 的灵魂——泛型编程。你将学会如何定义泛型函数、泛型接口和泛型类,让你的代码在保持类型安全的同时,具备极致的灵活性!
感觉码力无边的“类型体操”让你对 TypeScript 的理解焕然一新?别忘了点赞、收藏、关注,你的每一次支持,都是我揭开 TypeScript 更深层奥秘的强大动力!
今日论道:
Omit<T, K>看起来非常有用,但它并不是 TypeScript 最早内置的工具类型之一。在没有Omit的年代,你能否只用Pick和Exclude来组合实现一个功能完全一样的MyOmit<T, K>类型呢?这是一个经典的类型体操挑战,把你的解法或思路分享在评论区,我们一起探讨!
更多推荐



所有评论(0)