拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端精读周刊第 249 期:ObjectEntries、Shift、Reverse 等 8 道 TS 类型体操实战精解

前端精读周刊第 249 期:ObjectEntries、Shift、Reverse 等 8 道 TS 类型体操实战精解
  • 文档
  • 技术博客
  • 教程

【免费下载链接】weekly

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/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 ? "" : TUnion

IsNever的判断技巧也来自本系列 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

前端精读周刊。帮你理解最前沿、实用的技术。

项目地址:https://gitcode.com/GitHub_Trending/we/weekly
点击查看免费下载

相关推荐

上一篇:进阶指南:React Native Swipe List View 的左右滑动动作系统
下一篇:Python数据处理的终极指南:reader.py模块深度解析

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

返回列表