本节目标:读完这一节,你能说出 TypeScript 标准库里每一个内置工具类型的用途与参数;能用第 9 章的映射类型与条件类型手写
Partial、Pick、Omit、ReturnType;能判断哪些工具类型是浅层的、哪个工具类型的键不受约束;并能在真实项目里为表单、API 契约挑对工具类型。
10.1 内置工具类型全解
第 9 章我们学会了三件工具:keyof / 索引访问(读取类型)、映射类型(生成类型)、条件类型与 infer(判断类型)。标准库里的「工具类型(Utility Types)」不是新语法,它们全部是用这三样东西写出来的类型别名。
理解这一点很重要:工具类型不是魔法,而是一份官方替你写好的「类型函数库」。本节先把它们分组过一遍,再挑几个手写实现,最后给出速查表与踩坑清单。
为什么需要工具类型
真实项目里的类型很少是「从零发明」的,绝大多数是「在已有类型上做变换」:
- 表单草稿 = 实体类型的所有字段变可选 →
Partial - 列表项 = 实体类型去掉几个大字段 →
Omit - 只读配置 = 实体类型全字段只读 →
Readonly - 接口返回值 = 服务函数的返回类型 →
ReturnType
如果每处都手写一遍,接口一变就要改几十个地方。工具类型的价值就在于把「类型的派生关系」写进代码,让派生类型自动跟随源头类型演进。
对象修饰类:Partial / Required / Readonly
这三个是最常用的「整体修饰」工具:
| 工具类型 | 作用 | 手写实现 |
|---|---|---|
Partial<T> | 所有属性变可选 | { [K in keyof T]?: T[K] } |
Required<T> | 所有属性变必填 | { [K in keyof T]-?: T[K] } |
Readonly<T> | 所有属性变只读 | { readonly [K in keyof T]: T[K] } |
-? 里的减号是「移除修饰符」的意思,对应还有 +?(默认行为)和 -readonly:
interface User {
id: number;
name?: string;
email: string;
}
type DraftUser = Partial<User>;
// { id?: number; name?: string; email?: string }
type CompleteUser = Required<User>;
// { id: number; name: string; email: string }
type FrozenUser = Readonly<User>;
// { readonly id: number; readonly name?: string; readonly email: string }
const u: FrozenUser = { id: 1, email: "a@b.c" };
u.id = 2;
// 错误:Cannot assign to 'id' because it is a read-only property. ts(2540)
关键认知:这三个都是浅层的(shallow)。 嵌套对象的内层不会被修饰:
interface Profile {
user: User;
tags: string[];
}
type DraftProfile = Partial<Profile>;
// { user?: User; tags?: string[] }
// 注意:user 变成 User | undefined,但 User 内部的 id 仍然是必填的
需要深层修饰就得自己写递归版本——这正是 10.3 递归类型与类型性能治理 的主题。
键挑选类:Pick / Omit / Record
按「键的名字」增删属性:
| 工具类型 | 作用 | 手写实现 |
|---|---|---|
Pick<T, K> | 保留 K 中的键 | { [P in K]: T[P] },K 约束为 keyof T |
Omit<T, K> | 排除 K 中的键 | Pick<T, Exclude<keyof T, K>> |
Record<K, V> | 构造键为 K、值为 V 的对象 | { [P in K]: V } |
interface Article {
id: number;
title: string;
body: string;
createdAt: Date;
}
type ArticleSummary = Pick<Article, "id" | "title">;
// { id: number; title: string }
type ArticleInput = Omit<Article, "id" | "createdAt">;
// { title: string; body: string }
type ArticleMap = Record<string, ArticleSummary>;
// { [x: string]: ArticleSummary }
type StatusMap = Record<"idle" | "loading" | "done", number>;
// { idle: number; loading: number; done: number }
Record 的键参数接受 string | number | symbol 的子类型,所以传字面量联合时会生成精确的键,而不是宽泛的索引签名。这一点在构造映射表时非常有用:
const retryMap: Record<"GET" | "POST" | "PUT", boolean> = {
GET: true,
POST: false,
PUT: true,
// 少写一个键 → Property 'PUT' is missing in type ... ts(2741)
};
Omit 的实现在内部用了 Exclude<keyof T, K>,所以它间接依赖条件类型的分发能力,这也解释了为什么 Omit 的 K 不受 keyof T 约束(后面「常见坑」会展开)。
集合运算类:Exclude / Extract / NonNullable
这三个作用在联合类型上,靠的是条件类型的分发(回顾 9.3 条件类型与 infer ):
| 工具类型 | 作用 | 手写实现 |
|---|---|---|
Exclude<T, U> | 从 T 中剔除可赋值给 U 的成员 | T extends U ? never : T |
Extract<T, U> | 从 T 中只留可赋值给 U 的成员 | T extends U ? T : never |
NonNullable<T> | 剔除 null 与 undefined | T extends null | undefined ? never : T |
type Event = "click" | "focus" | "blur" | "scroll";
type MouseEvent = Extract<Event, "click" | "focus">;
// "click" | "focus"
type NonMouseEvent = Exclude<Event, "click" | "focus">;
// "blur" | "scroll"
type MaybeName = string | null | undefined;
type Name = NonNullable<MaybeName>; // string
这三个工具类型对非联合类型同样有效,只是结果退化得比较朴素:Exclude<string, "a"> 是 string(因为 string 不 extends "a"),Extract<string, "a"> 是 never(因为 string 不能赋值给 "a")。
函数与异步类
| 工具类型 | 作用 |
|---|---|
ReturnType<T> | 取函数返回类型 |
Parameters<T> | 取函数参数元组 |
ConstructorParameters<T> | 取构造函数参数元组 |
InstanceType<T> | 取构造函数实例类型 |
ThisParameterType<T> / OmitThisParameter<T> | 处理 this 参数 |
Awaited<T> | 递归解开 Promise |
async function loadUser(id: number) {
return { id, name: "Ada", roles: ["admin"] as const };
}
type LoadUserReturn = ReturnType<typeof loadUser>; // Promise<{...}>
type LoadUserArgs = Parameters<typeof loadUser>; // [id: number]
type User = Awaited<LoadUserReturn>;
// { id: number; name: string; roles: readonly ["admin"] }
type P = ConstructorParameters<typeof Date>; // [value?: number | string | Date]
type D = InstanceType<typeof Date>; // Date
注意 typeof loadUser 的用法:loadUser 是值,typeof 把它转成类型,才能喂给 ReturnType。这是 9.1 keyof·typeof 与索引访问类型
里讲过的桥梁。
Awaited<T> 是递归的,能穿透多层 Promise:
type R1 = Awaited<Promise<string>>; // string
type R2 = Awaited<Promise<Promise<number>>>; // number
type R3 = Awaited<number>; // number(非 Promise 原样返回)
ThisParameterType / OmitThisParameter 处理的是函数签名的 this 参数,属于偏门但偶尔救命的工具,写法细节见 4.2 重载、this 类型与箭头函数
。
字符串修饰类:四个 intrinsic
type A = Uppercase<"hello">; // "HELLO"
type B = Lowercase<"HELLO">; // "hello"
type C = Capitalize<"hello">; // "Hello"
type D = Uncapitalize<"Hello">; // "hello"
这四个由编译器内置实现(intrinsic),无法用 TypeScript 语法手写,属于真正的「编译器魔法」。它们真正的威力要和模板字面量类型组合才显现:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Point { x: number; y: number }
type PointGetters = Getters<Point>;
// { getX: () => number; getY: () => number }
这段代码同时用到了映射类型的键重映射(as)、Capitalize 与模板字面量类型。其中 string & K 是必要的:K 的静态类型是 string | number | symbol,不先收窄成 string,Capitalize 会报参数不满足 string 约束。完整展开见 10.2 模板字面量类型
。
一个真实工程示例:表单与 API 契约
把本节工具类型串起来,可以搭出一条「实体 → 输入 → 草稿 → 列表」的类型流水线:
interface UserEntity {
id: number;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
// 1. 创建时不需要服务端生成的字段
type CreateUserInput = Omit<UserEntity, "id" | "createdAt" | "updatedAt">;
// { name: string; email: string }
// 2. 编辑表单:字段可选,且服务端字段不可改
type UpdateUserForm = Partial<Omit<UserEntity, "id" | "createdAt">>;
// { name?: string; email?: string; updatedAt?: Date }
// 3. 列表展示:只要摘要
type UserListItem = Pick<UserEntity, "id" | "name" | "email">;
// 4. 分页响应:泛型信封
type Paged<T> = {
items: T[];
total: number;
page: number;
};
type UserPage = Paged<UserListItem>;
整条链路只有 UserEntity 一个真实来源。 未来给实体加一个 avatar 字段,CreateUserInput 会自动带上它、UpdateUserForm 也会自动接受它,而 UserListItem 保持不变——这正是「类型从实现生长出来」的收益。
常见坑与错误信息
坑一:以为 Partial 是深层的。 见前文,嵌套对象不受影响。深层版本要写递归类型,而递归映射会显著增加编译器工作量,不要在超大型类型上滥用。
坑二:Omit 的 K 不受约束。 Pick<T, K> 要求 K extends keyof T,但 Omit<T, K> 的 K 是 PropertyKey,写错键名不报错:
type Wrong = Omit<UserEntity, "nmae">; // 拼错也不报错,等于什么都没去掉
这是最隐蔽的一类 bug:类型看起来变了,实际没变,而运行时会多出一个不该存在的字段。
坑三:Required 会连 undefined 一起去掉。
interface Opt { a?: string | undefined }
type Req = Required<Opt>; // { a: string } —— undefined 被一并移除
坑四:Readonly 防不住运行时修改。 它只在编译期生效,类型擦除后 JavaScript 里照样能改。要真正冻结对象得用 Object.freeze:
const config = Object.freeze({ retries: 3 });
config.retries = 5;
// 错误:Cannot assign to 'retries' because it is a read-only property. ts(2540)
坑五:Exclude 与 never、any 的交互。 Exclude<never, string> 是 never(空联合分发后仍是空);Exclude<any, string> 是 any。这两个结果都容易让人意外。
坑六:把工具类型当成运行时工具。 所有工具类型在编译后都会被完全擦除。要在运行时校验数据,需要 schema 方案,可参考既有专题 /typescript-runtime-validation-typesafe/ 与 /typescript-zod-validation/ 。
速查表
| 分组 | 工具类型 |
|---|---|
| 修饰 | Partial Required Readonly |
| 挑选 | Pick Omit Record |
| 集合 | Exclude Extract NonNullable |
| 函数 | ReturnType Parameters ConstructorParameters InstanceType ThisParameterType OmitThisParameter ThisType |
| 异步 | Awaited |
| 字符串 | Uppercase Lowercase Capitalize Uncapitalize |
完整清单与常见组合模式另见本书 附录 B 内置工具类型与常用类型模式 。
小结
- 工具类型不是新语法,全部由映射类型、条件类型、
infer组合而成,完全可以自己手写。 Partial/Required/Readonly是浅层修饰,深层需求要写递归类型。Pick的键受keyof T约束,Omit的键不受约束,拼错键名不报错——这是最常见的静默 bug。Exclude/Extract/NonNullable依赖条件类型的分发,对联合类型最有用。Awaited递归解开 Promise;Uppercase等四个字符串工具由编译器内置,需配合模板字面量类型才发挥威力。- 真实项目里应让工具类型构成一条从实体出发的派生链,保证「只有一份真实来源」。
下一节我们进入模板字面量类型——它让类型系统具备字符串级别的运算能力,也是把 get${Capitalize<K>} 这类写法讲透的地方。
阅读导航:上一节:9.3 条件类型与 infer · 下一节:10.2 模板字面量类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。