如果大家比较熟悉 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.ts
interface 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 中的结构类型
只要发现有属性丢失或者类型错误,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 更多),则:
注意:除了需要是属性的超集之外,具体属性类型也有影响。
以上讲解可能过于抽象,下面咱们从更具体的例子入手。
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 的类型机制才会是现在这个样子。下面我们再举一例。
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
相关阅读:
TypeScript 与 JavaScript:你应该知道的区别
“TypeScript 不值得!”前端框架 Svelte 作者宣布重构代码,反向迁移到 JavaScript 引争议
Typescript- 类型检测和变量的定义
理论 + 实践:从原型链到继承模式,掌握 Object 的精髓
评论