资讯详情

资讯详情

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

type-challenges 这个项目我陆陆续续刷过好几轮。说实话前面十几道基础题里第 4 题“实现 Pick”是最值得停下来认真琢磨的一道。它表面上只让你做一个MyPickT, K实际上把 TypeScript 类型系统里最常用的四块积木全串起来了keyof、泛型约束、索引访问、映射类型。很多初学者在这道题卡住不是因为语法不会写而是不清楚“用类型编程”和“写业务类型”是两种完全不同的思维方式。这篇文章就围绕这道题展开。我会先拆解题面在训练什么再把四个核心知识点逐个讲透接着从报错版本一路改到通过最后整理几个我在实际刷题过程中踩过的边界坑。无论你是刚接触 TypeScript 的小白还是已经在业务里写过不少泛型的开发者这一篇都能让你在类型工具的理解上更扎实一些。1. 先说清楚这道 type-challenges 第 4 题到底在训练什么1.1 题面其实只要求你做一件事打开第 4 题的模板默认情况下你会看到这样一行type MyPickT, K any;题目期望你把这个any改成真正的实现让MyPickT, K的行为和 TypeScript 内置的PickT, K一致从类型T中挑选出若干属性属性名由K指定最终生成一个新对象类型。举个例子假设有一个Todo接口interface Todo { title: string; description: string; done: boolean; }当你写出MyPickTodo, title | done时它应该得到一个等价于{ title: string; done: boolean }的新类型。这不是运行时过滤对象字段而是纯粹在类型层面“裁剪”结构。它解决的问题也很直白很多时候你接收到的数据比实际需要的多你希望定义一个更精确的类型只暴露部分字段。为什么要自己实现一遍因为你在业务里写PickT, title时TypeScript 已经帮你把事情做完了。但内置类型不是黑魔法它背后就是一层简单的映射逻辑。把这层逻辑亲手拆开你才会真正理解为什么Pick需要K extends keyof T这个约束也才能在遇到“类型不满足约束”“无法用索引访问”这类报错时一眼看出问题出在哪。1.2 自己实现一遍比背答案有用在哪我见过不少人刷 type-challenges 是直接看答案的。看答案本身没问题但如果你只是把type MyPickT, K extends keyof T { [P in K]: T[P] };抄下来然后“哦”一声那就损失了这道题一大半价值。自己实现一遍你会发现几个很有意思的点第一你会被迫思考“为什么需要约束”。第一次写的时候很多人会写type MyPickT, 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 TK 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 MyPickT, 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 MyPickT, 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 MyPickTodo, title | done;把鼠标悬浮在TodoPreview上你会看到 TypeScript 展示出{ title: string; done: boolean }。这个结果和内置的PickTodo, title | done完全一致。事实上内置Pick的官方实现就是这一行没有任何额外逻辑。TypeScript 自带二十多个工具类型其中相当一部分就是类似这样一层映射就搞定的。3.3 用 Expect 和 Equal 理解项目自带的验证逻辑type-challenges 的题目和普通 LeetCode 最大的不同在于没有运行时的测试用例只有类型层面的断言。你会在题目文件里看到类似这样的代码type cases [ ExpectEqualMyPickTodo, title, { title: string }, ExpectEqualMyPickTodo, title | done, { title: string; done: boolean }, ];EqualA, B是一个类型层面的严格比较工具如果A和B在类型层面完全一致它就等于true否则等于false。ExpectT接收一个布尔结果如果传入的是true就通过传入false就报类型错误。所以在编辑器里看到红线不代表代码运行会出错而是代表你的类型实现和预期不一致。反过来所有Expect都没有红线就说明你的实现通过了验证。如果你想自己构造类似的测试不需要依赖项目里的那套复杂工具。最简单的方式是type A MyPickTodo, title; type B { title: string }; const check: A { title: hello }; // 如果能赋值就说明结构一致虽然这种方式不如Equal严格但用来快速验证思路已经足够。等你后面想做更严谨的检查再回头研究Equal内部是怎么用条件类型做深度比较的。4. 避坑经验几个我踩过的 Pick 边界坑4.1 高频报错与定位方法做这道题的过程中有几个报错几乎每个人都会遇到。我整理成一个速查表方便你对照定位报错信息出现原因解决方案Type K cannot be used to index type TK没有约束编译器无法确认K是T的键给K加上extends keyof TType P cannot be used to index type TK约束缺失遍历出来的P也不被信任检查泛型约束是否写完整Type keyof T does not satisfy the constraint ...使用场景不匹配比如把K约束成了字面量类型重新检查泛型参数的继承关系Expected 2 type arguments, but got 1调用MyPick时少传了泛型参数确认类型名后面是否写了两个参数定位这类问题有一个很实用的方法拆分验证。比如我先单独算type Keys keyof Todo;然后看看Keys到底是什么再把它传给MyPicktype Result MyPickTodo, Keys;如果这一步没问题说明问题出在你传入的具体K上可能是拼写错误可能是K里包含了T没有的键。把大问题拆成小问题逐个验证是类型调试里最朴素的技巧。4.2 最隐蔽的坑可选属性和索引签名这一节是我最想分享的。很多人在业务里用Pick用得顺手没意识到它有一个容易忽略的边界行为可选属性的?修饰符不会被保留。interface WithOptional { title?: string; } type PickedOptional MyPickWithOptional, 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 MyPickTodo, title | done;这个没什么问题P会逐个遍历两个键生成两个属性。但如果你传入一个空联合类型比如MyPickTodo, never结果会变成空对象类型{}。这在某些泛型推导场景下会引发赋值兼容问题需要留意。4.3 做完 Pick顺便打通后续一串类型题第 4 题的一个隐藏价值是它为你铺好了后面十几道题的基础。我刷到这里的时候明显感觉到很多所谓的“新题”其实是在Pick上做加减法。比如PartialT的实现思路是type MyPartialT { [P in keyof T]?: T[P] };它和Pick的区别只有两个遍历的键从“指定的K”变成了“全部的keyof T”同时加了一个?修饰符。再比如ReadonlyTtype MyReadonlyT { readonly [P in keyof T]: T[P] };同样只是加了一个readonly修饰符。RecordK, T则更简单type MyRecordK extends keyof any, T { [P in K]: T };它遍历指定的键但每个属性的值类型都直接取同一个T。当你把Pick彻底吃透之后再看这些题目就会产生“原来是同一套玩法”的感觉。这也是我推荐大家按顺序刷题的原因前几道基础题看似简单但它们把映射类型、泛型约束、索引访问这些最核心的语法揉进了你的肌肉记忆后面所有复杂类型都是在这几块积木上不断组合。最后再分享一个小技巧我每做完一个工具类型都会故意构造几个“错误写法”来测试它会不会如期报错。比如往MyPickTodo, nonexistent上传一个不存在的键如果编辑器没有报错说明我的约束没有写到位如果报错信息和我预期的一致说明这个工具类型真的在替你守护类型安全。这个习惯我保留到了后面的所有类型挑战里它比单纯追求“通过用例”更能帮你理解一个类型工具的边界在哪里。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →