《TypeScript编程入门》10.1 内置工具类型全解

本节把 TypeScript 标准库里的内置工具类型逐个拆开:从对象修饰的 Partial、Required、Readonly,到集合运算的 Pick、Omit、Exclude、Extract,再到函数与异步相关的 ReturnType、Parameters、Awaited。我们用第 9 章的映射类型与条件类型手写它们,讲清浅层修饰与 Omit 键不受约束这两个常见坑,并给出分组速查表。

本节目标:读完这一节,你能说出 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 与 undefinedT 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 模板字面量类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes