ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript类型编程入门:从Pick实现看keyof、泛型约束与映射类型

TypeScript类型编程入门:从Pick实现看keyof、泛型约束与映射类型

type-challenges 这个项目,我陆陆续续刷过好几轮。说实话,前面十几道基础题里,第 4 题“实现 Pick”是最值得停下来认真琢磨的一道。它表面上只让你做一个MyPick<T, K>,实际上把 TypeScript 类型系统里最常用的四块积木全串起来了:keyof、泛型约束、索引访问、映射类型。很多初学者在这道题卡住,不是因为语法不会写,而是不清楚“用类型编程”和“写业务类型”是两种完全不同的思维方式。

这篇文章就围绕这道题展开。我会先拆解题面在训练什么,再把四个核心知识点逐个讲透,接着从报错版本一路改到通过,最后整理几个我在实际刷题过程中踩过的边界坑。无论你是刚接触 TypeScript 的小白,还是已经在业务里写过不少泛型的开发者,这一篇都能让你在类型工具的理解上更扎实一些。

1. 先说清楚:这道 type-challenges 第 4 题到底在训练什么

1.1 题面其实只要求你做一件事

打开第 4 题的模板,默认情况下你会看到这样一行:

type MyPick<T, K> = any;

题目期望你把这个any改成真正的实现,让MyPick<T, K>的行为和 TypeScript 内置的Pick<T, K>一致:从类型T中挑选出若干属性,属性名由K指定,最终生成一个新对象类型。

举个例子,假设有一个Todo接口:

interface Todo { title: string; description: string; done: boolean; }

当你写出MyPick<Todo, 'title' | 'done'>时,它应该得到一个等价于{ title: string; done: boolean }的新类型。这不是运行时过滤对象字段,而是纯粹在类型层面“裁剪”结构。它解决的问题也很直白:很多时候你接收到的数据比实际需要的多,你希望定义一个更精确的类型,只暴露部分字段。

为什么要自己实现一遍?因为你在业务里写Pick<T, 'title'>时,TypeScript 已经帮你把事情做完了。但内置类型不是黑魔法,它背后就是一层简单的映射逻辑。把这层逻辑亲手拆开,你才会真正理解为什么Pick需要K extends keyof T这个约束,也才能在遇到“类型不满足约束”“无法用索引访问”这类报错时,一眼看出问题出在哪。

1.2 自己实现一遍,比背答案有用在哪

我见过不少人刷 type-challenges 是直接看答案的。看答案本身没问题,但如果你只是把type MyPick<T, K extends keyof T> = { [P in K]: T[P] };抄下来然后“哦”一声,那就损失了这道题一大半价值。

自己实现一遍,你会发现几个很有意思的点:

第一,你会被迫思考“为什么需要约束”。第一次写的时候,很多人会写type MyPick<T, K> = { [P in K]: T[P] };,结果编辑器直接报错。这个报错不是凭空出现的,它逼着你去理解:如果不告诉 TypeScript “K 必须是 T 的键”,那T[P]在类型层面就是一次非法的索引操作,因为P可能来自任何地方。

第二,你会开始习惯“类型层面的调试”。写业务代码时,你往往直接看红线就能定位。但写工具类型时,你需要学会在编辑器里 hover、拆分中间类型、构造测试用例来验证。这个习惯一旦养成,后面做Partial、Readonly、Record那些题会顺畅很多。

第三,也是最实际的收获:面试或写公共类型库时,别人问Pick怎么实现,你能当场写出来,而不是只能说“内置类型里有,直接用就行”。

2. 核心四件套:keyof、泛型约束、索引访问、映射类型

2.1 keyof:把“键集合”提前算出来

keyof T在英文文档里的定义很简单:它返回T所有公共属性的键组成的联合类型。中文社区里更喜欢叫它“索引类型查询操作符”。

interface Person { name: string; age: number; } type PersonKeys = keyof Person; // 'name' | 'age'

这里有两层理解:

第一层,keyof能把你本来要手动写一遍的联合类型算出来。如果接口有十个字段,你不需要手写十个字面量,用keyof就能拿到完整的键集合。

第二层,keyof的结果是可以参与运算的。既然它是联合类型,你就可以对它做遍历、过滤、交集等操作。比如拿它去约束另外一个泛型参数,就是第 4 题的核心思路。

有几个特殊情况值得留意,我在实际刷题里经常见到:

输入类型keyof 结果说明
{ a: string; b: number }'a' | 'b'常规对象
{ [key: string]: number }string | number字符串索引签名会把数字键也纳进来
string[]number | 'length' | 'push' | ...数组类型会暴露所有数组方法名
{}never空对象没有键

尤其注意数组的情况。keyof string[]不是你想象的number,而是一个包含number和一大堆方法名的联合类型。这在做工具类型时经常会造成困惑,所以遇到数组输入时,先 hover 一下keyof的结果再决定下一步怎么写。

2.2 泛型约束和索引访问:为什么 K 必须 extends keyof T

K extends keyof T是这道题最容易忽略、也最关键的一行。它读作:泛型参数K必须满足“是keyof T的子集”这个条件。

这里可以做一个生活化类比:keyof T相当于一把钥匙上的齿纹集合,K是你手里实际持有的锁匙。如果厂家只生产了三种齿纹,你却拿出一把不存在的齿纹去开锁,那在设计阶段就应该被拒绝。extends在这里就是那道安检闸门,把不合法的情况挡在编译期之前。

那为什么非要这道闸门不可?因为后面要用T[K]去取属性类型。T[K]是索引访问类型,类似 JS 里写obj[key],但发生在类型层面。如果K不是T的键,T[K]对 TypeScript 来说就是非法表达式,编译器会直接报错。

type Value = Person['name']; // string type Values = Person['name' | 'age']; // string | number

当K是一个联合类型时,T[K]会把每个键对应的类型分别取出来,再合并成联合类型。这个特性在后面做复杂工具类型时会经常用到。K extends keyof T这个约束确保了K里的每一个成员都是合法的键,所以T[K]才能安全地工作。

2.3 映射类型:一行语法批量构造新对象

映射类型是最接近“类型编程”的一块积木。它的基本写法是:

type NewType = { [P in K]: T[P] };

你可以把[P in K]理解成一个for...in循环:遍历K联合类型里的每一个成员,把当前成员记作P,然后生成一个新的属性,属性名是P,属性值类型取T[P]。

举一个最简单的例子:

type Keys = 'a' | 'b'; type T = { a: number; b: string; c: boolean }; type Mapped = { [P in Keys]: T[P] }; // 结果: { a: number; b: string }

这样就把一个包含三个属性的类型,裁剪成了一个只包含其中两个属性的类型。这正是Pick要做的事。

理解映射类型的时候,有一点必须强调:K本身必须是有明确边界的联合类型。如果K只是一个没有约束的泛型参数,TypeScript 无法保证P in K遍历出来的P是T的键,自然也就无法允许T[P]这种操作。所以映射类型和泛型约束不是两个孤立的知识点,它们在Pick这里被牢牢绑在了一起。

3. 实操过程:从报错版本到一行正解

3.1 我写下的第一个错误版本

我相信绝大多数人第一次写这个题,给出的答案和我当时一样:

type MyPick<T, K> = { [P in K]: T[P] };

乍一看完全合理:遍历K,从T里取每个键对应的类型。但 TypeScript 会毫不留情地给出报错:

Type 'P' cannot be used to index type 'T'.

这个报错信息才是整道题真正的教学内容。它告诉你:T[P]是否合法,取决于P是否被证明是T的键。而我写的K没有任何约束,所以P也只是一个“来历不明”的类型变量,编译器不敢让你拿它去索引T。

这就好比你走进一家仓库,对管理员说“给我P号货架上的东西”,但管理员根本没听说过P号货架。正确的做法是,先拿出库存清单keyof T,把P限定在清单范围内,管理员才敢去取。

这个报错还引出一个写工具类型的通用原则:每个出现在索引位置上的泛型参数,都必须在之前建立足够的约束。不是 TypeScript 故意刁难你,而是类型系统必须在不知道具体类型的情况下,保证所有操作是安全的。

3.2 加上约束之后,一行代码通过验证

正确的实现只需要在原来的基础上补上约束:

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

逐行拆开看:

  • T是待裁剪的原类型,它没有被直接约束,因为任何对象类型理论上都可以被裁剪。
  • K extends keyof T表示K必须是T的键的子集。这一步保证了后续T[P]操作的安全性。
  • [P in K]遍历K中的每一个键。如果K传入的是'title' | 'done',那P会分别取到'title'和'done',生成两个新属性。
  • T[P]取出原类型中对应属性的类型,作为新属性的类型。

我强烈建议你把这个结果放到编辑器里实际验证一下:

interface Todo { title: string; description: string; done: boolean; } type TodoPreview = MyPick<Todo, 'title' | 'done'>;

把鼠标悬浮在TodoPreview上,你会看到 TypeScript 展示出{ title: string; done: boolean }。这个结果和内置的Pick<Todo, 'title' | 'done'>完全一致。事实上,内置Pick的官方实现就是这一行,没有任何额外逻辑。TypeScript 自带二十多个工具类型,其中相当一部分就是类似这样一层映射就搞定的。

3.3 用 Expect 和 Equal 理解项目自带的验证逻辑

type-challenges 的题目和普通 LeetCode 最大的不同在于:没有运行时的测试用例,只有类型层面的断言。你会在题目文件里看到类似这样的代码:

type cases = [ Expect<Equal<MyPick<Todo, 'title'>, { title: string }>>, Expect<Equal<MyPick<Todo, 'title' | 'done'>, { title: string; done: boolean }>>, ];

Equal<A, B>是一个类型层面的严格比较工具,如果A和B在类型层面完全一致,它就等于true,否则等于false。Expect<T>接收一个布尔结果,如果传入的是true就通过,传入false就报类型错误。

所以在编辑器里看到红线,不代表代码运行会出错,而是代表你的类型实现和预期不一致。反过来,所有Expect都没有红线,就说明你的实现通过了验证。

如果你想自己构造类似的测试,不需要依赖项目里的那套复杂工具。最简单的方式是:

type A = MyPick<Todo, 'title'>; type B = { title: string }; const check: A = { title: 'hello' }; // 如果能赋值就说明结构一致

虽然这种方式不如Equal严格,但用来快速验证思路已经足够。等你后面想做更严谨的检查,再回头研究Equal内部是怎么用条件类型做深度比较的。

4. 避坑经验:几个我踩过的 Pick 边界坑

4.1 高频报错与定位方法

做这道题的过程中,有几个报错几乎每个人都会遇到。我整理成一个速查表,方便你对照定位:

报错信息出现原因解决方案
Type 'K' cannot be used to index type 'T'K没有约束,编译器无法确认K是T的键给K加上extends keyof T
Type 'P' cannot be used to index type 'T'K约束缺失,遍历出来的P也不被信任检查泛型约束是否写完整
Type 'keyof T' does not satisfy the constraint ...使用场景不匹配,比如把K约束成了字面量类型重新检查泛型参数的继承关系
Expected 2 type arguments, but got 1调用MyPick时少传了泛型参数确认类型名后面是否写了两个参数

定位这类问题有一个很实用的方法:拆分验证。比如我先单独算:

type Keys = keyof Todo;

然后看看Keys到底是什么,再把它传给MyPick:

type Result = MyPick<Todo, Keys>;

如果这一步没问题,说明问题出在你传入的具体K上,可能是拼写错误,可能是K里包含了T没有的键。把大问题拆成小问题,逐个验证,是类型调试里最朴素的技巧。

4.2 最隐蔽的坑:可选属性和索引签名

这一节是我最想分享的。很多人在业务里用Pick用得顺手,没意识到它有一个容易忽略的边界行为:可选属性的?修饰符不会被保留。

interface WithOptional { title?: string; } type PickedOptional = MyPick<WithOptional, 'title'>;

你以为结果会是{ title?: string },但实际得到的是{ title: string | undefined }。也就是说,title变成了必选属性,只是它的类型允许是undefined。原来你可以写const a: WithOptional = {},现在用PickedOptional就必须显式给出title键。

为什么会有这个行为?因为映射类型在处理[P in K]: T[P]时,只是把原属性的“类型”取出来了,并没有复制原属性上的修饰符。?和readonly这种修饰符在映射过程中默认不会自动保留。这一点在刷题初期感知不强,但等你做Partial、Required、Readonly这些题时就会发现,它们其实就是在显式地加或去修饰符。

遇到索引签名的类型时也值得小心。如果一个类型声明了字符串索引签名,keyof的结果会包含隐式的string | number,这可能会导致你从MyPick里取出来的类型比自己预期的更宽泛。我自己遇到类似情况时,通常会先用一个中间类型把索引签名“锁住”,或者用'a' | 'b'这样的字面量联合去约束K,避免误伤。

还有一个边界情况是联合键:

type A = MyPick<Todo, 'title' | 'done'>;

这个没什么问题,P会逐个遍历两个键,生成两个属性。但如果你传入一个空联合类型,比如MyPick<Todo, never>,结果会变成空对象类型{}。这在某些泛型推导场景下会引发赋值兼容问题,需要留意。

4.3 做完 Pick,顺便打通后续一串类型题

第 4 题的一个隐藏价值,是它为你铺好了后面十几道题的基础。我刷到这里的时候明显感觉到,很多所谓的“新题”其实是在Pick上做加减法。

比如Partial<T>的实现思路是:

type MyPartial<T> = { [P in keyof T]?: T[P] };

它和Pick的区别只有两个:遍历的键从“指定的K”变成了“全部的keyof T”,同时加了一个?修饰符。

再比如Readonly<T>:

type MyReadonly<T> = { readonly [P in keyof T]: T[P] };

同样只是加了一个readonly修饰符。

Record<K, T>则更简单:

type MyRecord<K extends keyof any, T> = { [P in K]: T };

它遍历指定的键,但每个属性的值类型都直接取同一个T。

当你把Pick彻底吃透之后,再看这些题目就会产生“原来是同一套玩法”的感觉。这也是我推荐大家按顺序刷题的原因:前几道基础题看似简单,但它们把映射类型、泛型约束、索引访问这些最核心的语法揉进了你的肌肉记忆,后面所有复杂类型都是在这几块积木上不断组合。

最后再分享一个小技巧:我每做完一个工具类型,都会故意构造几个“错误写法”来测试它会不会如期报错。比如往MyPick<Todo, 'nonexistent'>上传一个不存在的键,如果编辑器没有报错,说明我的约束没有写到位;如果报错信息和我预期的一致,说明这个工具类型真的在替你守护类型安全。这个习惯我保留到了后面的所有类型挑战里,它比单纯追求“通过用例”更能帮你理解一个类型工具的边界在哪里。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进