如何编写健壮的 TypeScript 库?

changcongying 2020-10-30

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

如何编写健壮的 TypeScript 库?

其主要部分是函数定义和函数体。如果你针对一个纯 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 用户也会用坏数据调用你的库。而且由于他们依赖其它库,这很可能不是他们的错误,因为这种问题可能发生在依赖图中的任何地方。

因此,如果问题是我们不能信任数据,那么我们应该怎么做?一个选项是使函数的所有参数实际为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`; 
 
} 

相关推荐