- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
本篇技术指南以 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 : falseEqual<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 与你的实现放在一起做编译期校验:
- 将
Shift的实现填入 questions/03062-medium-shift/template.ts; - 仓库已通过
pnpm-workspace.yaml将@type-challenges/utils挂为工作区依赖(声明见根 package.json 的"@type-challenges/utils": "workspace:*"),执行pnpm install后即可获得Equal/Expect; - 运行 TypeScript 编译器检查测试文件,例如
npx tsc --noEmit questions/03062-medium-shift/test-cases.ts(仓库根目录的 tsconfig.base.json 启用了strict模式,保证约束与@ts-expect-error断言都被严格校验); - 若无任何编译错误,说明 5 个用例全部通过。
对于"移除首元素但保留剩余元组类型"这类需求,该方法同样可用于实际业务类型设计,例如从事件参数元组中剔除event参数、解析路由参数列表时丢弃首个 token 等场景。
总结
围绕 type-challenges 的 3062・Shift 题目,本文完成了从题目解读、测试基准分析到多解法推导与边界论证的完整闭环:
- 核心解法:
type Shift<T extends any[]> = T extends [infer First, ...infer Rest] ? Rest : [],用条件类型 + rest 元素推断实现"删除首元素"; - 约束是硬性要求:
extends any[]不仅保障类型安全,更是通过@ts-expect-error Shift<unknown>用例的前提; - 空元组与单元素元组都经由不同路径得到
[],行为符合运行时直觉; - 横向对比
Pop/Push/Unshift后可以提炼规律:删除类操作依赖infer推断剩余部分,插入类操作只需 rest 展开构造。
掌握了Shift,你就掌握了元组模式匹配与 rest 推断这对类型体操的基础招式,后续无论是 Flatten 这类递归展平,还是 Reverse 这类元组反转,其核心都是同一套infer+ rest 的递归拆解技巧。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 中阶挑战题解:在类型系统里实现 Array.lastIndexOf——LastIndexOf\<T, U\> 完整推导
type challenges 中阶挑战题解:在类型系统里实现 Array.lastIndexOf——LastIndexOf\<T, U\ 完整推导 本篇围绕
示例工程type-challenges 中阶挑战:用类型系统实现联合类型的全排列 Permutation
type challenges 中阶挑战:用类型系统实现联合类型的全排列 Permutation 本篇文章以 type challenges 仓库的 00296
示例工程Clypra项目部署指南:从源码到可执行文件的完整发布流程
Clypra项目部署指南:从源码到可执行文件的完整发布流程 Clypra是一款基于Tauri、React和TypeScript构建的现代视频编辑器,致力于提供免
音视频视频视频处理桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考