写点什么

如何编写健壮的 TypeScript 库?

  • 2020-11-29
  • 本文字数:3402 字

    阅读完需:约 11 分钟

如何编写健壮的TypeScript库?

当你用 TypeScript 编写库时,你通常不知道这个库最终将如何被使用。即使你警告潜在用户,你编写这个库只是针对 TypeScript 用户,你还是可能会在某个时刻拥有 JavaScript 用户——或者是因为他们不顾你的警告而使用这个库,或者是他们因为传递性依赖而使用这个库。这有一个非常重要的后果:你必须将这个库设计成任何语言的开发者都可以使用!


其主要部分是函数定义和函数体。如果你针对一个纯 TypeScript 读者编写,那么你只需定义函数类型并信任编译器处理其它事情。如果你针对一个纯 JavaScrpit 读者编写,那么你需要记录那些类型,但在函数中将实际的类型设为unknown 并检查调用方传递的内容。


例如,给定如下代码——


interface Person {  age: number;  name?: string;}

function describe(person: Person): string { let name = person.name ?? 'someone'; return `${name} is ${person.age} years old!`;}
复制代码


一个 JS 用户可能用任何东西来调用 describe 函数。正确写法:


describe({ name: "chris" })
复制代码


灾难性的错误 


describe("potato");
复制代码


当然还有我们最喜欢犯的 JS 错误:


describe(undefined);
复制代码


你的库的 JS 用户并不是故意这么做的。恰恰相反!在任何足够大的系统中,很容易将错误的参数传递给系统中的某个函数。这通常是一个很难避免的错误,比如在一个点上做了修改,许多其它地方需要更新,但漏掉了一个点。故意的 JS 开发者会把坏数据发送到你设计精美的 TS API 中。


如果你针对一个纯TypeScript读者编写,那么你只需定义函数类型并信任编译器处理其它事情


我故意不提 TypeScript 编译非常严格,从一个与 JavaScript 没有区别的级别到几乎任何人可能想到的严格级别。这意味着,即使是 TypeScript 调用者也应该像 JavaScript 调用者一样被对待:众所周知,他们到处乱扔any ,忽略了事实上可能是null 或 undefined 的地方。返回上面的示例代码:


interface Person {  age: number;  name?: string;}

function describe(person: Person): string { let name = person.name ?? 'someone'; return `${name} is ${person.age} years old!`;}
复制代码


在没有启用严格标识的情况下,TypeScript 用户可以如下调用describe


function cueTheSobbing(data: any) {  describe(data);}

cueTheSobbing({ breakfastOf: ["eggs", "waffles"] });
复制代码


或者这样:


describe(null);
复制代码


或者这样:


describe({ age: null })
复制代码


也就是说:JS 调用者大部分会出错的方式,TS 调用者在关闭严格性设置的情况下也会出错。(你可以在这个TypeScript试验区查看所有这些“作品”!)这意味着故意的 TypeScript 用户也会用坏数据调用你的库。而且由于他们依赖其它库,这很可能不是他们的错误,因为这种问题可能发生在依赖图中的任何地方。


因此,如果问题是我们不能信任数据,那么我们应该怎么做?一个选项是使函数的所有参数实际为unknown ,并用JSDoc指定它该如何。然而,那样会使我们失去大量 TS 提供的能力。当与函数交互时,我们即使在内部也不会得到补全或类型错误,更不用说我们的库的用户。但是正如我们刚刚看到的,我们也不能依赖类型定义来提供函数内部的安全性。不过,我们可以将这几种方法结合起来:指定类型定义,并将传入的数据视为实际上的unknown。这确实带来了运行时开销——我们稍后将围绕这个权衡进行详细讨论。现在,我们可以先看看如何检查类型。


首先,我们会像实际上会从调用者得到真正未知的数据来编写我们的代码,因为我们已经确定了这正是我们可能得到的。一旦我们完成了对unknown 数据的校验,我们就能够将它替换为Person,而且所有东西都应该继续工作,但是现在我们可以保证它对任何抛给它的数据都能够工作。


function describe(person: unknown): string {  let name = person.name ?? 'someone';  return `${name} is ${person.age} years old`;}
复制代码


这里有类型错误(试验区),因为这里的person 类型可能是undefined"potato" 或者任何其它类型。我们可以使用 TypeScript 的类型缩小的概念来保证安全。然而,从unknown 缩小到特定的对象类型有点儿奇怪,因为如果你简单地检查是否typeof somethingUnknown === 'object' ,这会将类型缩小到{} ,这意味着它不会包含任何我们可能需要的类型。我们会先定义一个isObject 辅助函数,它会为我们提供正确的语义:


function isObject(  maybeObj: unknown): maybeObj is Record<string | number | symbol, unknown> {  return typeof maybeObj === 'object' && maybeObj !== null;}
复制代码

 

我们还需要一种方法来检查这个对象有没有指定的属性。如果in 运算符能以这种方式工作就太好了,但不幸的是,它没有这样工作。我们也可以内联这样做,但是每次都需要类型转换。我们可以称之为has ,类似于Object.hasOwnProperty 方法。由于这还需要检查isObject 返回的类型集——在 JS 中索引一个对象的所有合法类型——我们这里会将其提取到一个新的Key 类型。


这个has 辅助函数的返回类型告诉类型系统,如果主体为 true,传入的项目有其原始类型而且它包含我们要检查的属性。


type Key = string | number | symbol;

function has<K extends Key, T>( key: K, t: T): t is T & Record<K, unknown> { return key in t;}
复制代码


现在我们可以将它们组合成一个类型保护器,来检查给定对象是否是一个 person:


function isPerson(value: unknown): value is Person {  return (    isObject(value) &&    has('age', value) && typeof value.age === 'number' &&    (has('name', value) ? typeof value.name === 'string' : true)  )}
复制代码


现在,我们可以将所有这些集合到我们函数顶部的一个简单的检查中,如果它不合法的话抛出一个有用的错误。(你可以在这个试验区查看这个作品。)


function describe(person: unknown): string {  if (!isPerson(person)) {    throw new Error('`describe` requires you to pass a `Person`');  }

let name = person.name ?? 'someone'; return `${name} is ${person.age} years old`;}
复制代码


既然我们已经有了这个功能,我们可以将这里的person 类型更新为Person 来让 TypeScript 用户有更好的体验。


function describe(person: Person): string {  if (!isPerson(person)) {    throw new Error(      `'describe' takes a 'Person', but you passed ${JSON.stringify(person)}`    );  }

let name = person.name ?? 'someone'; return `${name} is ${person.age} years old`;}
复制代码


TypeScript 支持在条件不包含断言函数时抛出的这种模式泛化,这非常有用。我们可以编写如下格式:


function assert(  predicate: unknown,  message: string): asserts predicate {  if (!pred) {    throw new Error(message);  }}
复制代码


现在我们的函数变得更简单:


function describe(person: Person): string {  assert(    isPerson(person),    `'describe' takes a 'Person', but you passed ${JSON.stringify(person)}`  );

let name = person.name ?? 'someone'; return `${name} is ${person.age} years old`;}
复制代码


到目前为止,一直都还不错!我们现在保证,无论谁调用describe ,无论是从 JS,还是从松散类型的 TS,或是从其它完全不同的语言,它都会做“正确”的事情,在出错时向调用者提供一个可操作的错误。然而,根据我们的限制,这种运行时校验会开销过大而不可行。在一个浏览器中,我们通过网络发送的额外代码积累起来:需要下载更多东西,也需要解析更多东西,这都会减慢我们的 app。在任何环境中,每次与describe 函数交互时都会进行额外的运行时检查。


一种选项是利用一些编译智能来在开发期间而不是在生产构建中提供这些检查。Babel 允许你将给定函数转变成 noops,使得它们不完全没有开销,但开销非常小。例如,Ember CLI 提供了一个 Babel 插件将 Ember 的assert 函数(其类型与我在上面定义的assert 几乎等同)转变成 no-ops。你可以将它与任何可以消除无用代码的工具结合起来,以删除所有没有用到的辅助函数!


这种方案的缺点是,生产环境的错误的错误消息会比较糟糕,并且更难以调试。优点是,在生产环境中,你将上传更少的代码且运行时开销更少。为了使依赖这种assert 片段的代码工作,终端用户需要将它与任何具有良好的端到端测试覆盖的功能、UI 组件等相结合。但是不管怎样,这都是正确的:类型和测试消除了不同类型的 bugs,最好结合使用!


原文链接


Writing Robust TypeScript Libraries


2020-11-29 17:211411

评论

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

生活意义的缺失是当代都市人的必然窘境

mtfelix

28天写作 生活的意义

五分钟就能学会,一辈子难以精通(18/28)

赵新龙

28天写作

无快不破,在本地 docker 运行 IDEA 里面的项目?

秦怀杂货店

Java 后端 springboot dcoker

避免创建不必要的对象

李子捌

28天写作 12月日更

跟着动画学Go数据结构之冒泡排序

宇宙之一粟

golang 数据结构 算法 12月日更

起、承、转、合全赋能:华为应用生态为开发者带来了什么?

脑极体

Prometheus Exporter (三十)IPMI Exporter

耳东@Erdong

Prometheus 28天写作 exporter 12月日更 IPMI

围棋

圣迪

AI 数学 围棋 中国象棋 解空间

RocksDB 相关资料

Joseph295

存储 RocksDB LSM树 KV存储引擎

反序列化漏洞原理详解

网络安全学海

网络安全 信息安全 渗透测试 WEB安全 安全漏洞

父母的陪伴对孩子长大非常重要

Tiger

28天写作

架构训练营模块二作业

沈益飞

架构师训练营 4 期

[架构实战营] 模块二作业

Geek_0ed632

架构实战营

架构实战 模块二作业

mj4ever

架构实战

17《重学JAVA》--JAVA异常(一)

杨鹏Geek

Java 25 周年 28天写作 12月日更

一个简单的socket小工具

为自己带盐

socket dotnet 28天写作 12月日更

CSS之选择器(十)<label> 和 <input>

Augus

CSS 12月日更

架构实战营模块二作业

曾竞超

架构实战营 「架构实战营」

下班之后的生活

卢卡多多

28天写作 12月日更

云原生应用的建设之路

巨子嘉

云原生

公理设计:由奇怪海战引发的软件设计思考

程序员历小冰

设计模式 28天写作 12月日更

网络安全2.0主动防御体系有哪些新思路?

喀拉峻

网络安全 安全 渗透测试 渗透

营销思维:新消费品牌如何做营销

石云升

营销 28天写作 新消费 12月日更

面向WEB开发人员的Docker(八):将 Docker 镜像推送到仓库

devpoint

Docker 12月日更

明道云APaaS实践将GTD方法落地

明道云

为什么要“除夕”,原来是内存爆了

悟空聊架构

JVM 内存 28天写作 悟空聊架构 12月日更

语音信号处理4:语音信号的产生之语音的发音器官

轻口味

28天写作 12月日更

给弟弟的信第16封|人为什么会抑郁

大菠萝

28天写作

第二周作业

lv

IM会话阅读回执

superman

IM 已读未读 阅读回执

48 K8S之Ingress控制器部署

穿过生命散发芬芳

k8s 28天写作 12月日更

如何编写健壮的TypeScript库?_软件工程_Chris Krycho_InfoQ精选文章