- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
本篇是《前端精读周刊》TS 类型体操 系列的实战解读,以 type-challenges Medium 难度第 41~48 题为素材,逐题拆解ObjectEntries、Shift、TupleToNestedObject、Reverse、FlipArguments、FlattenDepth、BEM style string、InorderTraversal八道类型题。读完你不仅能拿到每一题可运行的最终答案,更能掌握「对象/数组 ↔ 联合类型转换」「infer模式匹配」「递归与辅助泛型」「字符串模板类型」「用数组长度模拟数值运算」这五类 TS 类型编程的核心思维,并理解为什么正向判断(而非用null判断)是 TS 递归的标准姿势。
前置知识:对象与数组如何转联合类型
这批题目里,多道题的第一步都是「把某个容器类型摊开成联合类型」,思路高度统一:
- 数组转联合类型:用
[number]作为下标索引。
['1', '2', '3']['number'] // '1' | '2' | '3'- 对象转联合类型:用
[keyof T]作为下标索引。
type ObjectToUnion<T> = T[keyof T]这个套路在本系列前面的 247.精读《Diff, AnyOf, IsUnion...》 中已经反复出现过,比如Diff借助Exclude<keyof A, keyof B>取 Key 差集,本质都是对keyof T的运用。理解了它,ObjectEntries就成功了一半。
ObjectEntries:实现 TS 版Object.entries
实现 TS 版本的Object.entries:
interface Model { name: string; age: number; locations: string[] | null; } type modelEntries = ObjectEntries<Model> // ['name', string] | ['age', number] | ['locations', string[] | null]看到这道题后的第一反应应该是:如何先把对象转换为联合类型?这个问题不解决,就无从下手。按上面的前置知识,答案呼之欲出——构造一个 Value 是[K, T[K]]结构的对象,再用[keyof T]摊开:
type ObjectEntries<T> = { [K in keyof T]: [K, T[K]] }[keyof T]但这版无法通过单测ObjectEntries<{ key?: undefined }>,原因是可选 Key 会被 TS 自动补上undefined,导致key位置出现undefined。解决方法是使用映射类型修饰符-?,强制把所有 Key 描述为非可选:
type ObjectEntries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T]还有最后一个单测ObjectEntries<Partial<Model>>要过:Partial会让所有值带上undefined,因此还需要把 Value 中的undefined剔除:
// 本题答案 type RemoveUndefined<T> = [T] extends [undefined] ? T : Exclude<T, undefined> type ObjectEntries<T> = { [K in keyof T]-?: [K, RemoveUndefined<T[K]>] }[keyof T]这里的RemoveUndefined用[T] extends [undefined]而非T extends undefined,正是为了避免联合类型分配律把T拆开逐项判断——这是本系列在 246.精读《Permutation, Flatten, Absolute...》 中详细讲解过的经典技巧([T] extends [never]同理),值得记住。
Shift:抛弃数组第一项
实现 TS 版Array.shift:
type Result = Shift<[3, 2, 1]> // [2, 1]这道题可以说是简单难度,只要把第一项抛弃即可,利用infer轻松实现:
// 本题答案 type Shift<T> = T extends [infer First, ...infer Rest] ? Rest : never关键点有二:一是infer First会匹配到数组第一项;二是...infer Rest的 rest 语法把剩余元素收集成一个新元组。注意First虽然没在结果中使用,但必须存在才能让模式匹配成立——T为空数组时匹配失败,返回never,语义上与 JS 的shift()返回undefined不同,这是类型层面合理的边界处理。
Tuple to Nested Object:元组递归嵌套成对象
实现TupleToNestedObject<T, P>,其中T仅接收字符串数组,P是任意类型,生成一个递归对象结构:
type a = TupleToNestedObject<['a'], string> // {a: string} type b = TupleToNestedObject<['a', 'b'], number> // {a: {b: number}} type c = TupleToNestedObject<[], boolean> // boolean,元组为空时直接返回 P这道题一次用到了 5 个知识点:递归、辅助类型、infer、如何指定对象 Key、PropertyKey,全部知道并组合起来才能解决。
首先,因为返回值是递归对象,递归过程中必定不断修改它,因此给泛型添加第三个参数R存储这个对象;并且在递归数组时从最后一个元素开始,这样就能从最内层对象开始一点点把它「包起来」:
type TupleToNestedObject<T, U, R = U> = /** 伪代码 T extends [...infer Rest, infer Last] */下一步是如何描述一个对象 Key?之前Chainable Options例子中学到过K in Q(见 244.精读《Get return type, Omit, ReadOnly...》 中的Chainable题:option: <K extends string, V>(key: K, value: V) => Chainable<Result & { [P in K]: V }>),但需要注意直接这么写会报错,因为必须声明Q extends PropertyKey。最后再处理递归结束条件——T变成空数组时直接返回R:
// 本题答案 type TupleToNestedObject<T, U, R = U> = T extends [] ? R : ( T extends [...infer Rest, infer Last extends PropertyKey] ? ( TupleToNestedObject<Rest, U, { [P in Last]: R }> ) : never )注意infer Last extends PropertyKey这种「infer 带约束」的写法(TS 4.7+ 支持),它保证Last一定是合法对象 Key,同时让映射类型[P in Last]不再报错。递归过程中,R从最初的U开始,每剥掉数组最后一个元素,就把当前累积的R包进一层新对象,直到数组为空返回R。
Reverse:反转元组
实现 TS 版Array.reverse:
type a = Reverse<['a', 'b']> // ['b', 'a'] type b = Reverse<['a', 'b', 'c']> // ['c', 'b', 'a']这道题比上一题简单,只需要用一个递归即可:
// 本题答案 type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T思路:每轮用infer End取出最后一个元素放到新元组头部,剩余部分递归处理;数组为空时直接返回T(即[]),递归自然终止。注意这里 base case 返回的是T本身而不是空数组字面量,这样对[]输入也能保持类型正确。
FlipArguments:反转函数参数
实现FlipArguments<T>将函数T的参数反转:
type Flipped = FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void> // (arg0: boolean, arg1: number, arg2: string) => void本题与上一题类似,只是反转内容从数组变成了函数的参数,只要用infer定义出函数的参数,再利用Reverse反转一下即可:
// 本题答案 type Reverse<T extends any[]> = T extends [...infer Rest, infer End] ? [End, ...Reverse<Rest>] : T type FlipArguments<T> = T extends (...args: infer Args) => infer Result ? (...args: Reverse<Args>) => Result : never(...args: infer Args)会把整个参数列表捕获成元组Args,infer Result捕获返回值;不满足函数形态时返回never。这个「函数签名的模式匹配」在业务中非常常见,例如包装函数时保留并变换原签名。
FlattenDepth:按指定深度打平数组
实现指定深度的 Flatten:
type a = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]],打平 2 次 type b = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]],深度默认为 1这道题比之前 246 期的Flatten更棘手,因为需要控制打平的次数。回顾一下Flatten的递归套路:T extends [infer Start, ...infer Rest],元素是数组则递归展开,否则直接收集。FlattenDepth的基本想法是:先实现打平一次的函数,再根据Deep值递归对应次数。
先实现打平一次(单层)的FlattenOnce:
type FlattenOnce<T extends any[], U extends any[] = []> = T extends [infer X, ...infer Y] ? ( X extends any[] ? FlattenOnce<Y, [...U, ...X]> : FlattenOnce<Y, [...U, X]> ) : U注意这里与Flatten的区别:Flatten遇到嵌套数组会递归到底(...Flatten<Start>),而FlattenOnce只展开一层(...X直接展开当前元素,不再递归进X内部),这正是「一次打平」的语义。
再实现主函数FlattenDepth。因为TS 无法实现 +、- 号运算,我们必须用数组长度判断与操作数组来辅助实现(这与 248 期MinusOne用['length']模拟运算的思路一脉相承):
// FlattenOnce 见上 type FlattenDepth< T extends any[], U extends number = 1, P extends any[] = [] > = P['length'] extends U ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]> )P是递归深度计数器:每递归一次就[...P, any]给数组塞一个元素,下次递归时若P['length'] extends U成立,说明递归深度已达到U,返回当前打平结果。
但测试用例FlattenDepth<[1, [2, [3, [4, [5]]]]], 19260817>会引发超长次数递归,因此需要提前终止:如果打平一次后结果和原来一样(已经是平的),就不用继续递归了,此时用FlattenOnce<T> extends T判断:
// 本题答案 // FlattenOnce 见上 type FlattenDepth< T extends any[], U extends number = 1, P extends any[] = [] > = P['length'] extends U ? T : ( FlattenOnce<T> extends T ? T : ( FlattenDepth<FlattenOnce<T>, U, [...P, any]> ) )三行判断的优先级:先看深度是否达标,再看本轮打平是否无变化(提前终止),最后才继续递归。这种「递归 + 提前终止」的双保险,是类型递归面对极端输入的通用防御手段。
BEM style string:模板字符串的笛卡尔积
实现BEM函数完成其规则拼接:
Expect<Equal< BEM<'btn', [], ['small', 'medium', 'large']>, 'btn--small' | 'btn--medium' | 'btn--large' >>之前了解了通过下标将数组或对象转成联合类型,这里还有一个特殊情况:在字符串模板中通过下标方式声明每一项,会自动做笛卡尔积生成新的联合类型。最简单的写法:
type BEM<B extends string, E extends string[], M extends string[]> = `${B}__${E[number]}--${M[number]}`但这样没有考虑项不存在的情况:当E或M为空数组时,E[number]是never,整个字符串模板会坍缩为never,导致结果错误。解决思路是创建一个SafeUnion辅助类型,当传入值不存在时返回空字符串,安全跳过该段拼接:
type IsNever<TValue> = TValue[] extends never[] ? true : false type SafeUnion<TUnion> = IsNever<TUnion> extends true ? "" : TUnionIsNever的判断技巧也来自本系列 247 期的 IsNever 题:TValue[] extends never[]利用了never[]的特殊性来判断TValue是否为never。最终代码:
// 本题答案 // IsNever, SafeUnion 见上 type BEM<B extends string, E extends string[], M extends string[]> = `${B}${SafeUnion<`__${E[number]}`>}${SafeUnion<`--${M[number]}`>}`当E = []时,__${never}通过SafeUnion变成'',最终得到'btn--small' | 'btn--medium' | 'btn--large'这类只带修饰符的结果;当M = []时同理。这比「直接用字面量判断数组是否为空」更通用,因为它作用于任意联合类型。
InorderTraversal:TS 版二叉树中序遍历
实现 TS 版二叉树中序遍历:
const tree1 = { val: 1, left: null, right: { val: 2, left: { val: 3, left: null, right: null }, right: null, }, } as const type A = InorderTraversal<typeof tree1> // [1, 3, 2]先回忆 JS 版中序遍历的实现:
function inorderTraversal(tree) { if (!tree) return [] return [ ...inorderTraversal(tree.left), tree.val, ...inorderTraversal(tree.right) ] }对 TS 来说,递归的实现方式有一点点不同,即通过extends TreeNode来判定它不是 Null,从而递归:
// 本题答案 interface TreeNode { val: number left: TreeNode | null right: TreeNode | null } type InorderTraversal<T extends TreeNode | null> = [T] extends [TreeNode] ? ( [ ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] ) : []你可能会问:为什么不能像 JS 一样,用null做判断呢?
type InorderTraversal<T extends TreeNode | null> = [T] extends [null] ? [] : ( [ // error ...InorderTraversal<T['left']>, T['val'], ...InorderTraversal<T['right']> ] )这么写会发现 TS 抛出异常,因为 TS 不能确定T此时符合TreeNode类型(虽然排除了null,但在条件分支里 TS 没有「负向收窄」能力),所以要对T执行属性访问时,必须采用正向判断(extends TreeNode),这正是 TS 类型编程与 JS 运行时代码在思维上的关键差异。同时注意[T] extends [TreeNode]的外层元组包裹,用于规避联合类型分配律可能带来的意外展开。
总结
这些类型挑战题目需要灵活组合 TS 的基础知识点才能破解,本批八题反复用到的核心能力包括:
- 如何操作对象:增减 Key、只读修饰符(
-?)、keyof与T[keyof T]下标访问,以及用as变形 Key(见 250 期 Flip)。 - 递归与辅助类型:用「泛型默认值」存储累积状态(
R、P、U等),这是本系列多个题目的通用套路,最早可追溯到 243 期 Pick 的泛型默认值讲解 与 246 期 Flatten 的Result参数。 infer知识点:[...infer Rest, infer End]、(...args: infer Args) => infer Result、infer Last extends PropertyKey,用于从元组、函数签名、字符串中提取子结构。- 联合类型:如何从对象或数组生成联合类型(
[keyof T]、[number]),字符串模板与联合类型的笛卡尔积关系,以及用[T] extends [...]包裹规避分配律。
正如本系列 247 期总结 所言,TS 是一门编程语言而非简单的描述或修饰符,很多复杂类型问题要动用逻辑思维来实现。建议读者拿到题目先自己尝试,再对照本文答案逐步推演,反复训练后自然能养成「TS 思维」。
本文解读基于仓库文档 TS 类型体操/249.精读《ObjectEntries, Shift, Reverse...》.md,该系列完整目录见仓库 readme.md 的「TS 类型体操」一节,可以按 243~252 的顺序循序渐进练习。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
cuda-samples 实战解析:用 simpleCUFFT 与 cuFFT 两种 Plan API 实现一维 FFT 频域卷积
cuda samples 实战解析:用 simpleCUFFT 与 cuFFT 两种 Plan API 实现一维 FFT 频域卷积 导读 本篇技术指南以 cud
文档技术博客教程notepad-- 深度拆解:Qt5 与 QScintilla 如何实现跨平台文本编辑与文件对比
notepad 深度拆解:Qt5 与 QScintilla 如何实现跨平台文本编辑与文件对比 在 macOS 上打开 GBK 文件满屏乱码、2G 日志根本打不开
文档技术博客教程前端精读周刊:前端内存管理实践
前端精读周刊:前端内存管理实践 引言 你是否曾遇到过网页随着使用时间增长而变得越来越卡顿?是否在开发复杂单页应用时,明明代码逻辑没问题,却总是出现莫名其妙的性能
文档技术博客教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考