如何使用 TypeScript 积累类型:输入所有可能的 fetch() 结果
当我和我的团队开始用 TypeScript 和 Svelte 重写我们的应用程序(之前使用的是我们都讨厌的 JavaScript 和 React)时,我遇到了一个问题:
我如何安全地输入 HTTP 响应的所有可能的主体?
这让你想起什么了吗?如果没有,你很可能就是“其中之一”,呵呵。让我们暂时离题一下,以便更好地理解这幅图。
为什么这个地区似乎尚未被探索过
似乎没有人关心 HTTP 响应的“所有可能的主体”,因为我找不到任何为此制作的东西(好吧,也许是“ts-fetch”)。让我快速解释一下为什么会这样。
没人关心,因为人们要么:
对于#1,我想说是的,开发人员(特别是缺乏经验的开发人员)忘记了 HTTP 请求可能会失败,并且失败响应中携带的信息很可能与常规响应完全不同。
对于问题 #2,让我们深入研究在流行的 NPM 包(如“ky”和“axios”)中发现的一个大问题。
数据获取包的问题
据我所知,人们喜欢 `ky` 或 `axios` 之类的包,因为它们的“功能”之一是它们会在 HTTP 状态代码不正常时抛出错误。从什么时候开始这是可以接受的?从来就不是。但显然人们并没有意识到这一点。人们很高兴并且满足于在非 OK 响应上收到错误。
我想象人们在捕获时会输入非 OK 正文。真是一团糟,真是代码异味!
这是一种代码异味,因为您实际上是在使用“try..catch”块作为分支语句,而“try..catch”并不是一个分支语句。
但即使你跟我争论分支在 `try..catch` 中自然发生,还有一个重要原因导致它仍然很糟糕:当抛出错误时,运行时需要展开调用堆栈。这比使用 `if` 或 `switch` 语句的常规分支在 CPU 周期方面要昂贵得多。
知道了这一点,你能证明仅仅因为滥用“try..catch”块而导致的性能损失是合理的吗?我说不。我想不出任何理由来解释为什么前端世界似乎对此非常满意。
既然我已经解释了我的理由,让我们回到正题上。
问题详述
HTTP 响应可能根据其状态代码携带不同的信息。例如,接收“PATCH”HTTP 请求的“todo”端点(如“api/todos/:id”)在响应状态代码为“200”时可能返回具有不同主体的响应,而在响应状态代码为“400”时则可能返回具有不同主体的响应。
让我们举例说明:
// For the 200 response, a copy of the updated object: { "id": 123, "text": "The updated text" } // For the 400 response, a list of validation errors: { "errors": [ "The updated text exceeds the maximum allowed number of characters." ] }
因此,考虑到这一点,我们回到问题陈述:我如何键入一个执行此“PATCH”请求的函数,其中 TypeScript 可以根据我编写代码时的 HTTP 状态代码告诉我正在处理哪个主体?答案:使用流畅的语法(构建器语法、链式语法)来积累类型。
构建解决方案
让我们首先定义一个基于先前类型的类型:
export type AccumType= T | NewT;
非常简单:给定类型“T”和“NewT”,将它们连接起来形成一个新类型。在“AccumType<>”中再次将此新类型用作“T”,然后可以累积另一个新类型。但是,手动完成这项工作并不好。让我们介绍解决方案的另一个关键部分:流畅的语法。
流畅的语法
给定一个 X 类的对象,其方法始终返回自身(或其自身的副本),可以一个接一个地链接方法调用。这就是流畅语法或链式语法。
让我们编写一个可以完成此任务的简单类:
export class NamePending{ accumulate () { return this as NamePending >; } } // Now you can use it like this: const x = new NamePending<{ a: number; }>(); // x is of type NamePending<{ a: number; }>. const y = x.accumulate<{ b: string; }> // y is of type NamePending<{ a: number; } | { b: string; }>.
尤里卡!我们已经成功地将流畅的语法和我们编写的类型结合起来,开始将数据类型累积为单一类型!
如果不明显,您可以继续练习,直到积累了所需的类型(`x.accumulate().accumulate()...`直到完成)。
这一切都很好,但是这种超级简单的类型并没有将 HTTP 状态代码与相应的主体类型绑定在一起。
完善现有资源
我们希望向 TypeScript 提供足够的信息,以便其类型缩小功能能够发挥作用。为此,让我们采取必要措施来获取与原始问题相关的代码(根据每个状态代码对 HTTP 响应的主体进行类型化)。
首先,重命名并改进“AccumType”。下面的代码显示了迭代的进展:
// Iteration 1. export type FetchResult= T | NewT; // Iteration 2. export type FetchResponse = { ok: boolean; status: TStatus; statusText: string; body: TBody }; export type FetchResult = T | FetchResponse ; //Makes sense to rename NewT to TBody.
此时,我意识到了一些事情:状态代码是有限的:我可以(并且确实)查找它们并为它们定义类型,并使用这些类型来限制类型参数“TStatus”:
// Iteration 3. export type OkStatusCode = 200 | 201 | 202 | ...; export type ClientErrorStatusCode = 400 | 401 | 403 | ...; export type ServerErrorStatusCode = 500 | 501 | 502 | ...; export type StatusCode = OkStatusCode | ClientErrorStatusCode | ServerErrorStatusCode; export type NonOkStatusCode = Exclude; export type FetchResponse = { ok: TStatus extends OkStatusCode ? true : false; status: TStatus; statusText: string; body: TBody }; export type FetchResult = T | FetchResponse ;
我们已经得到了一系列非常漂亮的类型:通过根据 `ok` 或 `status` 属性上的条件进行分支(编写 `if` 语句),TypeScript 的类型缩小功能将启动!如果你不相信,让我们编写类部分并尝试一下:
export class DrFetch{ for () { return this as DrFetch >; } }
试驾一下:
const x = new DrFetch<{}>(); // Ok, having to write an empty type is inconvenient. const y = x .for<200, { a: string; }>() .for<400, { errors: string[]; }>() ; /* y's type: DrFetch<{ ok: true; status: 200; statusText: string; body: { a: string; }; } | { ok: false; status: 400; statusText: string; body: { errors: string[]; }; } | {} // <-------- WHAT IS THIS!!!??? > */
现在应该清楚为什么类型缩小能够根据“status”属性的“ok”属性正确预测分支时主体的形状。
但是,有一个问题:类实例化时的初始类型,在上面的注释块中标记。我是这样解决的:
// Iteration 4. export type FetchResult= unknown extends T ? FetchResponse : T | FetchResponse ; export class DrFetch { ... }
这个小小的改变有效地排除了最初的输入,我们现在可以开始做事了!
现在我们可以编写如下代码,并且 Intellisense 将 100% 准确:
const fetcher = new DrFetch(); ... const response = await fetcher .for<200, ToDo>() .for<400, { errors: string[]; }>() .fetch('api/todos/123', { method: 'PATCH' }) ; if (response.status === 200) { // You'll have full TypeScript support on the properties of the ToDo object // in the body property. response.body.id; } else { // You'll have full TypeScript support here, too: response.body.errors; }
查询“ok”属性时,类型缩小也会起作用。
如果您没有注意到,我们可以通过不抛出错误来编写更好的代码。根据我的专业经验,“axios”是错误的,“ky”是错误的,任何其他执行相同操作的获取助手都是错误的。
结论
TypeScript 确实很有趣。通过结合 TypeScript 和流畅语法,我们可以积累所需的类型,这样我们就可以从第一天开始编写更准确、更清晰的代码,而不必一遍又一遍地调试。这种技术已被证明是成功的,任何人都可以尝试。安装 dr-fetch 并试用它:
npm i dr-fetch
更复杂的软件包
我还创建了 wj-config,这是一个旨在彻底消除过时的 .env 文件和“dotenv”的包。这个包也使用了这里教的 TypeScript 技巧,但它使用“&”而不是“|”来连接类型。如果你想尝试一下,请安装“v3.0.0-beta.1”。不过,类型要复杂得多。在“wj-config”之后制作“dr-fetch”是小菜一碟。
有趣的东西:那里有什么
让我们看看与 fetch 相关的包中存在的一些错误。
同构获取
您可以在 README 中看到以下内容:
fetch('//offline-news-api.herokuapp.com/stories') .then(function(response) { if (response.status >= 400) { throw new Error("Bad response from server"); } return response.json(); }) .then(function(stories) { console.log(stories); });
`“服务器响应错误”`?不。`“服务器说您的请求错误”`。是的,抛出部分本身就很糟糕。
ts-fetch
这个想法是对的,但不幸的是只能输入 OK 与非 OK 响应(最多 2 种类型)。
肯塔基州
我最批评的软件包之一显示了这个例子:
import ky from 'ky'; const json = await ky.post('https://example.com', {json: {foo: true}}).json(); console.log(json); //=> `{data: '🦄'}`
**非常初级**的开发人员会这样写:这只是快乐之路。根据其 README,等效性如下:
class HTTPError extends Error {} const response = await fetch('https://example.com', { method: 'POST', body: JSON.stringify({foo: true}), headers: { 'content-type': 'application/json' } }); if (!response.ok) { throw new HTTPError(`Fetch error: ${response.statusText}`); } const json = await response.json(); console.log(json); //=> `{data: '🦄'}`
抛出部分太糟糕了:为什么要分支到抛出,以迫使你稍后捕获?这对我来说毫无意义。错误中的文本也具有误导性:这不是“获取错误”。获取成功。你得到了回应,不是吗?你只是不喜欢它……因为这不是快乐的路径。更好的措辞应该是“HTTP 请求失败:”。失败的是请求本身,而不是获取操作。