☰
TS 类型体操 Medium 17~24 题完整拆解:从数字绝对值到联合全排列的 8 题递归推导指南
2026/10/5 1:51:03 网站建设 项目流程

TS 类型体操 Medium 17~24 题完整拆解:从数字绝对值到联合全排列的 8 题递归推导指南

【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址: https://gitcode.com/GitHub_Trending/we/weekly

本文精读 type-challenges 题库 Medium 难度第 17~24 题共 8 道:绝对值、对象追加属性、字符串转联合、字符串长度、对象合并、驼峰转横线、数组扁平化与联合类型全排列。按技术难度从低到高,逐题走通「题目 → 第一反应 → 破局关键 → 逐步展开 → 验证复盘」五步,带你吃透「带默认值的累加泛型 + 换域匹配 + 元组包裹 never」这套递归推导套路,并汇总多写法取舍表与高频陷阱。

本章题目地图

先看全局:下面这 8 道题按技术难度递增排列(而非题号顺序),难度越高越靠前需要垫底的推导能力。

题目核心技术点难度
Absolute 数字绝对值模板字面量换域 +infer抠前缀★
AppendToObject 对象追加属性映射类型 / 交叉类型★★
StringToUnion 字符串转联合字符串拆分infer+ 联合累积★★★
LengthOfString 字符串长度累加泛型 +[length]取值★★★
Merge 对象合并keyof并集遍历 + 条件索引★★★★
KebabCase 驼峰转横线内置Lowercase判断 + 前缀剥离助手★★★★
Flatten 数组扁平化元组解构infer+ 递归自我复用★★★★★
Permutation 联合全排列分配律 + 元组包裹never★★★★★

逐题精读:按技术难度递增

说明:新语法点「用到时再讲」,首次出现处都配了代码与解释,不集中在开头。

题 1|Absolute 数字绝对值:先换到字符串域再匹配符号

【题目】

type Test = -100; type Result = Absolute<Test>; // 期望 "100"

【第一反应】

想直接用T extends ...判断正负。但number类型本身不携带「负号字符」这个信息——-100在类型层面只是个数字,你没法对它的符号做字符串匹配。卡点:数字域里没有「符号」这个属性可抓。

【破局关键】🔑

模板字面量类型能把类型「翻译」到字符串域:${T}会把数字字面量变成对应字符串。一旦落到字符串域,infer前缀匹配`-${infer R}`就能抠出负号后面的部分。这是「先换域、再匹配」的思路。

【逐步展开】

// T = -100 `${T}` // 先转字符串域 → "-100" "-100" extends `-${infer R}` // 命中负号前缀,抠出 R = "100"

【验证与复盘】

// 最终答案:先转字符串域,再匹配开头的负号 type Absolute<T extends number> = `${T}` extends `-${infer R}` ? R : `${T}`

验证:Absolute<-100>得"100",Absolute<5>走else分支得"5",两个用例都命中预期即可。✅

题 2|AppendToObject 对象追加属性:交叉类型 vs 映射类型

【题目】

type Base = { id: '1' } type Result = AppendToObject<Base, 'value', 4> // 期望 { id: '1', value: 4 }

【第一反应】

想把新属性「拼上去」。TS 没有运行时的对象展开,只能靠映射类型重新描述整个对象。卡点:如何在不丢原属性的前提下,把一对Key/Value塞进去。

【破局关键】🔑

两条路。其一是交叉类型&,把「原对象」与「新增键值」直接交叉,TS 自动合成;其二是纯映射类型,用keyof T | U把新旧 Key 并成一个遍历范围,再对每个 Key 做条件判断,决定它取哪个值。

【逐步展开】

// 思路一:交叉 —— Base & { value: 4 } 被 TS 自动合成 { id: '1', value: 4 } // 思路二:映射 + keyof 并集,keyof Base | 'value' 展开为 'id' | 'value' // key = 'id' → 不在 U('value') 里 → 回退取 Base['id'] = '1' // key = 'value' → 命中 U → 取 V = 4

【验证与复盘】

// 写法一:交叉类型,最短 type AppendToObject<Obj, Key extends string, Value> = Obj & { [K in Key]: Value } // 写法二:纯映射类型,输出形状精确可控 type AppendToObject<T, U extends number | string | symbol, V> = { [K in keyof T | U]: K extends U ? V : T[Exclude<K, U>] }

验证:对{ id: '1' }追加'value', 4,两种写法都得到{ id: '1', value: 4 }。✅

题 3|StringToUnion 字符串转联合:两种收拢递归结果的方式

【题目】

type Test = '123'; type Result = StringToUnion<Test>; // 期望 "1" | "2" | "3"

【第一反应】

想把字符串「拆开」。TS 类型没有split,但可以用字符串模板拆分:T extends \${infer F}${infer R}`里,第一个infer` 抓首字符,第二个抓剩余全部字符。卡点:字符分散在递归各层里,怎么把它们重新「收拢」成一个联合类型?

【破局关键】🔑

两种收拢方式。一是联合累积泛型:声明第二个泛型P,默认值never,每层把首字符并进P | F——never在联合里是「空占位」,并入不污染结果。二是隐式拼接:不存中间态,直接F | 递归结果,靠联合类型天然把never吸收掉。累积式的套路在上一轮 Medium 9~16 题 的ReplaceAll里已初见雏形。

【逐步展开】

// 走"隐式拼接",以 '123' 为例 StringToUnion<'123'> // 拆出 F='1' → '1' | StringToUnion<'23'> // 拆出 F='2' → '1' | '2' | StringToUnion<'3'> // 拆出 F='3' → '1' | '2' | '3' | StringToUnion<''> // '' 不匹配模板 → never,被联合吸收 // 最终 '1' | '2' | '3'

【验证与复盘】

// 写法一:累积泛型,中间态显式 type StringToUnion<T, P = never> = T extends `${infer F}${infer R}` ? StringToUnion<R, P | F> : P // 写法二:隐式拼接,代码最简 type StringToUnion<T> = T extends `${infer F}${infer R}` ? F | StringToUnion<R> : never

验证:StringToUnion<'123'>得"1" | "2" | "3",展开到空串返回never且不影响结果。✅

题 4|LengthOfString 字符串长度:用累加泛型边拆边存

【题目】

type Result = LengthOfString<'abc'>; // 期望 3

【第一反应】

字符串长度?类型层没有.length,但数组有——['a','b','c']['length']返回3。思路是把'abc'变成['a','b','c']再读它的[length]。卡点:这个「边拆边存」的数组,要存在哪个类型变量里?类型递归不像函数那样有闭包变量可写。

【破局关键】🔑

带默认值的累加泛型就是类型层的「可变变量」:N extends any[] = []初始为空数组,每递归一层把首字符 push 进去[...N, F]。字符串拆完后读N['length']。这跟函数递归里「用参数传递累加器」是同构的。

【逐步展开】

LengthOfString<'abc'> // 拆出 F='a' → LengthOfString<'bc', ['a']> // 拆出 F='b' → LengthOfString<'c', ['a','b']> // 拆出 F='c' → LengthOfString<'', ['a','b','c']> // '' 不再匹配模板 → 返回 ['a','b','c']['length'] = 3

【验证与复盘】

// S:剩余待拆字符串;N:已收集的字符数组(默认空) type LengthOfString<S, N extends any[] = []> = S extends `${infer F}${infer R}` ? LengthOfString<R, [...N, F]> : N['length']

验证:LengthOfString<'abc'>得3,边界用例LengthOfString<''>得0均通过。✅

题 5|Merge 对象合并:keyof 并集遍历加双重条件收敛

【题目】

type Foo = { name: string; age: string } type Coo = { age: number; sex: string } type Result = Merge<Foo, Coo>; // 期望 { name: string, age: number, sex: string }

【第一反应】

「合并」两字让人想写运行时展开,但类型层没有对象合并运算符,只能重新描述一个对象:Key 覆盖两边所有 Key,冲突时后者优先。卡点:keyof Foo | keyof Coo展开后,TS 并不天然知道某个 Key 来自哪边,直接Foo[K]可能索引到不存在的键。

【破局关键】🔑

把keyof A | keyof B当成遍历范围,值位置用条件索引决定来源:先判K extends keyof B(后者优先),命中取B[K];否则再判K extends keyof A,命中取A[K]。两套条件保证K至少存在于一个对象中,规避非法索引。

【逐步展开】

// 遍历范围:keyof Foo | keyof Coo = 'name' | 'age' | 'sex' // K='name' → 不在 keyof Coo;在 keyof Foo → Foo['name'] = string // K='age' → 在 keyof Coo(后者优先) → Coo['age'] = number // K='sex' → 在 keyof Coo(后者优先) → Coo['sex'] = string // 最终 { name: string, age: number, sex: string }

【验证与复盘】

// K:并集里的单个键;B 优先于 A type Merge<A extends object, B extends object> = { [K in keyof A | keyof B]: K extends keyof B ? B[K] : (K extends keyof A ? A[K] : never) }

验证:三个 Key 各落正确分支,冲突的age取B的number,符合「后者优先」。✅

题 6|KebabCase 驼峰转横线:内置 Lowercase 判大小写加助手剥横线

【题目】

type Result = KebabCase<'FooBarBaz'>; // 期望 'foo-bar-baz'

【第一反应】

逐字符处理:遇大写转小写并前面补-,小写原样。卡点有两个——一是类型层怎么判断「这是大写」?二是递归拼出来的结果开头会多一个-(首个字符也是大写),得有人把它去掉。

【破局关键】🔑

判断大小写靠内置工具类型Lowercase:若Lowercase<F> extends F成立,说明转小写后没变化、原本就是小写;否则就是大写。开头多余的-交给一个前缀剥离助手:S extends \${infer Rest}`命中-` 开头就返回余下部分。

【逐步展开】

KebabCase<'FooBarBaz', ''> // F='F' 大写 → 累积 `${''}-f` → KebabCase<'ooBarBaz', '-f'> // F='o' 小写 → 累积 `${'-f'}o` → KebabCase<'oBarBaz', '-fo'> // ……逐字符推进,遇大写前插 '-' // 结束时 U = '-foo-bar-baz' // RemoveFirstHyphen<'-foo-bar-baz'> → 'foo-bar-baz'

【验证与复盘】

// S:剩余待转字符串;U:累积结果(默认空串) type KebabCase<S, U extends string = ''> = S extends `${infer F}${infer R}` ? ( Lowercase<F> extends F ? KebabCase<R, `${U}${F}`> : KebabCase<R, `${U}-${Lowercase<F>}`> ) : RemoveFirstHyphen<U> // 剥离首个横线的助手 type RemoveFirstHyphen<S> = S extends `-${infer Rest}` ? Rest : S

验证:KebabCase<'FooBarBaz'>得'foo-bar-baz',首字母大写导致的开头-被助手正确去掉。✅

题 7|Flatten 数组扁平化:递归自我复用拍到底

【题目】

type Result = Flatten<[1, 2, [3, 4], [[[5]]]]>; // 期望 [1, 2, 3, 4, 5]

【第一反应】

一层层拍平。用元组解构infer[infer Start, ...infer Rest]把首元素和剩余分开。卡点:首元素本身还可能「套了多层」(如[[[5]]]),一次...Start展不开到底,怎么保证彻底拍平?

【破局关键】🔑

让递归自我复用:首元素是数组时不要只展一层,而是直接...Flatten<Start>——让同一个类型继续把Start拍到底,再把结果铺进累加器。带默认值的累加泛型Result负责收集所有已拍平的元素。

【逐步展开】

Flatten<[1, 2, [3, 4], [[[5]]]]> // Start=1(非数组) → Flatten<[2,[3,4],[[[5]]]], [1]> // Start=2(非数组) → Flatten<[[3,4],[[[5]]]], [1,2]> // Start=3,4 → Flatten<[[[5]]], [1,2,...Flatten<[3,4]>]> → [1,2,3,4] // Start=[[5]](数组) → Flatten<[], [1,2,3,4,...Flatten<[[[5]]]>]> → 继续拍 [[5]]→[5] // 最终 [1, 2, 3, 4, 5]

【验证与复盘】

// T:待拍平数组;Result:已拍平结果(默认空) type Flatten<T extends any[], Result extends any[] = []> = T extends [infer Start, ...infer Rest] ? Start extends any[] ? Flatten<Rest, [...Result, ...Flatten<Start>]> : Flatten<Rest, [...Result, Start]> : Result

验证:上例展开到[1,2,3,4,5];注意...Flatten<Start>的...不能省,否则整个子数组会被当成单个元素塞进去。✅

题 8|Permutation 联合全排列:分配律加元组包裹 never 终止

【题目】

type perm = Permutation<'A' | 'B' | 'C'>; // 期望 ['A','B','C'] | ['A','C','B'] | ['B','A','C'] | ['B','C','A'] | ['C','A','B'] | ['C','B','A']

【第一反应】

排列要「每个元素轮流当排头」,这正是联合类型分配律的强项:条件类型里的T若是联合,T extends U ? A : B会先把T拆开逐项判断、再用|合并结果——这一点在 Easy 难度的 Exclude 里已验证过。卡点有两个:题目只给了一个泛型,分配律需要「当前项 + 全体候选」两个角色;递归到底时输入变never,而裸T extends never对never有分配特例,会把判断直接吞掉。

【破局关键】🔑

造第二个泛型U默认等于T,让T(当前排头)对U(候选全集)做分配;用Exclude<U, T>每次剔除已用元素,剩never即终止。终止判断必须写成元组包裹[T] extends [never]:把T塞进元组就阻断了分配行为,never才能被正常判定,联合类型也不会在这里被提前分发。infer的底层原理可回看 《Typescript infer 关键字》。

【逐步展开】

Permutation<'A' | 'B' | 'C'> // 对 U 分配,拆成三项: Permutation<'A','A'|'B'|'C'> | Permutation<'B',...> | Permutation<'C',...> // 展开第一项,用 Exclude 剔除自身 'A': ['A', ...Permutation<'B' | 'C'>] // 再展开,剔除 'B': ['B', ...Permutation<'C'>] | ['C', ...Permutation<'B'>] // 展开到叶子: ['C', ...Permutation<never>] → 命中 [T] extends [never],返回 [] // 逐层拼回 → ['A','B','C'] 等 6 种排列

【验证与复盘】

// T:当前排头;U:候选全集(默认等于 T) type Permutation<T, U = T> = [T] extends [never] ? [] : T extends U ? [T, ...Permutation<Exclude<U, T>>] : []

验证:Permutation<'A'|'B'|'C'>展开出 6 种排列全命中;若把[T] extends [never]误写成T extends never,结果是never而非[],排列直接丢失。✅

多写法对比与取舍

把前面出现过的多种写法收拢成一张表,方便你在实际项目里按场景选型:

写法优点缺点适用场景
交叉类型Obj & {K:V}(AppendToObject)代码最短,保留原对象全部修饰符输出是交叉类型,部分工具类型不接受交叉形状只合成对象、不关心内部形状
映射类型 +keyof并集(AppendToObject)纯映射,输出形状精确可控需手写Exclude收敛键,略长需要精确对象形状、被其他工具类型消费
累积泛型P(StringToUnion)中间态显式、可读性好多一个类型参数结果需跨层传递、后续还要加工
隐式联合拼接(StringToUnion)代码最简、无额外参数依赖never被吸收,初看略隐晦结果就是联合、无后续加工
元组包裹[T] extends [never](Permutation)正确判定never、阻断分配比裸判断多一层括号递归终止、判断never/空
裸判断T extends never写法最直白对never触发分配特例、直接吞成never基本不建议用于递归终止

五个高频陷阱:错误写法与正确写法

陷阱 1:裸判断never

  • 错误写法:type P<T, U = T> = T extends never ? [] : ...
  • 后果:T = never时触发分配特例,整体返回never,排列/递归结果丢失
  • 正确写法:[T] extends [never] ? [] : ...

陷阱 2:忘记展开子数组

  • 错误写法:Flatten<Rest, [...Result, Flatten<Start>]>
  • 后果:[Flatten<Start>]把子数组当单个元素塞进Result,得到嵌套而非拍平
  • 正确写法:[...Result, ...Flatten<Start>]

陷阱 3:合并漏掉非法索引收敛

  • 错误写法:{ [K in keyof A | keyof B]: K extends keyof B ? B[K] : A[K] }
  • 后果:对「只在 A」或「只在 B」的键,另一侧A[K]/B[K]索引到不存在的键
  • 正确写法:K extends keyof A ? A[K] : never,用双条件收敛

陷阱 4:KebabCase 开头横线残留

  • 错误写法:直接返回累积结果U,不剥离首个-
  • 后果:'FooBarBaz'得'-foo-bar-baz',首字母大写导致开头多一个横线
  • 正确写法:末尾接RemoveFirstHyphen<U>剥掉首个-

陷阱 5:数字绝对值在数字域匹配

  • 错误写法:T extends -number ? ...之类对符号做类型匹配
  • 后果:number类型不携带「负号字符」信息,无法按符号匹配
  • 正确写法:先${T}转字符串域,再`-${infer R}`抠前缀

收尾提炼:三条可复用方法论

把这 8 道题的方法论压成三条,后面遇到同族题目可以直接套用:

  1. 类型层的「可变变量」就是带默认值的泛型:字符串拆、数组拍平、结果累积,本质都是把中间态挂在= []/= never/= ''的默认泛型上逐层推进,等价于函数递归用参数传累加器。
  2. 换域再匹配:number用${T}进字符串域、string用${infer F}${infer R}拆字符、对象用keyof进键域——不同「域」之间靠模板字面量、[length]、keyof切换,而infer是贯穿各域的通用取件工具。
  3. never要刻意绕过:递归终止用[T] extends [never]而非裸T extends never;联合累积用never起点零成本起步,再靠元组包裹阻断分配特例。

想继续打磨手感,下一批可看 Medium 25~32 题(Diff、IsNever、IsUnion),同族的infer拼装思路还能在 Medium 63~68 题(Unique、MapTypes) 里复用。

落款

本文整理自前端精读周刊「TS 类型体操」系列,原题出自 type-challenges 题库 Medium 难度第 17~24 题。类型体操的核心不是背语法,而是把类型当一门语言来编程——多拆、多推、多练,推导手感自然会长出来。

【免费下载链接】weekly前端精读周刊。帮你理解最前沿、实用的技术。项目地址: https://gitcode.com/GitHub_Trending/we/weekly

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询