写点什么

TypeScript 中的 Object.keys 类型为什么非要这么设计?

作者 | alexharri

  • 2023-08-28
    北京
  • 本文字数:3597 字

    阅读完需:约 12 分钟

TypeScript中的Object.keys类型为什么非要这么设计?

如果大家比较熟悉 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 更多),则:

  • 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 的类型机制才会是现在这个样子。下面我们再举一例。

 

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 的精髓

2023-08-28 08:003104

评论

发布
暂无评论
发现更多内容

Dubbo 直连提供者

青年IT男

dubbo

稻盛和夫的管理绝活:阿米巴经营模式

石云升

团队建设 28天写作 职场经验 管理经验 4月日更

区块链电子发票的多维创新与变革效应

CECBC

电子税务

一念三年,心机流转|靠谱点评

无量靠谱

使用 rocketmq-spring-boot-starter 来配置、发送和消费 RocketMQ 消息

阿里巴巴云原生

容器 开发者 云原生 中间件 API

Leveldb解读之四:Compaction

Jowin

leveldb

Dubbo 集群容错

青年IT男

dubbo

聪明人的训练(二十五)

Changing Lin

4月日更

截图软件Snipaste超好用的贴图功能,在别的软件也能拥有 | 窗口置顶工具

彭宏豪95

效率 工具 windows 4月日更 窗口置顶

如何运用“区块链”,让档案数据管理更安全

CECBC

数据安全

几个快速提升工作效率的小工具(Listary等)分享一下(强烈建议收藏)

码农飞哥

Python基础语法和数据类型最全总结

华为云开发者联盟

Python

Redis 持久化

叶佳欣

redis持久化

把黄金圣衣交给圣斗士:HDC.Cloud 2021的硬核春天

脑极体

2021最全网页视频下载攻略 【建议收藏】

科技猫

软件 工具 网站 分享 视频处理

2.7w字!Java基础面试题/知识点总结!(2021 最新版)

JavaGuide

Java 面试

postman的url编码问题

箭上有毒

4月日更

源中瑞区块链BaaS平台搭建,为企业开发应用提供技术支撑

源中瑞-龙先生

区块链 源中瑞 Baas

ISO/IEC 5055:软件代码质量的标尺

华为云开发者联盟

软件 代码质量 源代码 ISO/IEC 软件质量模型

Dubbo 四大角色

青年IT男

dubbo

Dubbo 服务配置

青年IT男

dubbo

Dubbo 服务启动检查

青年IT男

dubbo

Dubbo 入门简介

青年IT男

dubbo

KubeFed集群联邦

ilinux

Kubernetes 容器 云原生 kubernetes联邦

MySQL事务机制是如何实现的?

冰河

MySQL 事务 Binlog Redo Log Undo Log

小白也能看懂的操作系统之内存

程序猿阿星

操作系统 内存 内存管理 内存优化

区块链链接能源:到底是乌托邦愿景还是未来蓝图?

CECBC

能源

What's new in dubbo-go v1.5.6

阿里巴巴云原生

开发者 云原生 dubbo 中间件 Go 语言

身边的最优化问题

PeterOne

人工智能 算法

Dubbo 服务注册与发现

青年IT男

dubbo

Dubbo快速入门

青年IT男

dubbo

TypeScript中的Object.keys类型为什么非要这么设计?_工程化_InfoQ精选文章