QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

评论

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

融云五大场景化能力,全面赋能金融行业数智化转型

融云 RongCloud

金融 IM RTC 数智化 通讯

低代码是“银弹”,还是“毒弹”?

高端章鱼哥

低代码

用低代码平台开发应用

互联网工科生

软件开发 低代码 JNPF

Farrago for Mac(最佳的音频编辑软件)v2.0.7注册版

iMac小白

ChatGPT被曝测试新功能:学习所有历史聊天,还能重置记忆、“阅后即焚”

Openlab_cosmoplat

链路追踪,助您洞悉数据联动分析的奥秘

观测云

Profile 链路跟踪 应用性能监控

鸿蒙千帆起,美团、百度、去哪儿旅行等60余企业参与鸿蒙实训营

最新动态

探索当代AI人工智能云服务技术的强者

不在线第一只蜗牛

AI 亚马逊云 AWS Lightsail 云服务 微软云

软件测试/人工智能|教你如何用ChatGPT写一份漂亮的测试计划

霍格沃兹测试开发学社

Pixologic ZBrush 2024中文版 支持m1

彩云

3D数字雕刻软件 Pixologic ZBrush

做开发这么多年,我终于悟了

秃头小帅oi

APP安全加固怎么做?加固技术、加固方法、加固方案

OpenAI 正在杀死创业公司?他们是这么回答的...

代码生成器研究

Aiseesoft Mac Video Converter Ultimate for Mac(好用的视频转换工具)免激活版

iMac小白

软件测试/人工智能|如何使用ChatGPT写出满意的测试方案

霍格沃兹测试开发学社

BatchPhoto pro for Mac(mac图片编辑工具)v5.0.1激活版

iMac小白

转变关键财务流程,实现企业数字化升级

智达方通

数字化升级 预算管理 财务流程

Permute 3 for mac(万能音视频转换器) 3.11.1中文激活版

mac

苹果mac Windows软件 媒体格式转换软件 Permute

随着大模型中数据局限问题的严峻化,向量数据库应运而生

苏沐

数据 向量 向量数据库 亚马逊大模型

OmniGraffle Pro for mac(最好用的思维导图工具)v7.22.3中文正式版

iMac小白

探索计算机视觉技术的应用前景

EquatorCoco

计算机视觉 视觉开发 计算机科学与技术

云计算自动化测试系统环境自动识别实现方案

lklmyy

云计算 自动化测试 pytest

Acon Digital Acoustica Premium Edition for mac(mac数字音频编辑器)v7.5.5激活版

iMac小白

HarmonyOS 实战项目

快乐非自愿限量之名

鸿蒙 HarmonyOS 鸿蒙开发

SRE方法论之监控设计

不思jo

#运维 #监控

最新demo版|如何0-1开发支付宝小程序之前期准备篇(一)

盐焗代码虾

小程序 支付宝小程序 教程分享

云原生专栏丨基于服务网格的企业级灰度发布技术

inBuilder低代码平台

云原生

云端力量:探索亚马逊云服务器,提升您的业务无限可能

熬夜磕代码、

亚马逊

软件测试/人工智能|测试报告很难写,ChatGPT来帮你

霍格沃兹测试开发学社

Parallels Desktop 19 for Mac虚拟机 19.1.0一键激活版 支持M1

彩云

Parallels Desktop 虚拟机 Parallels Desktop

通过 Canal 将 MySQL 数据实时同步到 Easysearch

极限实验室

MySQL canal easysearch

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