如何使用 TypeScript 积累类型:输入所有可能的 fetch() 结果

当我和我的团队开始用 TypeScript 和 Svelte 重写我们的应用程序(之前使用的是我们都讨厌的 JavaScript 和 React)时,我遇到了一个问题:

我如何安全地输入 HTTP 响应的所有可能的主体?

这让你想起什么了吗?如果没有,你很可能就是“其中之一”,呵呵。让我们暂时离题一下,以便更好地理解这幅图。

为什么这个地区似乎尚未被探索过

似乎没有人关心 HTTP 响应的“所有可能的主体”,因为我找不到任何为此制作的东西(好吧,也许是“ts-fetch”)。让我快速解释一下为什么会这样。

没人关心,因为人们要么:

  • 只关心快乐路径:HTTP 状态代码为 2xx 时的响应主体。
  • 人们在其他地方手动输入它。
  • 对于#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 请求失败:”。失败的是请求本身,而不是获取操作。