TypeScript 类型系统中一个巧妙的设计

JavaScript/前端
235
0
0
2024-01-31
标签   TypeScript

不知道大家平时使用 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.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: "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);
  }
});

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

最后

参考:

  • https://en.wikipedia.org/wiki/Structural_type_system
  • https://alexharri.com/blog/typescript-structural-typing
  • https://neugierig.org/software/blog/2019/11/interface-pattern.html