如果大家比较熟悉 TypeScript 开发,那肯定遇到过下面这种情况:
interface Options {hostName: string;port: number;}function validateOptions (options: Options) {Object.keys(options).forEach(key => {if (options[key] == null) {// @error {w=12} Expression of type 'string' can't be used to index type 'Options'.throw new Error(`Missing option ${key}`);}});}
乍看之下,这个错误完全是莫名其妙。我们完全可以使用 options 键来访问 options ,但 TypeScript 为什么还非要报错?
只要通过将 Object.keys(options) 强制转换为 (keyof typeof options)[],就能有效规避这个问题。
const keys = Object.keys(options) as (keyof typeof options)[];keys.forEach(key => {if (options[key] == null) {throw new Error(`Missing option ${key}`);}});
既然方法如此简单,TypeScript 为什么不出手解决?
查看 Object.keys 的类型定义,我们会看到如下内容:
// typescript/lib/lib.es5.d.tsinterface Object {keys(o: object): string[];}
这个类型定义非常简单,即接收 object 并返回 string[]。
也就是说,我们可以轻松让这个方法接收通用参数 T 并返回 (keyof T)[]。
class Object {keys<T extends object>(o: T): (keyof T)[];}
只要这样定义 Object.keys,就不会触发任何类型错误。
所以大家第一反应肯定是把 Object.keys 定义成这样,可 TypeScript 偏没有这么做。究其原因,与 TypeScript 的结构类型系统有关。
只要发现有属性丢失或者类型错误,TypeScript 就会马上报错。
function saveUser(user: { name: string, age: number }) {}const user1 = { name: "Alex", age: 25 };saveUser(user1); // OK!const user2 = { name: "Sarah" };saveUser(user2);// @error {w=5} Property 'age' is missing in type { name: string }.const user3 = { name: "John", age: '34' };saveUser(user3);// @error {w=5} Types of property 'age' are incompatible.\n Type 'string' is not assignable to type 'number'.
但如果我们提交的是无关的属性,那 TypeScript 不会做出任何反应。
function saveUser(user: { name: string, age: number }) {}const user = { name: "Alex", age: 25, city: "Reykjavík" };saveUser(user); // Not a type error
这就是结构类型系统的设计思路。如果 A 是 B 的超集(即 A 包含 B 中的所有属性),则可以将类型 A 分配给 B。
但如果 A 是 B 的真超集(即 A 中的属性比 B 更多),则:
A 可被分配给 B,但
B 不可被分配给 A。
注意:除了需要是属性的超集之外,具体属性类型也有影响。
以上讲解可能过于抽象,下面咱们从更具体的例子入手。
type A = { foo: number, bar: number };type B = { foo: number };const a1: A = { foo: 1, bar: 2 };const b1: B = { foo: 3 };const b2: B = a1;const a2: A = b1;// @error {w=2} Property 'bar' is missing in type 'B' but required in type 'A'.
其中的关键点在于,当我们面对一个类型 T 的对象时,也就相当于确定该对象至少包含 T 中的属性。
但我们并不知道 T 是否切实存在,所以 Object.keys 的类型机制才会是现在这个样子。下面我们再举一例。
假设我们正为某项 Web 服务创建一个端点,此端点会创建一个新用户。我们的现有 User 接口如下所示:
interface User {name: string;password: string;}
在将用户保存至数据库之前,我们先要确保这里的 User 对象有效。
name 必须为非空。
password 必须有至少 6 个字符。
因此,我们创建一个 validators 对象,其中包含 User 中每个属性的验证函数:
const validators = {name: (name: string) => name.length < 1? "Name must not be empty": "",password: (password: string) => password.length < 6? "Password must be at least 6 characters": "",};
之后我们再创建一个 validateUser 函数,通过这些验证器运行 User 对象:
function validateUser(user: User) {// Pass user object through the validators}
因为我们需要验证 user 中的各个属性,所以可以用 Object.keys 迭代 user 中的属性:
function validateUser(user: User) {let error = "";for (const key of Object.keys(user)) {const validate = validators[key];error ||= validate(user[key]);}return error;}
注意:这部分代码片段中存在类型错误,但我们暂不细究,稍后再进一步讨论。
这种方法的问题是,user 用户可能包含 validators 中不存在的属性。
interface User {name: string;password: string;}function validateUser(user: User) {}const user = {name: 'Alex',password: '1234',email: "alex@example.com",};validateUser(user); // OK!
即使 User 并没有指定 email 属性,由于结构类型允许提交无关属性,所以这里也不会触发类型错误。
在运行时中,email 属性会导致 validator 处于 undefined 状态,并在调用时抛出错误。
for (const key of Object.keys(user)) {const validate = validators[key];error ||= validate(user[key]);// @error {w=8} TypeError: 'validate' is not a function.}
好在 TypeScript 会在这段代码实际运行之前,就提醒我们其中存在类型错误。
for (const key of Object.keys(user)) {const validate = validators[key];// @error {w=15} Expression of type 'string' can't be used to index type '{ name: ..., password: ... }'.error ||= validate(user[key]);// @error {w=9} Expression of type 'string' can't be used to index type 'User'.}
现在相信大家能够理解 Object.keys 的类型为什么要这样设计了。其实质,就是强制提醒我们对象中可能包含类型系统无法识别的属性。
有了以上结构类型和潜在问题的知识储备,下面我们一起来看如何发挥结构类型的设计优势。
结构类型带来了很大的灵活性,允许接口准确声明自己需要的属性。下面还是通过实例加以演示。
设想我们编写了一个函数以解析 KeyboardEvent,并返回触发器的快捷方式。
function getKeyboardShortcut(e: KeyboardEvent) {if (e.key === "s" && e.metaKey) {return "save";}if (e.key === "o" && e.metaKey) {return "open";}return null;}
为了确保代码按预期工作,下面我们编写一些单元测试:
expect(getKeyboardShortcut({ key: "s", metaKey: true })).toEqual("save");expect(getKeyboardShortcut({ key: "o", metaKey: true })).toEqual("open");expect(getKeyboardShortcut({ key: "s", metaKey: false })).toEqual(null);
看起来不错,但 TypeScript 会报错:
getKeyboardShortcut({ key: "s", metaKey: true });// @error {w=27,shiftLeft=48} Type '{ key: string; metaKey: true; }' is missing the following properties from type 'KeyboardEvent': altKey, charCode, code, ctrlKey, and 37 more.
一个个指定 37 个额外属性根本就不现实,我们当然可以将参数转换为 KeyboardEvent 来解决这个问题:
getKeyboardShortcut({ key: "s", metaKey: true } as KeyboardEvent);
但这可能遮盖掉其他可能发生的类型错误。
所以正确的思路,应该是更新 getKeyboardShortcut 以确保仅从事件中声明它需要的属性。
interface KeyboardShortcutEvent {key: string;metaKey: boolean;}function getKeyboardShortcut(e: KeyboardShortcutEvent) {}
现在测试代码需要满足的条件大大收窄,处理起来自然更加轻松。
函数与全局 KeyboardEvent 类型的耦合也更少,且能够在更多上下文中使用。换言之,灵活性得到显著提升。
而这一切之所以可行,显然要归功于结构类型。作为后者的超集,KeyboardEvent 可被分配给 KeyboardShortcutEvent,这就回避了 KeyboardEvent 中的 37 个不相关属性。
window.addEventListener("keydown", (e: KeyboardEvent) => {const shortcut = getKeyboardShortcut(e); // This is OK!if (shortcut) {execShortcut(shortcut);}});
原文链接:
https://alexharri.com/blog/typescript-structural-typing
声明:本文为 InfoQ 翻译,未经许可禁止转载。
7 月 21 日 ArchSummit 架构师峰会(深圳站)即将开幕,演讲嘉宾均为国内外知名企业专家,分享架构设计、数据库、AGI 通用人工智能应用等话题,帮助国内的技术人了解行业的技术动态,解决自己在工作中遇到的技术和业务问题。咨询购票可联系票务经理 18514549229(微信同手机号)。
点击「阅读原文」可查看大会完整日程。
文章引用微信公众号"前端之巅",如有侵权,请联系管理员删除!