TypeScript 高级工具类型:从"类型世界"到"值世界",一套组合拳搞定类型转换
摘要:TypeScript 的高级工具类型不是"语法糖",而是类型编程的核心武器。本文从"类型世界 vs 值世界"的根本认知出发,拆解 Pick、Omit、Partial、Record、ReturnType 等工具类型的原理与组合用法,帮你写出更健壮、更可维护的类型代码。
📑 目录
- 两个世界:类型世界与值世界
- typeof:连接两个世界的桥梁
- 为什么需要工具类型?
- 联合类型与 keyof:工具类型的基石
- 对象类型工具:Pick、Omit、Partial
- 联合类型工具:Exclude、Extract
- 函数类型工具:ReturnType、Parameters
- 映射类型工具:Record
- 组合实战:从接口到 DTO 的完整类型设计
- 互动讨论
两个世界:类型世界与值世界
TypeScript 中存在两个平行的世界:
| 世界 | 位置 | 操作对象 | 示例 |
|---|---|---|---|
| 类型世界 | : 后面、<> 里面、type 声明、interface |
类型 | type T = string; |
| 值世界 | 赋值语句、函数调用、console.log |
值 | const x = 'hello'; |
这两个世界在编译时完全隔离。类型世界的内容在编译后会被擦除,不会出现在最终的 JavaScript 代码中;值世界的内容则会在运行时真实存在。
typescript
typescript
// 类型世界
interface User {
id: number;
name: string;
}
// 值世界
const user: User = { id: 1, name: '大许' };
User 活在类型世界,user 活在值世界。它们共享同一个名字,但属于不同的维度。
typeof:连接两个世界的桥梁
typeof 在 JavaScript(运行时)和 TypeScript(编译时)里干的是完全不同的两件事:
typeof 出现的位置 |
作用 | 示例 |
|---|---|---|
| 值世界(JS 运行时) | 返回变量的基本类型(字符串) | typeof myFunc → "function" |
| 类型世界(TS 类型注解中) | 把值 转为类型定义 | type T = typeof myFunc |
核心结论: ReturnType、Parameters 等工具只吃"类型",不吃"值"。要处理具体函数时,必须先用 typeof 把它送进类型世界。
typescript
ini
const foo = (a: string) => 123;
// ❌ 错误:foo 是值,不是类型
type Wrong = ReturnType<foo>;
// ✅ 正确:先用 typeof 把值转为类型
type Right = ReturnType<typeof foo>; // number
记忆口诀:在类型世界里,typeof 是"值→类型"的传送门。
为什么需要工具类型?
手写独立类型和用工具类型生成类型,最终结果在类型定义上是完全等价的 。但核心区别在于:你建立的是派生关系 还是独立副本。
1. 维护成本:改一处,自动变
假设 User 接口在项目迭代中发生了变化:
typescript
typescript
// 原来的定义
interface User {
id: number;
name: string; // 后来改成了 fullName
email: string;
}
// 版本升级后
interface User {
id: number;
fullName: string; // 👈 字段名变了
email: string;
}
- 手写独立类型 :
type UserBasic = { name: string; email: string; }不会自动更新,必须手动全局搜索修改。 - 使用工具类型 :
type UserBasic = Pick<User, 'name' | 'email'>。TypeScript 会报错(name不存在于User中),你只需把'name'改成'fullName',一处修改,所有使用处自动同步。
2. 组合能力:避免"类型爆炸"
假设你需要多种形态的 DTO:
typescript
ini
type CreateUserDto = Pick<User, 'name' | 'email'>;
type UpdateUserDto = Partial<Pick<User, 'name' | 'email'>>;
type UserResponse = Omit<User, 'password'>;
当 User 加新字段时,只需确定"该不该出现在 DTO 里",修改对应的 Pick 或 Omit,派生关系让你永远不用去手动同步副本。
3. 语义表达:代码自文档化
当你看到 Pick<User, 'name' | 'email'> 时,一眼就知道这个类型是 User 的严格子集 。而手写类型无法从定义上看出和 User 的关联。
什么时候应该手写独立类型?
- 该类型与现有类型没有关系(如全新的第三方 API 响应结构)
- 该类型是聚合型,由多个无关类型拼凑而成
- 希望该类型完全不依赖底层变化,即使源头变了也保持原样
联合类型与 keyof:工具类型的基石
联合类型(Union Type)
联合类型表示"值可以是这些类型中的任意一种",用管道符
|表示。
typescript
ini
let id: string | number;
id = "abc123"; // ✅
id = 456; // ✅
id = true; // ❌ boolean 不在联合类型中
类型收窄(Type Narrowing)
当变量是联合类型时,TypeScript 只允许调用所有类型共有的方法。要调用特定类型的方法,必须进行类型收窄:
typescript
typescript
function printLength(value: string | number) {
// ✅ 使用 typeof 收窄
if (typeof value === "string") {
console.log(value.length); // 这里 value 是 string
} else {
console.log(value.toFixed(2)); // 这里 value 是 number
}
}
常用类型收窄方式:typeof、in、instanceof、Array.isArray、switch。
keyof 操作符
keyof是"类型世界"的操作符,用于把"对象类型"的所有键名提取为"联合类型"。
typescript
ini
interface User {
id: number;
name: string;
age: number;
}
type UserKeys = keyof User; // "id" | "name" | "age"
typeof 与 keyof 的分工
| 操作符 | 入口 | 出口 | 记忆锚点 |
|---|---|---|---|
typeof(类型世界) |
一个值(变量/函数) | 这个值的类型定义 | "把值变成类型" |
keyof |
一个类型(对象类型) | 属性名组成的联合类型 | "提取对象的钥匙串" |
对象类型工具:Pick、Omit、Partial
Pick<T, K> ------ 从 T 中挑选指定属性
typescript
ini
interface User {
id: number;
name: string;
age: number;
email: string;
}
type UserPreview = Pick<User, "id" | "name">;
// 结果:{ id: number; name: string; }
const u1: UserPreview = {
id: 1,
name: "大许"
};
物理本质 :Pick 遍历 K 中的每个成员,从 T 中取出对应属性,组装成新类型。
参数:
T:对象类型K:keyof T的子集(联合类型)
Omit<T, K> ------ 从 T 中剔除指定属性
typescript
ini
type UserSafe = Omit<User, "email">;
// 结果:{ id: number; name: string; age: number; }
const u2: UserSafe = {
id: 2,
name: "大刘",
age: 18
};
实现原理 :Omit<T, K> = Pick<T, Exclude<keyof T, K>>
物理过程:
keyof T→'id' | 'name' | 'age' | 'email'Exclude<联合类型, K>→'id' | 'name' | 'age'Pick<T, 'id' | 'name' | 'age'>→{ id: number; name: string; age: number; }
Partial<T> ------ 所有属性变为可选
typescript
css
type PartialUser = Partial<User>;
// 结果:{ id?: number; name?: string; age?: number; email?: string; }
// 适合 PATCH 更新场景------客户端只传需要修改的字段
const patch: PartialUser = {
name: "大锋",
age: 18
};
参数 :T 是对象类型。
工具类型对比
| 工具 | 作用 | 参数 | 典型场景 |
|---|---|---|---|
Pick<T, K> |
挑选指定属性 | T 对象,K 联合类型 |
数据预览、列表展示 |
Omit<T, K> |
剔除指定属性 | T 对象,K 联合类型 |
隐藏敏感字段 |
Partial<T> |
所有属性可选 | T 对象 |
PATCH 更新、表单初始值 |
联合类型工具:Exclude、Extract
Exclude<T, U> ------ 从联合类型中排除成员
typescript
ini
type All = "id" | "name" | "age" | "email";
type AfterExclude = Exclude<All, "email">;
// 结果:"id" | "name" | "age"
参数:
T:联合类型U:要排除的类型
Extract<T, U> ------ 从联合类型中提取成员
typescript
ini
type All = "id" | "name" | "age" | "email";
type AfterExtract = Extract<All, "name" | "email">;
// 结果:"name" | "email"
Exclude vs Omit
| 对比 | Exclude<T, U> | Omit<T, K> |
|----------|------------------|--------------|--------|-------------------------------|
| 操作对象 | 联合类型(如 `'a' | 'b' | 'c'`) | 对象类型 (如 interface User) |
| 输出 | 联合类型 | 对象类型 |
| 典型场景 | 类型字符串过滤 | 对象属性剔除 |
函数类型工具:ReturnType、Parameters
ReturnType<T> ------ 提取函数的返回值类型
typescript
csharp
function fn() {
return { x: 1, y: 2 };
}
type FnReturn = ReturnType<typeof fn>;
// 结果:{ x: number; y: number; }
⚠️ 关键注意 :ReturnType 只接受类型 ,不接受值 ,必须用 typeof。
错误示例:
typescript
rust
// ❌ 报错:ReturnType 只接受函数类型
type Wrong = ReturnType<fn>;
// ✅ 正确:先用 typeof 把函数值转换为类型
type Right = ReturnType<typeof fn>;
Parameters<T> ------ 提取函数的参数类型(元组)
typescript
typescript
function greet(name: string, age: number): void {}
type GreetParams = Parameters<typeof greet>;
// 结果:[string, number]
参数类型速查
| 工具 | 第一个参数 T |
结果 |
|---|---|---|
ReturnType<T> |
函数类型 | 函数返回值类型 |
Parameters<T> |
函数类型 | 函数参数元组类型 |
映射类型工具:Record
Record<K, T>用固定键和固定值类型构造一个对象类型。
typescript
typescript
// 键为 string,值为 number
type Dict = Record<string, number>;
const obj: Dict = { a: 1, b: 2 };
// HTTP 状态码映射
type ErrorMsgMap = Record<number, string>;
const errorMessage: ErrorMsgMap = {
400: "请求参数错误",
401: "未登录,请重新登录",
403: "权限不足,禁止访问",
404: "找不到资源",
500: "服务器内部错误",
};
function getErrMsg(code: number) {
return errorMessage[code] ?? "未知错误";
}
参数:
K:联合类型(作为键名)T:类型(作为值类型)
组合实战:从接口到 DTO 的完整类型设计
将上述工具类型组合使用,可以构建一套完整的类型体系:
typescript
typescript
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// 场景1:创建用户 DTO --- 只需要 name 和 email
type CreateUserDto = Pick<User, 'name' | 'email'>;
// 场景2:更新用户 DTO --- 所有字段都可选,且不能改 id 和 createdAt
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt'>>;
// 场景3:从函数返回值中提取数据类型
function fetchUser(id: number): Promise<{ data: User; status: number }> {
return Promise.resolve({
data: { id: 1, name: '大许', email: 'xx@xx.com', password: '123', createdAt: new Date() },
status: 200,
});
}
type FetchUserReturn = ReturnType<typeof fetchUser>;
// 场景4:枚举值到描述的映射
type UserRole = 'admin' | 'user' | 'guest';
type RoleDescription = Record<UserRole, string>;
const roleDesc: RoleDescription = {
admin: '管理员',
user: '普通用户',
guest: '访客',
};
互动讨论
💬 Pick 和 Omit 能接受 keyof T 之外的字符串吗?
不能。K 必须是 keyof T 的子集。如果传入 User 中不存在的键,TypeScript 会报错。
💬 Partial<T> 和 { [P in keyof T]?: T[P] } 有什么关系?
Partial<T> 内部就是用这个"映射类型"语法实现的。它是 TypeScript 内置的工具类型,本质上是一个预定义的泛型类型。
💬 为什么 ReturnType 不能直接传入函数,必须用 typeof?
ReturnType 工作在类型世界 ,只接受类型 参数。函数名(如 fn)是一个值 ,存在于值世界 。typeof fn 把值转换为类型,才能被 ReturnType 处理。
💬 Exclude 和 Omit 的核心区别是什么?
Exclude 处理联合类型 (如 'a' | 'b' | 'c'),Omit 处理对象类型 (如 interface User)。Omit 内部实际上用了 Exclude 来过滤键名。
💬 工具类型在实际项目中怎么用?
最常见的场景是DTO(数据传输对象) 设计。比如从 User 接口派生出 CreateUserDto、UpdateUserDto、UserResponse,避免重复定义,源头变化时自动同步。