为什么Object.keys不能正确地类型推导
原文:Why doesn’t TypeScript properly type Object.keys
如果你写过一段时间的TypeScript,你可能会遇到这种情况
1 | interface Options { |
这个错误似乎毫无意义。我们使用options
的键来访问options
。为什么TypeScript不能解决这个问题呢?
我们可以通过 Object.keys(options)
到 (keyof typeof options)[]
的强制转换来规避这个问题
1 | const keys = Object.keys(options) as (keyof typeof options)[]; |
但为什么这是一个问题呢?
如果我们访问Object.keys
的类型定义,能看到:
1 | // typescript/lib/lib.es5.d.ts |
类型定义非常简单。接受object
并返回string[]
。
让这个方法接受一个泛型参数T
并返回(keyof T)[]
是非常容易的。
1 | class Object { |
如果Object.keys
是这样定义的,我们就不会遇到类型错误。
看起来我们应该将Object
定义这样。但是TypeScript有很好的理由不这样做。原因与TypeScript的结构类型系统有关。
TypeScript中的结构类型
当属性丢失或类型错误时,TypeScript会发出警告。
1 | function saveUser(user: { name: string, age: number }) {} |
然而,如果我们提供了多余的属性,TypeScript也不会报错。
1 | function saveUser(user: { name: string, age: number }) {} |
这是结构类型系统的预期行为,如果A
是B
的超集,类型A
可赋值给B
(即A
包含B
中的所有属性)。
然而,如果A是B的真超集(即A比B有更多的属性),那么A可以赋值给B,但是B不能赋给A。
这些都很抽象,所以让我们来看一个具体的例子。
1 | type A = { foo: number, bar: number }; |
关键的结论是,当我们有一个T
类型的对象时,我们所知道的关于这个对象的一切就是它至少包含了T
中的属性。
我们不知道我们是否有确切的T
,这就是为什么Object.keys
的类型会是这样。让我们举个例子。
不安全地使用Object.keys
假设我们正在为创建一个新用户注册的界面。我们有一个现有的User
接口,看起来像这样:
1 | interface User { |
在将用户保存到数据库之前,我们要确保用户对象是有效的。
name
不能为空。password
至少为6个字符。
因此,我们创建一个validators
对象,其中包含User
中的每个属性的验证函数
1 | const validators = { |
然后,我们创建一个 validateUser
函数,通过这些验证器运行 User
对象
1 | function validateUser(user: User) { |
因为我们想要验证user
中的每个属性,所以可以使用Object.keys
遍历user
中的属性
1 | function validateUser(user: User) { |
注意:在这个代码块中有类型错误,我现在隐藏。我们稍后再谈。
这种方法的问题是,user
对象可能包含validators
中不存在的属性。
1 | interface User { |
即使User
没有指定email
属性,这也不是类型错误,因为结构类型允许提供无关的属性。
在运行时,email
属性将导致validators
未定义,并在调用时抛出错误。
1 | for (const key of Object.keys(user)) { |
幸运的是,TypeScript在这段代码有机会运行之前就发出了类型错误。
1 | for (const key of Object.keys(user)) { |
现在我们知道为什么Object.keys
的类型是这样了。它迫使我们承认对象可能包含类型系统不知道的属性。
有了关于结构类型及其缺陷的新知识,让我们来看看如何有效地利用结构类型。
利用结构类型
结构类型提供了很大的灵活性。它允许接口准确地声明它们所需要的属性。我想通过一个例子来说明这一点。
假设我们编写了一个函数,解析KeyboardEvent
并返回要触发的快捷方式。
1 | function getKeyboardShortcut(e: KeyboardEvent) { |
为了确保代码按预期工作,我们编写了一些单元测试
1 | expect(getKeyboardShortcut({ key: "s", metaKey: true })) |
看起来不错,但是TypeScript会报错
1 | getKeyboardShortcut({ key: "s", metaKey: true }); |
指定所有37个附加属性将会非常杂乱,所以这是不可能的。
我们可以通过将参数强制转换为KeyboardEvent
来解决这个问题
1 | getKeyboardShortcut({ key: "s", metaKey: true } as KeyboardEvent); |
但这可能会掩盖可能发生的其他类型错误。
相反,我们可以更新getKeyboardShortcut
,只声明它需要从事件中获取的属性。
1 | interface KeyboardShortcutEvent { |
测试代码现在只需要满足这个更小的接口,这使得它更加简洁。
我们的函数与全局KeyboardEvent
类型的耦合也更少,可以在更多的上下文中使用。现在灵活多了。
这是不会报错的,因为结构类型KeyboardEvent
可以分配给KeyboardShortcutEvent
,因为它是一个超集,尽管KeyboardEvent
有37个不相关的属性。
1 | window.addEventListener("keydown", (e: KeyboardEvent) => { |
Evan Martin在一篇精彩的文章中探讨了这个想法:界面通常属于用户。我强烈建议大家读一读!它改变了我编写和思考TypeScript代码的方式。