☰
type-challenges 中阶挑战解析:用 TypeScript 类型系统实现 Array.shift(Shift\<T\>)
2026/10/2 1:46:39 网站建设 项目流程
  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载

本篇技术指南以 type-challenges 仓库中的 3062・Shift 中阶题目为核心,完整讲解如何用类型系统实现Array.shift的类型版本:从题目要求、仓库测试基准出发,逐步推导出基于infer与元组解构的多种解法,并深入剖析空数组、单元素数组、unknown约束与 readonly 元组等边界场景。读完本文,你将掌握元组(Tuple)上的条件类型分发、剩余元素推断(Rest Inference)等核心技巧,能够举一反三地独立实现Pop、Push、Unshift等数组操作类型。

题目解读:为 Array.shift 实现类型层版本

在 JavaScript 中,Array.prototype.shift()会移除并返回数组的第一个元素,使原数组长度减一。type-challenges 的这道中阶(medium)题目要求我们把这个行为搬到类型层面:实现一个泛型Shift<T>,输入一个数组/元组类型T,输出去掉首元素后的剩余部分。

题目给出的唯一示例(见 questions/03062-medium-shift/README.md):

type Result = Shift<[3, 2, 1]> // [2, 1]

该题目由 jiangshan(@jiangshanmeta 系列题目作者之一)发起,难度为中阶(medium),标签为#array。与它直接关联的是仓库中的 16・Pop——两题互为镜像:Pop删除最后一个元素,Shift删除第一个元素。

仓库中的题目骨架与测试基准

要正确解题,首先看清仓库为这道题准备的起点和判定标准。

起点模板(questions/03062-medium-shift/template.ts)只有一行占位实现:

type Shift<T> = any

注意模板的T没有extends any[]之类的约束,这意味着约束需要由解题者自己加上——测试用例恰恰对此提出了要求。

判定标准(questions/03062-medium-shift/test-cases.ts)共 5 个用例:

import type { Equal, Expect } from '@type-challenges/utils' type cases = [ // @ts-expect-error Shift<unknown>, Expect<Equal<Shift<[]>, []>>, Expect<Equal<Shift<[1]>, []>>, Expect<Equal<Shift<[3, 2, 1]>, [2, 1]>>, Expect<Equal<Shift<['a', 'b', 'c', 'd']>, ['b', 'c', 'd']>>, ]

逐个拆解这些用例,它们恰好覆盖了四类关键场景:

用例输入期望输出考察点
Shift<unknown>unknown应产生类型错误(@ts-expect-error)必须为T加上数组约束
Shift<[]>空元组[]空数组不崩溃、返回空元组
Shift<[1]>单元素元组[]移除唯一元素后为空
Shift<[3, 2, 1]>/Shift<['a','b','c','d']>多元素元组去掉首元素核心功能正确性

其中Equal与Expect来自仓库工作区的@type-challenges/utils包(utils/index.d.ts):

export type Expect<T extends true> = T export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false

Equal<X, Y>是一个对类型做“结构严格相等”比较的辅助类型(利用函数签名在泛型推断下的行为判断两类型是否完全一致),Expect则强制结果必须为字面量true。因此测试的本质是:只有当你的Shift输出与期望类型严格相等时,Expect<Equal<...>>才编译通过。

解法一:infer 解构 + 剩余元组

最直观的思路与运行时实现完全对应:把元组"拆"成"首元素 + 剩余部分",然后返回剩余部分。这依赖 TypeScript 条件类型中的infer与 rest 元素模式匹配:

type Shift<T extends any[]> = T extends [infer First, ...infer Rest] ? Rest : []

逐部分解释:

  • T extends any[]:为T加上数组约束。这是必须的——否则Shift<unknown>不会报错,第一个用例(@ts-expect-error)就会失效,导致测试编译失败。
  • [infer First, ...infer Rest]:将T与"至少含一个元素的元组"模式匹配。匹配成功时,infer First捕获首元素类型,...infer Rest捕获剩余元素的元组类型。
  • 匹配成功走真分支返回Rest;匹配失败(即空元组或非元组)走假分支返回[]。

验证几个关键用例:

type R1 = Shift<[3, 2, 1]> // [2, 1] type R2 = Shift<['a','b','c']> // ['b', 'c'] type R3 = Shift<[1]> // [] (First 捕获 1,Rest 推断为 []) type R4 = Shift<[]> // [] ([] 无法匹配 [infer F, ...infer R])

空元组走假分支[]的原因在于:[]与[infer First, ...infer Rest]模式匹配时,rest 元素...infer Rest要求至少存在零个以上元素可捕获,但类型系统不会把一个"零长度"的[]强行套进"至少一个元素"的模式——因此不匹配,返回[]。这与测试用例Shift<[]>的期望完全一致。

解法二:省略首元素名称,直取剩余部分

如果不需要在结果中使用首元素,可以不为它命名,把infer只用于剩余部分:

type Shift<T extends unknown[]> = T extends [unknown, ...infer Rest] ? Rest : []

与解法一的区别:

  • 用unknown占位匹配任意类型的首元素,避免出现未被使用的infer First变量(仓库 tsconfig 开启了noUnusedParameters,保持类型整洁也能减少 ESLint 告警,见 tsconfig.base.json)。
  • T extends unknown[]与T extends any[]在此处作用等价,均是对元组/数组的类型约束。

两种写法在本题的测试基准下结果完全一致,选择哪种取决于个人风格与后续是否复用首元素。

边界情况与类型约束详解

为什么必须拒绝unknown

测试用例第一行// @ts-expect-error Shift<unknown>明确要求:把Shift<unknown>写进代码应当产生类型错误。@ts-expect-error注释的作用是"断言下一行存在类型错误;若下一行没有报错,则本注释本身报错"。这意味着:

  • 若你的Shift<T>没有extends any[]约束(照抄模板type Shift<T> = any),Shift<unknown>不会报错,@ts-expect-error反而会触发"无错误可断言"的编译错误——测试直接失败;
  • 加上约束后,unknown不满足unknown extends any[],于是Shift<unknown>产生"类型 'unknown' 不满足约束 'any[]'" 的错误,@ts-expect-error断言成立。

单元素与空数组

  • Shift<[1]>→[]:模式[infer F, ...infer R]成功匹配,R推断为空元组[]。
  • Shift<[]>→[]:模式匹配失败走假分支。注意两条路径汇合到同一个结果[],但语义不同——前者是"移除唯一元素后自然为空",后者是"空元组本就没有首元素可移除"。

readonly 元组的行为(可推断结论)

仓库测试未显式覆盖 readonly 元组,但从模式匹配机制可以推断:Shift<readonly [1, 2]>这类输入在[infer First, ...infer Rest]匹配时会保留 readonly 修饰——infer推断出的Rest会维持元组的只读性(readonly [2])。若希望强制输出普通可变元组,可在返回前用[...Rest]展开,例如T extends [unknown, ...infer Rest] ? [...Rest] : [],[...Rest]会剥离 readonly 修饰生成新的可变数组类型。这一写法同样能通过既有全部用例。

与 Pop、Push、Unshift 的对照:数组操作四件套

Shift并非孤立的题目。仓库将数组增删操作拆成了难度递进的系列:

  • 16・Pop(中阶):删除最后一个元素。其模板为type Pop<T extends any[]> = any,典型的解法是把模式写成[...infer Rest, infer Last]——rest 元素放在前面捕获剩余部分,最后一个元素由infer Last捕获。其测试用例同样覆盖了空数组Pop<[]>返回[]的情况(见 questions/00016-medium-pop/test-cases.ts)。题目描述中还专门提示:"作为额外练习,能否顺便实现Shift、Push与Unshift?"
  • 3057・Push(简单):在元组末尾追加一个元素,模板type Push<T, U> = any,典型实现为[...T, U]。
  • 3060・Unshift(简单):在元组开头插入一个元素,模板type Unshift<T, U> = any,典型实现为[U, ...T]。

四个类型可以横向对比:

类型操作运行时原型核心模式匹配难度
Shift<T>Array.shift()T extends [infer F, ...infer R] ? R : []medium
Pop<T>Array.pop()T extends [...infer R, infer L] ? R : []medium
Push<T, U>Array.push()[...T, U]easy
Unshift<T, U>Array.unshift()[U, ...T]easy

可以看到一个规律:凡是"删头去尾"的操作都需要infer+ 条件类型,因为结果(剩余部分)是"被推断出来的";而"追加插入"只需要 rest 展开语法,因为结果是"直接构造出来的"。这也是为什么Push/Unshift是 easy、而Pop/Shift是 medium。此外,同属数组主题的还有 14・First of Array(仅提取首元素,模板为type First<T extends any[]> = any,实现T extends [infer F, ...any[]] ? F : never)与 15・Last of Array,它们可以看作Shift/Pop的"只读版本"前置练习。

原理深入:infer 在元组上的工作方式

条件类型与模式匹配

T extends [infer First, ...infer Rest] ? Rest : []本质是 TypeScript 条件类型T extends X ? A : B的一种应用:当T与模式X结构兼容时进入真分支,并在该分支内使用infer声明的类型变量。对元组而言,匹配是按位置进行的:

  • 普通元素位置(如infer First)捕获该位置的元素类型;
  • rest 位置(...infer Rest)捕获剩余元素组成的元组类型。

这正是"元组级 shift"得以实现的核心机制:rest 元素推断天然产生一个与剩余元素一一对应的元组,无需任何手工展开。

空元组为何不匹配

模式[infer First, ...infer Rest]蕴含"第一个位置必须有一个元素"。空元组[]不满足这一结构约束,因此[] extends [infer First, ...infer Rest]为 false,条件类型落入假分支返回[]。这与运行时的防御性处理思路一致:shift()对空数组返回undefined且不改变数组,类型层面则"原样返回空元组",避免产生never或越界访问。

在本仓库中验证实现

type-challenges 没有单独的 test 脚本(见根目录 package.json),判题方式是把 questions/03062-medium-shift/test-cases.ts 与你的实现放在一起做编译期校验:

  1. 将Shift的实现填入 questions/03062-medium-shift/template.ts;
  2. 仓库已通过pnpm-workspace.yaml将@type-challenges/utils挂为工作区依赖(声明见根 package.json 的"@type-challenges/utils": "workspace:*"),执行pnpm install后即可获得Equal/Expect;
  3. 运行 TypeScript 编译器检查测试文件,例如npx tsc --noEmit questions/03062-medium-shift/test-cases.ts(仓库根目录的 tsconfig.base.json 启用了strict模式,保证约束与@ts-expect-error断言都被严格校验);
  4. 若无任何编译错误,说明 5 个用例全部通过。

对于"移除首元素但保留剩余元组类型"这类需求,该方法同样可用于实际业务类型设计,例如从事件参数元组中剔除event参数、解析路由参数列表时丢弃首个 token 等场景。

总结

围绕 type-challenges 的 3062・Shift 题目,本文完成了从题目解读、测试基准分析到多解法推导与边界论证的完整闭环:

  1. 核心解法:type Shift<T extends any[]> = T extends [infer First, ...infer Rest] ? Rest : [],用条件类型 + rest 元素推断实现"删除首元素";
  2. 约束是硬性要求:extends any[]不仅保障类型安全,更是通过@ts-expect-error Shift<unknown>用例的前提;
  3. 空元组与单元素元组都经由不同路径得到[],行为符合运行时直觉;
  4. 横向对比Pop/Push/Unshift后可以提炼规律:删除类操作依赖infer推断剩余部分,插入类操作只需 rest 展开构造。

掌握了Shift,你就掌握了元组模式匹配与 rest 推断这对类型体操的基础招式,后续无论是 Flatten 这类递归展平,还是 Reverse 这类元组反转,其核心都是同一套infer+ rest 的递归拆解技巧。

  • 示例工程

【免费下载链接】type-challenges

Collection of TypeScript type challenges with online judge

项目地址:https://gitcode.com/GitHub_Trending/ty/type-challenges
点击查看免费下载
上一篇:pwru输出格式解析:从JSON到元数据的完整解读
下一篇:G0DM0D3三层遥测架构设计:ZDR元数据、客户端信标与opt-in数据集

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

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

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

立即咨询