在某个手办模型工作室里,最厉害的往往不是捏黏土的手,而是画图纸的手。模型还没开模,所有想法都要先在图纸上验证:加一顶帽子、换一个姿势、把领子改成可拆件——图纸上改一笔只需五分钟,开模后再改就得回炉重造。TypeScript 就是前端世界的「图纸系统」:你写的每一个类型,都在描述数据该长什么样;而「类型体操」,就是在图纸上做各种改造的技巧总称——把类型当作可以计算的原料,用工具类型、条件类型、模板字面量这些「改造工具」,在编译期就雕刻出想要的形状。 这门手艺为什么重要?因为图纸上的错误,永远比成品上的错误好修。掌握类型体操,等于让编译器在你写代码时就提供「形状检查」:接口返回的数据、路由的参数、状态的形状,一切在运行前就已严丝合缝。
对 AI 从业者的价值:掌握类型体操,你写的库和业务代码自带类型说明书,大模型辅助编程时提示更精准、重构更安全,工程协作的效率会上一大截。
可视化演示
展示TypeScript类型体操中工具类型变换、条件类型推导与模板字面量类型操作的完整流程。切换模式查看不同类型变换维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
工具类型变换 Partial/Required/Pick/Omit等工具类型对接口字段进行变换 映射类型遍历键名实现批量属性修饰 Output = Utility 工具类型是类型层面的高阶函数
类型体操入门
在手办模型工作室里,最值钱的是原型师的图纸。新企划启动时,原型师不会直接上手捏黏土,而是在图纸上反复改:加一顶帽子、换一个手势、把领子做成可拆件。图纸上改一笔只需五分钟,开模后改一笔要回炉重造整整一天。TypeScript 开发者就是拿着「类型图纸」工作的原型师,而类型体操,就是图纸上的各种改造手艺。
要玩转这些手艺,先分清两个世界:运行时世界和类型世界。const user = { name: 'Alice' } 是一个真实存在的「手办」,运行时世界里它占着内存;而 type User = { name: string } 是它的「设计图标注」,只在编译期被检查。编译一完成,设计图就被收进抽屉——类型信息全部被擦除,JS 引擎根本看不到它们。这就是常说的「类型擦除」。
类型体操的操作对象正是这些设计图。type 别名相当于给一张图纸命名,方便反复引用;keyof T 像一个放大镜,把图纸上的零件名全部列成清单,得到的是「字符串字面量联合类型」;索引访问 T[K] 则像按零件名查找规格,取出某个属性的值类型。三者组合,已经能做出第一套体操动作:查询并描述你的数据结构。
就像原型师在图纸角落画一张「零件清单」,keyof 把设计图里所有可动部件登记在案。这张清单是后续所有工具类型施展手艺的基础——没有零件名,后面的改造动作都无从下手。
- keyof 查询放在类型 T 前面,取出 T 的全部属性名。
- 集合花括号结果是一个字符串字面量联合类型,花括号表示这是枚举出的「属性名集合」。
- 成员约束K 被限定为 T 的属性名,而不是属性的值或方法。
内置工具类型
工作室的墙上挂着一排标准改造套件:有的负责「全部放宽要求」,有的负责「只留重点零件」,有的负责「清点并新建规格」。这些是老师傅们反复用顺手的通用方案。TypeScript 也内置了这样一排「工具类型」,不需要你从零发明,直接拿来用就行。
工具类型的本质是一个「类型层函数」:输入一个类型,输出一个新类型,在编译期完成计算。比如 Partial<T> 把 T 的每个属性都加上可选标记,Required<T> 反过来把可选标记全部去掉(相当于给 Partial 做逆向操作)。它们的实现并不神秘——背后都是映射类型在遍历属性,这一点后面会看到。
四个最常用的内置工具,正好对应四种图纸调整需求:Partial<T>——所有零件从「必装」改为「可装可不装」,适合做表单草稿模型;Pick<T, K>——只保留你点名的零件,其余全部裁剪,适合从大接口里捞出视图需要的一小块;Omit<T, K>——反向操作,把点名的零件删掉,其余全留,常用在剔除敏感字段;Record<K, V>——用一组新键 K 和统一值类型 V 从零画一张图纸,比如「每款手办的 SKU 都对应一个价格记录」。
就像工作台上的四把标准螺丝刀,Partial、Pick、Omit、Record 覆盖了「放宽、精选、剔除、新建」四类最高频的图纸需求。记住它们,多数日常类型变换不用自己动手造轮子。
- 枚举属性名先用 keyof T 得到所有属性名的清单。
- 遍历绑定P in keyof T 表示 P 轮流代表清单里的每一个属性名。
- 可选标记问号
?为当前属性加上可选标记,完成「放宽要求」。 - 保留值类型T[P] 从原类型中取出该属性的值类型,保证形状不变。
条件类型进阶
原型师在选材料时有个习惯:先摸一摸再定工艺。如果材料是透明树脂,就走透光涂装流程;否则走标准涂装流程。类型世界也需要这种「看情况分流」的判断,这就是条件类型。
条件类型的语法像极了 JS 的三元表达式:T extends U ? X : Y。编译器先检查 T 是否可以被赋给 U(也就是 T 能不能装进 U 这个模具),能则结果取 X,否则取 Y。这个判断发生在编译期,不会影响运行时的一行代码。
条件类型真正强大的地方在于配合 infer——也就是「推理」。infer 不是判断能否装进模具,而是从模具中把某个部位「抽出来」。比如你拿到一个函数类型,想取出它的返回值类型:
编译器看到 infer R 就明白了:「把返回值位置的空格填上你要的类型」。若 T 确实是函数类型,R 就被实例化为返回值类型,ReturnType<T> 由此实现。另外条件类型还有个「分布式」行为:当 T 是联合类型(如 'a' | 'b')且 T 是裸泛型参数时,判断会像拆快递一样对每个成员分别进行,再把结果用 | 重新拼起来。这让联合类型的过滤、提取变得异常优雅。
条件类型是工作台上的「分流闸」,infer 是从成品倒推模具尺寸的「卡尺」。两者结合,让原型师不仅会改图纸,还会从一张陌生图纸里反向提取出关键数据——这是类型体操中最核心的「判断+提取」组合技。
- 忽略形参
(...args) => ...模式表示「任意的函数类型」,形参整体被省略号忽略。 - 占位捕捉在返回值位置写 infer R,编译器匹配成功后把返回类型存进 R。
- 结果分支匹配成功返回 R,否则回退 any 做宽松兜底。
映射类型机制
某天工坊接了一笔大单:一百款手办,全部要在图纸上加一行「限量编号」。有经验的老师傅不会一张图一张图地改,而是布置一条流水线——「给每一张经过的图纸盖上编号戳」。映射类型就是类型层的这条流水线。
映射类型的基本写法是对一个类型的属性名做遍历。你或许已经注意到,Partial 的实现就用了这种结构:
读法是:对于 T 的属性名 P,逐个生成新属性,新属性名仍叫 P,值类型取 T[P]。这相当于类型层的 for...in 循环。
映射类型还能做三件「高级体操」。一是键值重映射:用 as 改写属性名,比如把所有键加上前缀 readonly_。二是修饰符增减:在 ? 和 readonly 前面加 - 就能删掉它们,Required<T> 的本质就是 [P in keyof T]-?: T[P]。三是配合条件类型按需过滤:遍历时对 T[P] 做判断,不符合条件的键用 never 踢出结果。实际开发中,给接口的每个字段包一层 Promise、把每张表的更新时间字段统一转为字符串,一行映射类型就能完成——这正是工具类型背后的「发动机」。
映射型流水线把「逐个画图」升级为「批量盖章」,是手工到半自动化的跃迁。理解它,你就看穿了 Partial、Required、Readonly、Record 这些工具背后的共同引擎。
- 属性名清单keyof T 枚举出 T 的全部属性名。
- 循环取键P in 让 P 依次绑定清单里的每一个属性名。
- 取原值类型T[P] 是索引访问,读取该属性对应的值类型。
模板字面量
每款手办出厂都要刻铭牌,格式是固定的:「系列名-三位编号」,比如 pilot-07、ship-99。铭牌规则一旦定下,所有型号都必须遵守。模板字面量类型就是给类型世界的「铭牌」定规则的工具。
它复用 JavaScript 模板字符串的写法,但作用在类型层。一个类型 ` ${string}-${number} 描述的不是具体某个字符串,而是一整类模式:任意字符串、短横线、任意数字。于是 "pilot-07" 可以通过类型检查,"pilot-0x"` 则不行。这种「以模式匹配字符串」的能力,是字符串类型从死板走向灵活的分水岭。
模板字面量类型还有两个最佳搭档。其一是字符串工具类型:Uppercase<S>、Lowercase<S>、Capitalize<S> 像一盒印章,给字符串类型统一大小写风格,常配合模板字面量为事件名、Action 类型生成规范格式。其二是 infer 模式匹配:` T extends get${infer Rest} 能识别出所有以 get 开头的方法名,并把后缀提取出来。前端路由是这类技巧最经典的舞台:把 /user/:id/posts/:postId 拆成段、捕获 :id 和 :postId,映射成参数对象 { id: string; postId: string }`,从此路由参数在编译期就自带完整类型。
模板字面量类型给图纸加了一台「格式化铭牌机」:字符串类型不再只能死记几个字面量,而是能描述一整类模式。路由、事件名、枚举式字符串——所有有规律的文本,都能在编译期验明正身。
${string} 是任意字符串位,${number} 是任意数字位,其余字符必须原样匹配。- 字符串插值段
${string}表示此处出现任意字符串。 - 字面量分隔符短横线是固定字符,必须原样匹配,不能多不能少。
- 数字插值段
${number}表示此处只能是数字型字符串。
递归与实战
工坊仓库里有一种「俄罗斯套娃」手办:大娃肚子里套中娃,中娃里再套小娃,层数不固定。要在设计图上表示这种结构,得让图纸自己引用自己——这就是递归类型。
递归类型指一个类型别名在定义中直接或间接引用自身。最常见的例子是树:一个菜单节点有名称和子节点,子节点的类型还是菜单节点——interface TreeNode { name: string; children?: TreeNode[] }。这种自引用在类型系统里完全合法,只要递归层数在有穷处终止。
递归在工具类型中最大的用武之地是「深度变换」。Partial 只把第一层属性变成可选,可表单场景常常希望嵌套对象也全部可选。这时需要自己写 DeepPartial:
它先像 Partial 一样把第一层属性标可选,再对每个属性的值类型做条件判断:如果值还是对象,就递归地调用自己进入下一层;如果不是对象,直接保留原类型终止递归。同样的配方可以写 DeepReadonly、DeepNonNullable。像树的类型建模、无限级评论、文件目录,全都指望这套「拆套娃」的功夫。社区里热门的「类型挑战」(type-challenges)就是练这套功夫的训练馆。
从「一层图纸」到「层层图纸」,递归让类型体操有了纵深。就像最资深的老师傅面对套娃,知道一层一层拆到最里层一定能停下来——递归类型只要写好终止条件,再深的嵌套结构也能被稳稳驯服。
---
- 首层映射对 T 的每个属性加上可选标记,完成第一层放宽。
- 值类型判断取出属性值类型,判断它是否是一个对象。
- 递归深挖是对象就调用 DeepPartial 深入处理嵌套层。
- 递归出口不是对象就保留原类型,避免无限递归。
AI 应用场景
接口数据瘦身:Pick 与 Omit 的日常战斗
阶段:数据接入与呈现
解决的问题:后端返回的用户 DTO 字段多且含敏感信息,前端各页面只需要其中一小部分。手写精简类型会和 DTO 发生「重复劳动+漂移」——后端加一个字段,前端手写的类型就悄悄失联。用 Pick 直接从 DTO 类型中提取列表所需字段,用 Omit 剔除 password 等敏感字段,让类型层的边界与数据权限对齐。AI 大模型生成的接口代码往往字段过多,工具类型就像「数据抽油烟机」,瞬间收敛类型。
具体案例:管理后台用户管理页,后端 UserDTO 含 password、phone、address 等 12 个字段,列表组件只需要 id、name、email、loginAt。定义 type UserListItem = Pick<UserDTO, 'id' | 'name' | 'email' | 'loginAt'>,组件 props 绑定这个精简类型;构造「不含敏感字段的完整信息」时用 Omit<UserDTO, 'password' | 'token'>。
路由参数自动推导:模板字面量类型加持
阶段:路由与状态类型安全
解决的问题:Vue Router / React Router 动态路由 /user/:id 中,route.params.id 默认是 string,手写接口或类型断言容易与路由配置脱节。借助模板字面量类型 + infer 写一个 ParseRouteParams 工具,从路由字符串自动解析参数名,生成 { id: string } 参数对象类型。
具体案例:声明路由表 '/user/:id/posts/:postId' 后,工具类型自动推导出 { id: string; postId: string };配合 AI 代码补全,在 useRoute().params 处直接看到字段名和类型,彻底告别「魔法字符串」。
状态管理类型收敛:映射类型的流水线
阶段:状态管理与代码生成
解决的问题:Pinia/Vuex 的 store 中 state、getters、mutations 的类型散落多处,AI 生成代码时容易出现字段拼写不一致。用映射类型给 state 整体加只读保护、统一包装类型,让 store 的定义和消费处共享同一份类型真相。
具体案例:定义 defineStore 的泛型封装,state 类型 S 经过 { readonly [K in keyof S]: S[K] } 自动变为只读版本;配合模板字面量类型把 setXxx 方法名与 state 字段 xxx 自动配对。大模型生成新模块时,只需给出 state 一处声明,派生类型全部自动跟上。
Python 演示
# -*- coding: utf-8 -*-"""在 Python 中模拟 TypeScript 类型体操的核心工具类型""" from __future__ import annotations from dataclasses import dataclass, fieldfrom typing import Any, Dict, Iterable, Set @dataclassclass ObjType: """一个极简的「对象类型」表示: 属性名 -> 属性类型, 外加可选标记集合""" props: Dict[str, Any] = field(default_factory=dict) optional: Set[str] = field(default_factory=set) def copy(self) -> "ObjType": return ObjType(dict(self.props), set(self.optional)) def partial(t: ObjType) -> ObjType: """Partial<T>: 把所有属性标记为可选""" result = t.copy() result.optional.update(result.props.keys()) return result def required(t: ObjType) -> ObjType: """Required<T>: 去掉所有可选标记""" result = t.copy() result.optional.clear() return result def pick(t: ObjType, keys: Iterable[str]) -> ObjType: """Pick<T, K>: 只保留指定的属性""" result = ObjType() for k in keys: if k in t.props: result.props[k] = t.props[k] if k in t.optional: result.optional.add(k) return result def omit(t: ObjType, keys: Iterable[str]) -> ObjType: """Omit<T, K>: 剔除指定的属性""" result = t.copy() for k in keys: result.props.pop(k, None) result.optional.discard(k) return result def record(keys: Iterable[str], value_type: Any) -> ObjType: """Record<K, V>: 用一组键和同一个值类型构建新类型""" return ObjType({k: value_type for k in keys}) def match_template(template: str, text: str) -> bool: """模拟模板字面量类型: :param 是动态段, 其余必须逐字匹配""" t_parts = template.split("/") s_parts = text.split("/") if len(t_parts) != len(s_parts): return False for tp, sp in zip(t_parts, s_parts): if tp.startswith(":"): continue if tp != sp: return False return True def show(title: str, t: ObjType) -> None: marks = [k + ("?" if k in t.optional else "") for k in t.props] print(f"{title}: {{ {', '.join(marks)} }}") def main() -> None: user = ObjType({"id": int, "name": str, "age": int, "email": str}) show("原始 User", user) p = partial(user) show("Partial<User>", p) r = required(p) show("Required<Partial<User>>", r) picked = pick(user, ["id", "name"]) show("Pick<User, 'id'|'name'>", picked) omitted = omit(user, ["email"]) show("Omit<User, 'email'>", omitted) rec = record(["sku-01", "sku-02"], "Item") show("Record<'sku-01'|'sku-02', Item>", rec) print("匹配 /user/:id/post/:pid -> /user/42/post/7 :", match_template("/user/:id/post/:pid", "/user/42/post/7")) print("匹配 /user/:id/post/:pid -> /user/42/order/7 :", match_template("/user/:id/post/:pid", "/user/42/order/7")) if __name__ == "__main__": main()
result.optional.update(result.props.keys())Partial 实现核心:把所有属性名加入可选集合,模拟给每个属性加?标记。if k in t.props:Pick 只复制确实存在的属性,不存在的键直接跳过,避免产生幽灵属性。result.optional.discard(k)Omit 在删除属性时同步清理可选标记,防止残留问号。result.optional.clear()Required 把可选集合整体清空,相当于给所有属性强制「去问号」。tp.startswith(":")模拟模板字面量类型中的动态段:以冒号开头就视为参数,匹配任意值。show(f"{title}: {{ {', '.join(marks)} }}")用属性名后的问号直观呈现「可选」状态,对应 TS 里的name?: string。
常见误区
误区“我在类型里写 `typeof user`,运行时是不是真的会执行一次 typeof 运算?那会不会影响性能?”
正解TypeScript 的 typeof 出现在类型上下文时是「类型查询」,它在编译期把 user 的静态类型提取出来,根本不会生成任何运行时 typeof 指令。就像图纸上标注「这块用 ABS 塑料」不是真的拿一块材料去测,而是直接标注规格。编译完成后,类型层的一切都会消失,JS 引擎看到的只是图纸被收走后的实体。
为何常见:因为 JS 本身有 typeof 运算符,TS 复用了同一个关键字,新手很容易把「类型世界」和「运行时世界」混在一起。
误区“`keyof T` 拿到属性名数组,我用 `Object.keys(t)` 也能拿到,两者是不是一回事?”
正解完全不同。keyof T 在编译期产生一个「字符串字面量联合类型」,是类型的描述,不是真实数组;Object.keys 在运行时返回真实的字符串数组。一个在图纸世界,一个在实体仓库。keyof 永远不会进入 JS 代码,Object.keys 也帮不了编译期做类型检查。
为何常见:名字上都带「keys」,且实际开发中两者常常出现在同一个表达式附近,容易产生联想。
误区“我调用了 `Partial<Config>`,嵌套的子对象属性应该也全部变可选了吧?”
正解Partial 只处理一层。它的等价映射 [P in keyof T]?: T[P] 中,T[P] 没有继续递归,子对象仍是原来的嵌套结构。想要深层可选必须自己写 DeepPartial<T> 递归版。类比手办:Partial 只把外盒的「必装」改成「可装可不装」,盒子里的零件图纸一个都没动。
为何常见:表单、配置类场景最常见,人们希望一次搞定所有层级,而工具类型名里没有「浅层」两个字。
误区“类型推导太复杂,报错我就把类型写成 any,反正能跑。”
正解any 等于把设计图揉成白纸:编译期全部放行,所有形状错误都推迟到运行时才爆雷。类型体操的意义正是让错误在设计阶段暴露。优先用 unknown 收紧,再配合条件类型和 infer 逐步收窄;即使要写临时工具类型,也比 any 更负责任。
为何常见:项目 deadline 压力下,「先跑起来」的诱惑极大,而 TS 对复杂泛型的报错信息确实不够友好。
误区“我用了 `T extends object` 约束,为什么 T 不是对象的时候没有走我的备用分支?”
正解泛型约束是「门卫」,只负责检查,不符合直接编译报错;条件类型是「分流闸」,符合走 X、不符合走 Y,两种结果都合法。想表达「不满足约束也别报错,给个默认分支」必须用条件类型表达式,而不是约束。门卫不会帮你修路,但分流闸会。
为何常见:两者都写着 extends,从字面上非常相似,职责却完全不同。
面试题
Q1. 在 TypeScript 中,keyof T 的结果是什么?
- A.T 的所有属性名组成联合类型← 正确答案
- B.T 的所有属性值组成联合类型
- C.T 的实例方法名字符串全集合
- D.T 的运行时键数组的完整类型
答案:A。keyof 是编译期类型查询,返回 T 的属性名字符串联合类型,相当于图纸上的「零件清单」。B 把键和值混了,C 是运行时 Object.getOwnPropertyNames 才做的事,D 错误地把类型世界拉回运行时。
Q2. Partial<{ id: number; name: string }> 会产生什么效果?
- A.让 id 和 name 两个属性都变成可选← 正确答案
- B.让 id 和 name 两个属性都变成只读
- C.让 id 和 name 两个属性都变成必填
- D.让 id 和 name 两个属性都被删掉舍弃
答案:A。Partial 对每个属性加 ? 标记,对应工坊里「把图纸上每个零件的必装要求划掉」。B 是 Readonly 的职责,C 是 Required 的行为,D 是完全删除属性,都不对。
Q3. Pick<Cat, 'name' | 'age'> 返回的类型包含哪些属性?
- A.仅 name 和 age 这两个属性在类型中← 正确答案
- B.除 name 和 age 外的全部其余属性
- C.name age 变为可选并保持值类型
- D.name age 变为只读并删除其他键
答案:A。Pick 只保留指定键,其余全部裁剪,像用刻刀从图纸上挖出指定零件。B 是 Omit 的行为,C 多加了「可选」,D 多加了「只读」,都不符合 Pick 的语义。
Q4. infer R 在条件类型中的主要作用是什么?
- A.从类型结构中推断提取出某一部分类型← 正确答案
- B.在运行时测量变量的具体类型是什么
- C.将任意字符串类型强制转换为数字类型
- D.声明一个用于存放推导结果的类型变量
答案:A。infer 在条件类型匹配时「捕获」子结构,供后续分支使用,比如从函数类型里取返回值。B 是运行时概念,C 是类型转换,与 infer 无关,D 描述的是普通泛型变量——infer 不需要预先声明,由编译器自动填充。
Q5. 以下哪些属于 TypeScript 内置的工具类型?
- A.Partial Pick Omit Record 四个工具← 正确答案
- B.Map Filter Reduce Slice 四个数组方法
- C.Awaited Uppercase Lowercase 三个工具← 正确答案
- D.Push Pop Shift Unshift 四个数组方法
答案:AC。A 是对象属性工具全家桶,C 中 Awaited 负责递归解包 Promise、Uppercase/Lowercase 负责字符串大小写,都属于内置工具类型。B、D 是 JavaScript 数组方法的名字,不是类型层工具。
Q6. 模板字面量类型 ` user_${string} ` 可以匹配以下哪个字符串?
- A.字符串 user_alice 这样带 user 前缀串← 正确答案
- B.字符串 alice_user 这样带 user 后缀串
- C.纯字符串 user 本身不带任何下划线
- D.任何包含 user 三个字母的随意字符串
答案:A。模板要求以 user_ 开头、后面跟任意字符串,像铭牌规则「前四位固定」。B 的前缀是 alice,C 缺下划线,D 太宽泛——只要不是以 user_ 开头就不匹配。
Q7. 关于条件类型的分布式行为(裸类型参数),正确的说法有哪些?
- A.联合类型 T 每个成员会分别参与条件判断← 正确答案
- B.判断完的结果再组成新的联合类型返回← 正确答案
- C.分布式只对裸类型参数生效而非整体包裹← 正确答案
- D.条件类型在任何场景下都不会进行分配计算
答案:ABC。当 T 是裸泛型参数且为联合类型时,条件判断会「拆快递」——每个成员分别判断再合并结果,A、B 正确。C 也是对的:一旦 T 被 [] 包裹或提前计算,分发就不发生。D 明显说反了。
Q8. Partial<T> 与自定义 DeepPartial<T> 的核心差异是什么?
- A.后者会让嵌套子对象的所有层级属性变可选← 正确答案
- B.前者会让嵌套子对象的所有层级属性变可选
- C.后者会在运行时递归修改对象的属性值内容
- D.前者依赖映射类型后者依赖运行时遍历对象
答案:A。Partial 浅层可选,DeepPartial 通过递归让每一层嵌套对象的属性都变可选,像把套娃每一层都拆松。B 说反了,C 错在「运行时」,D 后半句也错——两者都是编译期类型操作。
Q9. 要实现一个将路由字符串 /user/:id 解析为参数对象类型的工具,需要哪些能力?
- A.模板字面量类型配合 infer 拆分字符串片段← 正确答案
- B.递归地解析多个动态段形成联合类型结果← 正确答案
- C.在运行时读取 window.location 获得路由字符串
- D.利用映射类型把参数名映射为 string 值类型← 正确答案
答案:ABD。A 是拆段手段,B 处理多个动态段,D 负责产出最终的对象类型。C 是运行时行为,而工具类型是在编译期推导,既不需要也不应该读取 window.location。
Q10. type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T; 的推理结果是什么?
- A.递归解开多层 Promise 最终得到最内层值类型← 正确答案
- B.只解开一层 Promise 就停止返回第二层类型
- C.将所有 Promise 类型直接替换为 unknown 类型
- D.把非 Promise 类型强制包装成 Promise 类型
答案:A。条件匹配成功时会对 U 递归调用 Awaited,直到 U 不再是 Promise,所以多层 Promise<Promise<number>> 最终得到 number,像套娃拆到最里层。B 说停在第一层,C 和 D 都与定义相反。