Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

大家好,我是 ConardLi

不知道大家平时使用 TypeScript 有没有遇到过这种情况:

interface Options {  hostName: string;  port: number;}function validateOptions (options: Options) {  Object.keys(options).forEach(key => {    if (options[key] == null) {        // ❌ Expression of type 'string' can't be used to index type 'Options'.      throw new Error(`${key} 不存在!`);    }  });}

这个错误看起来毫无意义,我们使用 optionskey 来访问 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(`${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 中的结构类型

当一个对象的属性丢失或类型错误时,TypeScript 会抛出错误。

function saveUser(user: { name: string, age: number }) {}const user1 = { name: "ConardLi", age: 17 };saveUser(user1); // ✅ OK!const user2 = { name: "Sarah" };saveUser(user2);         // ❌ Property 'age' is missing in type { name: string }.const user3 = { name: "John", age: '17' };saveUser(user3);         // ❌ Types of property 'age' are incompatible.         // ❌ Type 'string' is not assignable to type 'number'.

但是,如果我们多提供了一个额外的属性,TypeScript 就不会报错。

function saveUser(user: { name: string, age: number }) {}const user = { name: "ConardLi", age: 17, city: "BeiJing" };saveUser(user); // ✅ Not a type error

这就是是结构类型系统中的预期表现:如果 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;      //  ❌ Property 'bar' is missing in type 'B' but required in type 'A'.

这里面的关键点就是:当我们拥有一个 T 类型的对象时,我们所知道的关于这个对象的一切就是它至少包含 T 中的所有属性。

但是我们并不知道这个对象是不是和 T 类型完全相同,这就是为什么 Object.keys 的类型定义是这样的。

下面我们再来看一个例子:

Object.keys 的不安全使用

假设我们现在要做一个登陆界面,现在我们定义了一个 User 类型:

interface User {  name: string;  password: string;}

在将用户信息提交到服务端之前,我们要确保用户对象有效,所以我们会在前端做个简单的验证:

  • 名称必须非空。

  • 密码必须至少 6 个字符。

所以我们再创建一个 validators 对象,其中包含 User 中每个属性的验证函数:

const validators = {  name: (name: string) => name.length < 1    ? "Name 不能为空!"    : "",  password: (password: string) => password.length < 6    ? "Password 至少 6 位!"    : "",};

然后,我们创建一个 validateUser 函数,来使用 validators 对用户信息进行验证:

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: 'ConardLi',  password: '17171717',  email: "17171717@17.com",};validateUser(user); // OK!

即使 User 没有声明 email 属性,也不会抛出类型错误,因为结构类型是允许提供无关属性的。

但是 ,在运行时,email 属性将导致 validator 未定义,并在调用时抛出错误。

for (const key of Object.keys(user)) {  const validate = validators[key];  error ||= validate(user[key]);            // ❌ 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 的类型是这样设计的原因。

它强迫让我们知道:对象中是可能包含类型系统不知道的属性的。

好,上面其实我们知道了结构类型,以及它的小坑点,下面让我们看看在开发中怎么去利用它呢?

利用结构类型

结构类型给我们提供了很大的灵活性,它允许接口准确地声明它们需要的属性。

下面我们再来举一个例子。

假如我们编写了一个函数,来解析键盘事件并返回要触发的快捷方式。

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 });                    // ❌ Type '{ key: string; metaKey: true; }' is missing the following properties from type 'KeyboardEvent': altKey, charCode, code, ctrlKey, and 37 more.

啊?我们就写个单元测试需要把 KeyboardEvent 的 37 个属性都补全吗?这不可能。

我们可以通过将参数转换为 KeyboardEvent 来解决这个问题:

getKeyboardShortcut({ key: "s", metaKey: true } as KeyboardEvent);

但是,这可能会把其他可能会发生的类型错误也掩盖掉。

相反,我们可以只更新一下函数入参的属性,只从事件中声明它所必需的属性。

interface KeyboardShortcutEvent {  key: string;  metaKey: boolean;}function getKeyboardShortcut(e: KeyboardShortcutEvent) {}

现在,测试代码只需满足这个更简单的接口了。

我们的函数与全局 KeyboardEvent 类型的耦合也比较少,并且可以在更多上下文中使用了,现在更加灵活了。

这就得益于结构类型,KeyboardEvent 可以分配给 KeyboardShortcutEvent,就是因为 KeyboardEventKeyboardShortcutEvent 的超集。

window.addEventListener("keydown", (e: KeyboardEvent) => {  const shortcut = getKeyboardShortcut(e); // This is OK!  if (shortcut) {    execShortcut(shortcut);  }});

这个结构类型系统的设计是不是挺好的呢?大家有什么想法?欢迎大家在评论区留言。

最后

参考:

如果你想加入高质量前端交流群,或者你有任何其他事情想和我交流也可以添加我的个人微信 ConardLi

点赞在看是最大的支持⬇️❤️⬇️