写点什么

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:003940

评论

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

大咖说|极客邦CEO霍太稳对话阿里云存储负责人吴结生:我的数据存储20年(无限生长篇)

大咖说

阿里巴巴 InfoQ 存储线

星巴克涨价引热议!中国现磨咖啡市场目前到底如何?

易观分析

星巴克涨价 中国咖啡市场

数据同步与缓存一致性问题

Mars

布隆过滤器 缓存一致性

面对锁等待难题,数仓如何实现问题的秒级定位和分析

华为云开发者联盟

sql GaussDB(DWS) 锁等待 分布式死锁

SQL学习(持续更新)

阿丞

事务 索引 sql

敏捷开发中的「史诗」到底是什么?

LigaAI

项目管理 敏捷开发 史诗

冬奥高质量炫技,Get同款“魔法”:图像处理算法 | 赠书

博文视点Broadview

第十四节:SpringBoot使用JdbcTemplate访问操作数据库基本用法

入门小站

springboot

在线时序流程图制作工具

入门小站

再见了,我的散装研发管理平台;再见了,4台ECS!

阿里云云效

阿里云 DevOps 云原生 研发 敏捷研发

英特尔2022年投资者大会:以软件解锁更大增长机遇

科技新消息

会声会影2022语音转文字功能怎么用

懒得勤快

2022年1月视频行业用户洞察:假期影响下活跃用户开始回升

易观分析

移动视频 视频app

终极指南:企业级云原生 PaaS 平台日志分析架构全面解析

尔达Erda

微服务 云原生 PaaS 云原生应用

Apache ShardingSphere 5.1.0 正式发布

SphereEx

数据库 开源社区 SphereEx Apache ShardingSphere

晟盾科技加入龙蜥社区,共建开源新生态

OpenAnolis小助手

Linux 开源

《数字经济全景白皮书》数字冰雪篇 重磅发布

易观分析

数字经济 冬奥会

记录一些Oracle操作命令

wong

oracle

祝贺!首届龙蜥社区年度突出贡献奖揭晓,马上查看

OpenAnolis小助手

Linux 开源 互联网 社群运营

华为云企业级Redis揭秘第16期:超越开源Redis的ACID"真"事务

华为云开发者联盟

redis 事务 ACID GaussDB(for Redis) 开源Redis

巧用EasyRecovery监控硬盘!为你的电脑保驾护航!

淋雨

EasyRecovery 数据恢复’

黑客马拉松(Hackathon)是什么?

Speedoooo

黑客马拉松 黑客松

会声会影2022美颜功能介绍 教你玩转视频美颜

懒得勤快

15 行代码在 wangEditor v5 使用数学公式

CRMEB

会声会影2022重磅发布!会声会影2022全新功能详解

懒得勤快

感谢认可!一封又一封的感谢信

郑州埃文科技

IP地址 网络空间 地图测绘

哪里可以查到网络安全等级测评与检测评估机构目录?

行云管家

网络安全 等保 等级测评

网络安全kali渗透学习 web渗透入门 如何进行NESSUS漏洞检测

学神来啦

【C语言】二维数组

謓泽

C语言 2月月更 二维数组

外包学生管理系统的架构设计

yhjhero

#架构训练营

看SparkSQL如何支撑企业级数仓

字节跳动数据平台

hive 字节跳动 Sparksql 数仓

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