- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
导读
本文围绕 type-challenges 仓库中的中级题目18220 Filter展开,目标是用纯 TypeScript 类型系统实现一个「数组过滤器」Filter<T, Predicate>:给定一个数组类型T和一个由原始类型(或其联合)构成的谓词Predicate,返回仅保留「类型属于Predicate」的元素的数组类型。读完本文,你将掌握条件类型在元组上的递归拆分技巧、联合类型的匹配判定、以及如何利用仓库自带的测试框架(@type-challenges/utils)验证类型答案的正确性。
一、题目速览:规格、难度与标签
本题收录于仓库的 questions/18220-medium-filter 目录,官方描述如下:
实现类型
Filter<T, Predicate>。其中T是数组,Predicate是原始类型(primitive type)或原始类型的联合(union)。结果应当是只包含「类型属于Predicate」的元素的数组。
配套的元数据位于 info.yml,明确给出了本题的定位:
difficulty: medium title: Filter tags: array, filter author: github: mu-hun name: Mu-Hun- 难度:medium(中等),适合已经熟悉条件类型、元组递归的读者挑战;
- 标签:
array、filter,说明本题考查的核心领域是数组(元组)类型的变换; - 作者:Mu-Hun(@mu-hun)。
题目本身可以在线挑战(README 中提供了 Judge 入口),也可以在本仓库本地完成并通过类型检查验证答案。
二、题目环境:模板、测试用例与工具包
2.1 起始模板
template.ts 给出了待填充的骨架,默认实现是一个空数组,显然需要完全重写:
type Filter<T extends any[], P> = []约束条件T extends any[]表明入参T必须是数组/元组类型,P没有显式约束,理论上可以传入任意类型,但按题目本意应当是原始类型或其联合。
2.2 测试用例(题目判定的唯一标准)
test-cases.ts 是本题通过与否的判定依据:
import type { Equal, Expect } from '@type-challenges/utils' type Falsy = false | 0 | '' | null | undefined type cases = [ Expect<Equal<Filter<[0, 1, 2], 2>, [2]>>, Expect<Equal<Filter<[0, 1, 2], 0 | 1>, [0, 1]>>, Expect<Equal<Filter<[0, 1, 2], Falsy>, [0]>>, ]三个用例分别覆盖三类场景:
- 单个原始类型谓词:
Filter<[0, 1, 2], 2>应得到[2],即只保留等于2的元素; - 联合类型谓词:
Filter<[0, 1, 2], 0 | 1>应得到[0, 1],即保留所有属于联合0 | 1的元素; - Falsy 联合谓词:
Falsy = false | 0 | '' | null | undefined,Filter<[0, 1, 2], Falsy>应得到[0]。注意这里的0是Falsy的成员,因此0会被保留,而1、2被过滤掉。
2.3 测试工具包
Equal与Expect来自本仓库的 utils/index.d.ts(包名@type-challenges/utils,见 utils/package.json)。其中Equal使用了经典的函数签名比较法,能够区分any、never等「看似相等实则不同」的类型:
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false export type Expect<T extends true> = TExpect<...>要求传入的类型严格等于true,一旦你的Filter结果与期望值不一致,整个cases类型声明就会报编译错误。这正是 type-challenges 一类题目的核心机制——用类型系统自己来判定类型答案的对错。
三、解题思路:元组递归 + 条件类型
数组过滤的本质是一个「遍历 + 筛选」过程。在类型层面,我们无法写循环,但可以借助条件类型对元组的递归拆分完成:
- 用
T extends [infer F, ...infer Rest]把元组拆成「第一个元素F」和「剩余部分Rest」; - 判断
F是否属于谓词P; - 属于则把
F放回结果头部,并继续处理Rest;不属于则跳过F,直接处理Rest; - 当
T为空元组时递归终止,返回[]。
3.1 标准解
type Filter<T extends any[], P> = T extends [infer F, ...infer Rest] ? F extends P ? [F, ...Filter<Rest, P>] : Filter<Rest, P> : []执行推演(以Filter<[0, 1, 2], 2>为例):
[0, 1, 2]拆出F = 0、Rest = [1, 2],0 extends 2为假 → 走Filter<[1, 2], 2>;[1, 2]拆出F = 1,1 extends 2为假 → 走Filter<[2], 2>;[2]拆出F = 2,2 extends 2为真 → 走[2, ...Filter<[], 2>];[]无法匹配拆分模式 → 返回[];- 最终结果
[2],与用例期望一致。
3.2 借助内置工具类型Extract
条件类型F extends P ? ... : ...本质上就是在做集合(成员)判定,也可以显式地用内置的Extract<F, P>表达「取出 F 中属于 P 的部分」:
type Filter<T extends any[], P> = T extends [infer F, ...infer Rest] ? Extract<F, P> extends never ? Filter<Rest, P> : [F, ...Filter<Rest, P>] : []这里Extract<F, P> extends never表示「F 与 P 没有交集」→ 过滤掉;否则保留。两种写法等价,标准解更简洁,Extract版语义更显式,适合作为思路二。
3.3 更严谨的写法:元组包裹避免分发
条件类型的左侧如果是裸类型参数(naked type parameter),会触发联合类型分发(distributive conditional types)。在上面的标准解中,F是从infer推断出来的具体类型,本身不会触发分发;但为了养成良好习惯、应对「元素本身是联合类型」的极端输入,可以用元组包裹来显式禁用分发:
type Filter<T extends any[], P> = T extends [infer F, ...infer Rest] ? [F] extends [P] ? [F, ...Filter<Rest, P>] : Filter<Rest, P> : []对本题测试用例而言,三种写法的结果一致,均可通过验证。
四、易错点与边界分析
联合类型谓词不参与分发:
F extends P中F是具体的推断类型、P是联合,因此F不会对P分发,0 extends 0 | 1会得到true,这正是用例 2 能通过的关键。如果你把写法改成P extends F,语义就会变成「P 整体包含于 F」,方向就反了。0与 Falsy 的关系:0 extends (false | 0 | '' | null | undefined)为真,所以Filter<[0, 1, 2], Falsy>返回[0]。这提醒我们:题目中的谓词是类型层面的包含关系,与运行时Boolean(0) === false的「假值」概念不是一回事——类型过滤只看类型归属。never元素的处理差异:标准解中never extends P恒为真,即never元素会被「保留」。本题的测试用例没有涉及never,因此三种写法都能通过;但如果想剔除never,就需要单独处理,这正是 hard 难度题目 00399 FilterOut 的考点。boolean与false的差异:boolean类型等价于true | false,因此boolean extends false为假。若谓词只含false而元素是boolean,该元素不会被保留。这类边界在 00399-hard-tuple-filter/test-cases.ts 的用例中体现得更明显(例如FilterOut<[1, never, 'a', undefined, false, null], never | null | undefined>期望得到[1, 'a', false],其中false被保留、undefined/null/never被剔除)。
五、与 Hard 题目 FilterOut(00399)的对比
本仓库还有一道姊妹题 00399-hard-tuple-filter,要求实现FilterOut<T, F>,把「属于F」的元素全部剔除。两者正好互为镜像:
- Filter(18220,medium):保留「属于谓词」的元素;
- FilterOut(00399,hard):剔除「属于过滤集合」的元素。
FilterOut 的测试用例额外覆盖了never的剔除、[never, 1, 'a', undefined, false, null]与never | null | undefined的组合,以及「元素本身是联合类型」时[number | null | undefined, never]应整体保留的情况。从源码结构看,FilterOut 需要在 Filter 的思路上额外处理never与元素级联合的判定,难度因此更高。建议先完成 18220,再挑战 00399,形成「一正一反」的完整认知。
六、本地验证方法
type-challenges 仓库以 pnpm 作为包管理器(见根目录 package.json 中的"packageManager": "pnpm@8.12.1"),并在 devDependencies 中通过 workspace 引入@type-challenges/utils:
"@type-challenges/utils": "workspace:*"本地验证答案的典型步骤:
- 在仓库根目录执行
pnpm install,安装工作区依赖(含@type-challenges/utils与 TypeScript 5.3.x,见 utils/package.json); - 将答案写入 questions/18220-medium-filter/template.ts 的
Filter; - 对测试用例做类型检查,例如:
npx tsc --noEmit questions/18220-medium-filter/test-cases.ts当Filter实现正确时,cases中的每个Expect<Equal<...>>都展开为true,编译无错误;实现不正确时,编译器会指出Expect的约束被违反。仓库根目录的 tsconfig.base.json 开启了strict等严格选项,能帮助捕捉更多类型层面的细节。
七、小结
Filter<T, Predicate>是 type-challenges 中非常经典的 medium 级数组题,核心公式可概括为:
- 递归拆分:
T extends [infer F, ...infer Rest]; - 条件筛选:
F extends P ? 保留 : 跳过; - 终止条件:空元组返回
[]。
在此基础上,你可以继续延伸:加入never处理即为 hard 题 FilterOut(00399),还可以尝试与仓库中其他数组类题目(如 Flatten、Reverse)对比,体会「元组递归 + 条件类型」这一通用范式在类型体操中的复用价值。文中给出的三种实现写法均可通过 test-cases.ts 的全部断言,读者可根据可读性偏好自行选用。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
type-challenges 18220 · Filter:用 TypeScript 类型系统实现按谓词过滤数组元素
type challenges 18220 · Filter:用 TypeScript 类型系统实现按谓词过滤数组元素 本篇技术指南围绕 type challe
示例工程TypeScript 类型体操实战:实现 Tuple Filter(`FilterOut<T, F>`)—— type-challenges 399 题源码级解析
TypeScript 类型体操实战:实现 Tuple Filter( FilterOut<T, F )—— type challenges 399 题源码级解析
示例工程type-challenges 第 14 题 First of Array 实战:用类型体操实现 First<T> 提取数组首元素类型
type challenges 第 14 题 First of Array 实战:用类型体操实现 First<T 提取数组首元素类型 本篇文章以 type ch
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考