想象一座深夜仍在运转的智能快递分拣中心:传送带把形状各异的包裹送过来,一台装着蓝色扫描灯的分拣机立在中央。包裹经过时,扫描灯“叮”地一照——能塞进标准箱 U 的,被推上 X 通道;塞不进去的,滑向 Y 通道。同一批货里混着装在一起的单件,机器会拆开逐个检查;有的包裹里还藏着一本说明书,机器有专门的窗口能把它抽出来看一眼;货架区的工人则按月批量给所有货架贴锁、撕标签、换铭牌。这套分拣中心的一切操作,恰好就是 TypeScript 条件类型与映射类型的日常:T extends U ? X : Y 的问答、联合类型的逐一判型、infer 的拆包取值、[K in keyof T] 的批量改造。本文就用这座分拣中心作向导,带你从零看懂类型体操的每一台机器。

岗位关联

对 AI 从业者而言,类型体操是给大模型应用套上“编译期保险丝”的必修课:让 Agent 工具调用的入参、LLM 输出的 Schema、AI 前端的状态流转在运行之前就被类型系统勘误,大幅减少线上事故。

目录
  1. 可视化演示
  2. 条件类型基础:分拣机的第一问
  3. 分布式条件:一箱混装的独立判型
  4. infer 关键字:拆包裹取说明书
  5. 映射类型:批量改造货架车间
  6. 修饰符增减:贴锁、撕签与重贴铭牌
  7. 内置工具类型:标准化改造机器
  8. 实战类型体操:组合流水线
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示TypeScript条件类型的分支推断与映射类型的属性变换流程,揭示类型系统如何实现条件判断与批量属性转换。

本节配有交互动效(进入视口后自动加载,约 3KB)。

条件类型与映射类型 条件类型:T extends U ? X : Y 分支推断 映射类型:{[K in keyof T]: V} 属性变换 分布式条件类型:联合类型逐成员推断 Conditional = T extends U ? TrueType : FalseType TypeScript高级类型系统的条件判断与映射变换

条件类型基础:分拣机的第一问

分拣中心的传送带把包裹送到蓝色扫描灯下,机器只问一句话:“这个包裹能塞进标准箱 U 吗?”能,推入 X 通道;不能,滑向 Y 通道。TypeScript 的条件类型就是这台机器——它把“能不能塞进”翻译成“T 是否结构兼容于 U”,一句话问完,两个通道二选一。

条件类型的完整写法是 T extends U ? X : Y。注意这里的 extends 不是类的继承,而是一台“结构兼容扫描仪”:只要 T 拥有 U 要求的全部形状,哪怕 T 还多出几个属性,也算通过。就好比分拣机的标准箱只关心长宽高,包裹里多塞一块泡沫都不影响它能被装进去。这种“问规格”发生在编译期,运行时根本不存在这台扫描灯。

条件类型可以出现在任何类型能出现的地方,比如 type IsString<T> = T extends string ? true : false; 用起来就像给类型世界写 if/else。它常与泛型配合,让类型根据输入的不同自动选择出口,这正是类型体操里最基础的“起手式”。

回到分拣中心:扫描灯“叮”的一声之后,包裹已经消失在某一侧的传送带上。条件类型的妙处也在于此——它不问过程,只问结果,一锤定音地根据输入决定输出,类型自此有了“判断力”。

T extends U  ?  X:YT \ \text{extends} \ U \ \ ? \ \ X : Y
读作:“如果 T 能赋给 U,就取 X,否则取 Y。”它是类型世界的三元运算。
  1. T待检包裹:T 是我们传入的原始类型,可能是泛型参数、联合类型或某个对象属性的类型。
    TT
  2. extends U扫描规格:把 T 与 U 做结构兼容性检查,能塞进 U 的规格箱就算通过扫描。
    T extends UT \ \text{extends} \ U
  3. ? XY:双通道分流:扫描通过走 X 通道得到 X,不通过则滑向 Y 通道得到 Y。
    T extends U ? X:YT \ \text{extends} \ U \ ?\ X : Y

分布式条件:一箱混装的独立判型

分拣中心偶尔会接到一箱混装的包裹:箱子里同时叠着字符串包裹、数字包裹和布尔包裹。蓝灯扫描仪的规矩是:不判断整箱,而是把箱子拆开,让每个单件独立过一遍扫描灯,最后再把各自的结果重新拼成一箱。这个规矩叫“分布式条件类型”。

对应的语法事实是:当 T 是一个裸的泛型参数,且调用时传入的是联合类型 string | number | boolean 时,T extends U ? X : Y 会自动对每个成员分别求值。比如 T extends string ? 1 : 2,传入 string | number 时,结果是 1 | 2,而不是整个联合被一票否决议成 2。

内置工具类型 Exclude 就是典型的分布式机器:Exclude<T, U> 把 T 中所有能赋给 U 的成员换成 never(相当于在传送带上直接扔掉),剩下的成员自动合并返回。若想关掉“拆箱”,把 T 用方括号包起来写成 [T] extends [U],就像给整箱包裹加一道封条,扫描仪就只能整箱判断了。

这就像混装箱被拆开、逐件扫描、再重新装箱:每个单件都保有独立判断的权利,谁也不会因为“同箱的兄弟不合格”而被连坐。分布式条件类型让联合类型的处理精确到“每一个成员”,是类型体操里最常用的机关之一。

Exclude(T,U)=T extends U ? never:T\text{Exclude}(T, U) = T \ \text{extends} \ U \ ?\ \text{never} : T
Exclude 是“剔除机器”:从 T 里删掉所有 U 能接受的成员,never 会被自动丢弃,残余成员重组成新的联合类型。
  1. Exclude(T,U)剔除机器:内置的条件类型工具,作用是从 T 中筛掉能赋给 U 的成员。
    Exclude(T,U)\text{Exclude}(T, U)
  2. T 自动拆包T 是裸类型参数时,联合类型的每个成员会被拆成单件逐一处理。
    T=T1∣T2∣⋯∣TnT = T_1 \mid T_2 \mid \cdots \mid T_n
  3. 逐成员判定每个成员独立走一遍 extends 扫描,命中的被替换成 never。
    Ti extends U ? never:TiT_i \ \text{extends} \ U \ ?\ \text{never} : T_i
  4. 结果合并never 被系统自动过滤,存活的成员重新组成联合类型。
    结果=⋃{ Ti:Ti⊈U }\text{结果} = \bigcup\{\, T_i : T_i \nsubseteq U \,\}

infer 关键字:拆包裹取说明书

有些包裹里夹着一本说明书,写着“本包裹实际装载的是 X 类型”。分拣中心在扫描灯旁边开了一个检查窗口:窗口上写着“infer R“,意思是”让我看看里面到底装了什么,并把答案记到 R 上”。这个窗口就是 infer 关键字。

infer 只能出现在条件类型的 extends 匹配模式里。最经典的应用是提取函数返回类型:ReturnType<F> 会先检查 F 是否匹配“一个函数”的形态,匹配时在返回位置放一个 infer R 占位符,让 TypeScript 把真实的返回类型填进 R。就像一个窗口把函数签名“摊开”给你看:参数那一段用 ...args: any[] 模糊处理,返回值那一段由 infer 精确抄录。

换个例子:type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never; 可以从元组里提取第一个元素的类型。infer 的位置决定它能提取什么——放在返回类型处取返回值,放在元组头部取首元素,放在 Promise 内部就取被包裹的内层类型。它的作用域仅限于当前条件分支,出了这个分支,R 就作废了。

检查窗口的存在,让“拆包裹”变得合法且有边界:infer 不是魔法,它必须依附于一个具体的模式匹配。就像分拣员只能在指定窗口拆包,换一个位置(比如直接写 type X = infer R)就不被允许。学会在正确的位置安放 infer,类型体操就入门了一半。

ReturnType(F)=F extends (…args:any[])⇒infer R ? R:any\text{ReturnType}(F) = F \ \text{extends} \ ({\dots}\text{args}: \text{any}[]) \Rightarrow \text{infer}\ R \ ?\ R : \text{any}
读作:“如果 F 是一个函数,就把它返回的类型记作 R 并返回 R;否则退化为 any。”
  1. F函数类型本体:F 必须能匹配函数签名形态,比如 (x: number) => string。
    F=(x:number)⇒stringF = (x: \text{number}) \Rightarrow \text{string}
  2. 参数占位(...args: any[]) 张开一张大网,把函数签名中的参数部分整体承接。
    F extends (…args:any[])F \ \text{extends} \ ({\dots}\text{args}: \text{any}[])
  3. infer R 提取返回值在返回位置放一个占位符,让 TS 把真实的返回类型填入 R。
    ⇒infer R\Rightarrow \text{infer}\ R
  4. 兜底 any若 F 不是函数,匹配失败,走假分支得到 any。
    ? R:any? \ R : \text{any}

映射类型:批量改造货架车间

分拣中心的深处是货架车间。主管发话:“这排货架,每个抽屉都贴一张'可选'标签;那排货架,每个抽屉都加一把锁。”工人不需要逐个抽屉手工处理,而是拿着钥匙串 keyof T,用 in 循环从第一把钥匙走到最后一把,全程机械化操作。这套批量改造流程叫映射类型。

映射类型的写法是 { [K in keyof T]: T[K] }。keyof T 把对象的所有键收集成联合类型,好比把整排货架的钥匙串到一起;K in 像 for 循环一样逐个取出键名;T[K] 则用键去索引原来的类型,取出对应抽屉里的值类型。三者配合,就能“照原样复制”一个对象类型——或者在此基础上做任意改造。

说它是“同态变换”是因为输出类型与输入类型共享同一套键:键没有增减,只是每个键对应的值类型被统一处理。比如 { [K in keyof T]: T[K][] } 会让每个属性都变成数组。货架还是那排货架,抽屉里的东西全被换成了“数组包装”。映射类型管的是“形状”,不改“骨架”。

货架车间的哲学是“批量化、机械化”:只要给出钥匙串和处理规则,整排货架一次改造完成。映射类型因此成为所有内置工具类型的底盘——下一节的修饰符增减,就是在改造指令上多写了几个符号而已。

{[K in keyof T]:T[K]}\{ [K \ \text{in}\ \text{keyof}\ T] : T[K] \}
读作:“遍历 T 的所有键 K,逐个产出一个新属性,键名不变,值类型由 T[K] 决定。”
  1. keyof T取钥匙串:把 T 的所有键收集成联合类型,比如 keyof { a: number; b: string } 得到 "a" | "b"。
    keyof {a:number; b:string}="a"∣"b"\text{keyof}\ \{ a: \text{number};\, b: \text{string} \} = \text{"a"} \mid \text{"b"}
  2. K in遍历钥匙:in 像循环逐个取出键名,K 依次取 "a"、再取 "b"。
    [K in keyof T][K \ \text{in}\ \text{keyof}\ T]
  3. T[K]打开对应抽屉:用键 K 索引原类型 T,取该键对应的值类型。
    T[K]T[K]

修饰符增减:贴锁、撕签与重贴铭牌

车间主管的指令不只是“改造值类型”,还能动属性本身的“属性”:加一把锁,叫 readonly;贴一张“可选”标签,叫 ?;反过来,撕掉标签就用减号。TypeScript 给映射类型准备了专门的修饰符语法:+ 表示添加,- 表示移除。

最常见的操作有两个。一是加锁:{ readonly [K in keyof T]: T[K] } 让每个属性都变成只读,编译期禁止再赋值;二是加减可选标记:{ [K in keyof T]-?: T[K] } 把所有可选属性变成必选,这就是内置工具 Required<T> 的实现原理。+? 则反过来把每个属性都变成可选,也就是 Partial<T> 的实现。

更高级的玩法是 as 键重映射:{ [K in keyof T as \get${Capitalize<K>}\]: T[K] } 可以把键名改造成 getXxx 的形式。好比给货架换一套新铭牌,键还是那些键,但名字全变了。修饰符加减配合 as 重映射,让映射类型从“复制”进化成“再创作”。

除了解除可选,还有一套对应的“重贴铭牌”公式:

修饰符就像是货架上的标签系统:readonly 是“请勿擅动”的警示牌,? 是“可缺省”的便利贴,减号是撕下旧标签的手。掌握这些符号的增删,就能像车间主管一样精准控制每个属性的“脾气”。

Required(T)={[K in keyof T]−?:T[K]}\text{Required}(T) = \{ [K \ \text{in}\ \text{keyof}\ T]-? : T[K] \}
读作:“遍历 T 的每个键 K,用 Uppercase 把键名大写后作为新键名。”
  1. as 重贴标签as 允许在遍历过程中改写键名,生成一个全新的键集。
    [K in keyof T as Uppercase⟨K⟩][K \ \text{in}\ \text{keyof}\ T \ \text{as}\ \text{Uppercase}\langle K \rangle]
  2. Uppercase 内置模板把每个键名转成大写,像给货架换上新铭牌。
    Uppercase⟨"id"⟩="ID"\text{Uppercase}\langle \text{"id"} \rangle = \text{"ID"}

内置工具类型:标准化改造机器

分拣中心备有一套标准化机器,每台机器背后都是一段我们刚刚学过的映射或条件表达式。Partial<T> 全员可选,Required<T> 全员必选,Readonly<T> 全员加锁——三台机器共用同一个遍历底盘,只是修饰符不同。Record<K, V> 则不一样:它不改造已有对象,而是用 K 这串钥匙凭空造出一排新货架,每个抽屉统一放 V 类型的货物。

Pick<T, K> 是“挑拣机”:从 T 中只保留键集 K 里的属性;Omit<T, K> 反过来,剔除 K 里的属性。Exclude<T, U> 和 Extract<T, U> 是条件类型家族的成员,前者剔除、后者保留,都依赖分布式展开。至于 ReturnType 和 Parameters,则是 infer 家族的代表,专攻函数签名的拆解。

把这些机器排在一起会发现:所谓内置工具类型,不过是映射类型与条件类型的“官方预置模板”。理解它们的实现原理,比记忆 API 更重要——因为一旦看穿底盘,你就能自己组装出任何定制机器。

这些标准化机器解决了 80% 的日常类型需求:表单提交用 Partial,配置补丁用 Partial,键值表用 Record,白名单字段用 Pick。车间里备好了通用机器,剩下的 20% 才轮到我们亲手造新机器——那就是实战体操的舞台。

Partial(T)={[K in keyof T]?:T[K]}Record(K,V)={[P in K]:V}\text{Partial}(T) = \{ [K \ \text{in}\ \text{keyof}\ T]? : T[K] \} \qquad \text{Record}(K, V) = \{ [P \ \text{in}\ K] : V \}
左边:逐个键加问号,得到全员可选的新类型;右边:用键集合 K 生成新对象,每个键的值统一为 V。
  1. Partial(T)全员可选:遍历 T 的键,每个键都加上可选标记。
    {[K in keyof T]?:T[K]}\{ [K \ \text{in}\ \text{keyof}\ T]? : T[K] \}
  2. Record(K,V)批量制造:K 是键的联合,P 逐个取键,V 作为统一的值类型。
    {[P in K]:V}\{ [P \ \text{in}\ K] : V \}
  3. 属性访问保持两种机器都不改动原有属性对应的值类型,只改“键的存在方式”。
    T[K],VT[K],\quad V

实战类型体操:组合流水线

真正的“体操”发生在机器不够用的时候。假设我们需要 DeepPartial<T>:不仅顶层属性可选,嵌套对象里的每一层属性也都可选。普通的 Partial 只改造一层就停工了,好比车间主管只给货架第一排贴了标签,里层的抽屉一概不管。

解法是把映射类型和条件类型组合成递归流水线:遍历每个键,加可选标记;然后问一句“这个值类型是复合包裹吗?是,就把它送进流水线再改造一层;不是(是 string、number 这类原子类型),就直接放行”。递归的出口是原子类型,保证了流水线不会无限循环。

这类组合在真实项目中俯拾皆是:从函数签名里抠出参数类型、从 Promise<T> 里解包出 T、把数据库实体映射成 API 响应 DTO、按权限裁剪用户字段……每一样都是“映射 + 条件 + infer”三种机器的组合拳。体操的名字由此而来:动作不新,组合见功夫。

回到分拣中心看这条终极流水线:传送带每过一个包裹就贴一张“可选”标签,若发现里面还是箱子,就拆开再走一遍同样的流程;拆到底,看到牙刷、文件这些没法再拆的东西,就放它离开。体操的精髓不在某台机器多神奇,而在于你能否把机器按正确的顺序接起来,让类型像流水线一样自动流转。

DeepPartial(T)={[K in keyof T]?:T[K] extends object ? DeepPartial(T[K]):T[K]}\text{DeepPartial}(T) = \{ [K \ \text{in}\ \text{keyof}\ T]? : T[K] \ \text{extends}\ \text{object} \ ?\ \text{DeepPartial}(T[K]) : T[K] \}
读作:“每个键都变可选;若原值类型是对象,递归地再 DeepPartial 一次,否则保持原样。”
  1. 逐键加问号先给每个键加上可选标记,允许外层缺省。
    [K in keyof T]?[K \ \text{in}\ \text{keyof}\ T]?
  2. 判断是否嵌套用 extends object 检测该值类型是不是一个复合包裹。
    T[K] extends objectT[K] \ \text{extends}\ \text{object}
  3. 是则递归遇到嵌套对象就递归调用自身,把改造贯彻到底层。
    DeepPartial(T[K])\text{DeepPartial}(T[K])
  4. 否则保留原子基础类型原样返回,不再深挖。
    T[K]T[K]

AI 应用场景

LLM 对话消息的判别联合类型

阶段:前端应用层 / 类型安全工程

解决的问题:AI 聊天应用中,消息可能是用户消息、助手消息、工具调用消息,每种消息的载荷结构完全不同。传统做法用一个大而全的接口,运行时再靠 switch 猜结构,容易漏判导致白屏。利用条件类型和判别联合,kind 字段驱动类型收窄,payload 类型在编译期就被精确锁定。

具体案例:定义 type ChatMessage = { kind: 'user', content: string } | { kind: 'tool_call', name: string, args: Record<string, unknown> },渲染组件用条件类型根据 kind 精确推导渲染分支,Agent 每新增一种消息类型,编译器立刻标出所有遗漏的分支处理。

Agent 工具调用的参数 Schema 提取

阶段:Agent 编排层 / Function Calling

解决的问题:让大模型调用工具时,入参必须和工具函数签名完全一致。手写一遍入参类型再写一遍函数,两边极易漂移。用 infer 从工具函数签名直接提取入参类型,保证“一份签名,处处复用”,模型生成的 JSON 参数在编译期就接受类型校验。

具体案例:写一个 defineTool<F extends (args: any) => any>(fn: F) 工具,内部用 Parameters<F>[0](基于 infer 实现)取出第一个参数类型作为 inputSchema 的 TS 类型,前端表单根据该类型自动生成输入控件,同时把 schema 喂给 LLM 做工具描述。

训练平台配置补丁的 DTO 映射

阶段:MLOps / 数据管道工程化

解决的问题:AI 训练平台的配置项有几十个字段,PATCH 接口只允许提交部分字段,传统做法复制整份配置再手动删字段。用 Partial<Config> 生成补丁类型,用 Record<MetricName, number> 生成指标表类型,映射类型让 DTO 与内部模型保持同构。

具体案例:训练配置 TrainConfig 有 learningRate、batchSize、epochs 等字段,type Patch = Partial<TrainConfig> 直接作为更新接口的入参类型;平台用映射类型从指标名联合自动生成 Record<string, number> 的监控上报类型,新增指标只需改一处枚举。

Python 演示

python57 行
"""模拟 TS 条件类型与映射类型的小判型器:- conditional_type 对应 T extends U ? X : Y- distributive_conditional 模拟联合类型逐成员展开- mapped_type 对应映射类型 { [K in keyof T]: F(T[K]) }- PromiseBox 演示 infer 解包内部类型"""from typing import Any, Callable, List, TypeVar T = TypeVar("T")U = TypeVar("U")  def conditional_type(t: type, u: type, x: str, y: str) -> str:    """若 t 可赋给 u 则走 X 通道, 否则走 Y 通道"""    try:        return x if issubclass(t, u) else y    except TypeError:        return y  def distributive_conditional(items: List[type], u: type, x: str, y: str) -> List[str]:    """把类型列表当成联合类型, 对每个成员独立判型"""    return [conditional_type(it, u, x, y) for it in items]  def mapped_type(data: dict, transform: Callable[[Any], Any]) -> dict:    """逐键批量施加变换, 生成新对象类型"""    return {key: transform(val) for key, val in data.items()}  class PromiseBox:    """模拟 Promise<T>, inner 是被藏起来的内部类型"""     def __init__(self, inner: type) -> None:        self.inner = inner  def infer_inner(box: PromiseBox):    """扮演 infer R: 从包装中拆出内部类型"""    return box.inner if isinstance(box, PromiseBox) else None  def main() -> None:    results = distributive_conditional([int, str, float], int, "X通道", "Y通道")    print("分布式判定:", results)     raw = {"id": "42", "age": 18}    locked = mapped_type(raw, lambda v: "[readonly]" + str(v))    print("映射加锁:", locked)     box = PromiseBox(str)    print("infer 解包:", infer_inner(box))  if __name__ == "__main__":    main()
目的:用 Python 的 issubclass、列表推导和字典推导,直观复刻 TS 条件类型、分布式展开、映射类型和 infer 解包的判型逻辑。
  1. def conditional_type(t: type, u: type, x: str, y: str) -> str:定义判型函数,四个参数正好对应 T extends U ? X : Y 的四个角色。
  2. return x if issubclass(t, u) else y核心判型逻辑:issubclass 扮演 extends 扫描仪,通过走 X,不通过走 Y。
  3. def distributive_conditional(items: List[type], u: type, x: str, y: str) -> List[str]:接收一个类型列表,模拟“联合类型拆成逐个成员”。
  4. return [conditional_type(it, u, x, y) for it in items]列表推导对每个成员独立判型,就是分布式条件类型的逐成员展开。
  5. def mapped_type(data: dict, transform: Callable[[Any], Any]) -> dict:映射类型的通用形态:对每个键值批量施加变换。
  6. return {key: transform(val) for key, val in data.items()}字典推导模拟 { [K in keyof T]: F(T[K]) } 的逐键遍历。
  7. def infer_inner(box: PromiseBox):模拟 infer R:把包装里的内部类型重新拆出来。
  8. results = distributive_conditional([int, str, float], int, "X通道", "Y通道")一次典型的分布式判型调用:三个类型各走各的通道。

常见误区

误区“我以为 `T extends U` 就像 JS 的 `===`,运行时拿两个值比一比,条件类型是程序跑起来之后才执行的分支。”

正解条件类型的一切都发生在编译期。extends 是“结构可赋值性”检查,像分拣机只看包裹外包装的规格标签,不打开包裹,更不等着包裹寄出才判断。编译完成后,这些类型分支全部消失,运行时里没有任何分拣机,也没有任何扫描动作。

为何常见:因为 JS 里有三元运算符、有 typeof,初学者很容易把类型层面的语法和运行时的语法画等号,忽略了 TypeScript 类型是“编译期虚拟世界”这一事实。

误区“`T extends U ? X : Y` 里 T 如果是 `string | number`,我以为会把整个联合当成一个整体去和 U 比,结果要么整体通过、要么整体拒绝。”

正解裸类型参数会被分布式展开:机器先把联合里的每个成员拆开,单独过一遍 extends,再把各自的结果合并成新联合。就像一箱混装的快递会被拆成单件逐一扫描,而不是整箱一次过检。想整体判断,可以用 [T] extends [U] 把包裹封进箱子,阻止拆件。

为何常见:日常 if 语句总是把一个值整体判断,人脑默认“类型条件也是整体判断”,于是漏掉了裸类型参数触发分布式展开这个特殊规则。

误区“我以为 `infer R` 像正则表达式里的捕获组,想在哪里挖类型就在哪里写占位符,甚至可以在映射类型之外直接声明来用。”

正解infer 只能出现在条件类型的 extends 匹配模式内部,且 R 的作用域只覆盖当前分支。它像分拣中心里唯一的那扇检查窗口——包裹必须从窗口经过,里面的东西才被看见;脱离窗口直接拆包是不被允许的。位置错了,编译器立刻报错。

为何常见:正则捕获组深入人心,而 infer 长得确实像捕获组,导致它的“位置必须依附于匹配模式”这一约束被习惯性忽略。

误区“我以为映射类型里加了 `readonly` 之后,对象在运行时就像 `Object.freeze` 一样完全不能改写,改了就抛异常。”

正解readonly 是编译期的锁,只在类型检查时禁止重新赋值;运行时该对象仍然是普通 JS 对象,照样能改。它像贴在货架上的“请勿擅动”标签,约束的是开发者,不是货架本身。真正想运行时冻结,得用 Object.freeze,这是两套完全不同的机制。

为何常见:Object.freeze 返回的类型也带 readonly 标记,两者名字相似、表现相关,初学者很容易把“类型层表示”当成“运行时行为”。

面试题

Q1. 条件类型 T extends U ? X : Y 中,extends 判断的本质是什么?

单选题 · easy

  • A.判断 T 运行时与 U 完全相等
  • B.判断 T 能否赋给 U(结构兼容)← 正确答案
  • C.判断 T 的键数量是否超过 U
  • D.判断 T 是否是 U 的子类实例

答案:B。extends 做的是结构可赋值性检查,只要 T 具备 U 要求的全部形状就算通过,不要求相等,也不存在运行时比较。就像分拣机只看包裹能否塞进规格箱,不看品牌也不称重量。A 错在“运行时”,C 错在计数,D 错在把类型兼容理解成了类继承。

Q2. 下面哪个类型作为裸类型参数时会被分布式展开?

单选题 · easy

  • A.联合 string | number← 正确答案
  • B.对象 { a: string }
  • C.元组 [string, number]
  • D.字面量 "hello" 类型

答案:A。只有联合类型会被分布式条件类型拆成单件逐一判定。对象、元组、字面量都是单一类型,不会触发拆箱。就像分拣中心只拆混装箱,单件包裹直接原样过检,不额外拆解。

Q3. Exclude<"a" | "b" | "c", "a"> 的结果是什么?

单选题 · mid

  • A.得到联合 "b" | "c"← 正确答案
  • B.得到原联合 "a" | "b" | "c"
  • C.得到 never 空类型
  • D.得到类型 "a" 单例

答案:A。Exclude<T, U> 对每个成员做 T extends U ? never : T。三个成员中只有 "a" 命中 U,被替换成 never 后自动丢弃,剩下的 "b" | "c" 重新合并返回。整个过程就是“拆箱-逐件扫描-丢掉命中件-重装”。

Q4. 关于 infer R 的说法,正确的是哪一项?

单选题 · mid

  • A.R 与 extends 左侧类型完全无关
  • B.R 只能出现在条件类型的假分支
  • C.R 从被匹配位置提取真实类型← 正确答案
  • D.R 会在运行时被实例化为对象

答案:C。infer R 在 extends 的匹配模式中声明一个类型变量,TS 会根据被匹配位置的实际结构把真实类型填进 R。它只存在于编译期,且只能在真分支使用。就像检查窗口抄录包裹内说明书的内容,位置决定内容,运行时毫无踪迹。

Q5. keyof { a: number; b: string } 的结果是什么?

单选题 · easy

  • A.number | string(值类型联合)
  • B."a" | "b"(键名字面量联合)← 正确答案
  • C.{ a; b } 的整体对象类型
  • D.number 与 string 的交集类型

答案:B。keyof 取的是对象键名的联合类型,而不是值类型的联合。"a" | "b" 就是那串“货架钥匙”,后续映射类型用 in 遍历的正是这把钥匙串。A 是值的类型,C 是原对象整体,D 完全是另外的概念。

Q6. 关于分布式条件类型,下列说法正确的有?

多选题 · mid

  • A.仅对裸类型参数的联合逐个展开← 正确答案
  • B.结果会重新合并成联合类型← 正确答案
  • C.用 [T] 包裹可关闭分布行为← 正确答案
  • D.对象属性也会被逐个展开

答案:ABC。分布式展开只发生在“裸类型参数 + 联合类型”组合下,每个成员独立判型后结果重新合并;用 [T] extends [U] 把参数包进元组,就相当于给混装箱加封条,关闭拆件。对象属性属于映射类型的管辖范围,不会触发条件类型的分布式展开。

Q7. ReturnType<(x: number, y: string) => boolean> 的结果是什么?

单选题 · hard

  • A.number | string 的联合
  • B.参数类型组成的元组
  • C.boolean 返回类型← 正确答案
  • D.unknown 兜底类型

答案:C。ReturnType 内部用 F extends (...args: any[]) => infer R ? R : any,infer 放在返回位置,因此提取到 boolean。A 和 B 分别是参数相关的类型,D 是匹配失败时的兜底值,这里 F 能匹配函数形态,不会走兜底分支。

Q8. 映射类型 { [K in keyof T]: T[K] } 的变体中,可以做到哪些事?

多选题 · hard

  • A.给属性加上 readonly 锁← 正确答案
  • B.去掉属性的可选问号← 正确答案
  • C.用 as 重映射键名← 正确答案
  • D.在运行时遍历对象数据

答案:ABC。映射类型支持修饰符增减(readonly、? 的加减)和 as 键重映射,这些都是编译期类型变换。D 错在“运行时”——映射类型根本不产生运行时代码,跟遍历对象数据没有任何关系。

Q9. 防止分布式条件类型展开的正确写法是什么?

单选题 · mid

  • A.用 [T] extends [U] 包一层← 正确答案
  • B.把 T 转成字符串再比较
  • C.使用 any 替代联合类型
  • D.在 extends 前加 typeof 运算

答案:A。把裸类型参数 T 放进元组 [T],就不再是“裸”参数,[T] extends [U] 会把整个联合当成一个整体判断,从而关闭分布式展开。B、C、D 都改变了语义或引入了运行时概念,与条件类型的编译期机制相悖。

Q10. 实现 DeepPartial<T> 递归映射,下列说法正确的有?

多选题 · hard

  • A.每个属性都要置为可选标记← 正确答案
  • B.嵌套对象要递归调用自身← 正确答案
  • C.原子类型应原样保留不递归← 正确答案
  • D.仅顶层生效不处理深层嵌套

答案:ABC。DeepPartial 的核心是“逐键加 ? + extends object 判断 + 是对象就递归、否则原样保留”。它必须贯穿嵌套结构的每一层。D 描述的只改造一层的效果是普通 Partial 的行为,不是 DeepPartial。