TypeScript条件类型与infer构建类型安全的fetch

本篇依然来自于我们的 《前端周刊》 项目!

由团队成员 嘿嘿 翻译,他的文章风格稳健而清晰,注重结构与逻辑的严谨性,善于用简洁的语言将复杂技术拆解成易于理解的知识点~

欢迎大家 进群 与他探讨 TypeScript 条件类型的最新趋势,并持续追踪全球前端领域的最新动态!

原文地址:Building a typed fetch in TypeScript with conditional types and infer

最近,我在处理基于 OpenAPI schema 生成的 API 请求和响应类型时,发现使用 TypeScript 的条件类型,我的 fetch 逻辑能够根据调用的路径和 HTTP 方法自动推断出适当的参数和响应类型,这很酷。我试图在文档中查找更多信息,但发现它们实际上并没有深入讲解这种类型推断,所以我想我应该更广泛地分享一下这个很酷的特性。

TypeScript 复制代码
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : never;

我将向你展示 TypeScript 如何通过 extendsinfer 关键字的强大功能,巧妙地从复杂的嵌套结构中提取类型。

我为本文整理了一个简化的 API schema,你可以在 GitHub 上探索它 --- 以及所有代码,都是实时可运行的。通过从仓库中按 . 键启动 GitHub Codespace,这样你就能看到所有内容的实际运行效果。

请注意,本文假设你对 TypeScript 中的泛型有一些基础了解!

条件类型让编译器做决策

将条件类型想象成 TypeScript 中的小 if 语句:"如果满足这个条件,就是这种 类型,否则就是那种类型。" 语法看起来很像 JavaScript 的三元运算符,它在诸如为可能根据传入内容返回不同返回类型的函数编写类型时很有用:

TypeScript 复制代码
type myFn = (arg: SomeType | OtherType) => arg extends SomeType ? string : number;

你可能在类型声明中熟悉 extends,我们会说"这个类型 X 继承自另一个类型 Y"。当在类型声明内部使用时,extends 不像一个语句,而是一个问题。"schema[P][M] 是否继承自这个对象?"

如果 extends 条件为真,TypeScript 返回问号 ? 后面的类型。如果为假,则返回冒号 : 后面的类型。

infer 允许我们提取类型

如果你查看 TypeScript 条件类型文档infer 只有几个小例子。例如,这里是他们建议用它来获取函数返回类型的方法:

TypeScript 复制代码
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : never;

type Num = GetReturnType<() => number>; // type Num = number

type Str = GetReturnType<(x: string) => string>; // type Str = string

type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>; // type Bools = boolean[]

我认为这并没有充分展现这个小关键字的强大之处。你实际上是在对 TypeScript 编译器说," 告诉我这是什么类型,我不想把整个类型都写出来"。当我们处理许多具有相同属性不同组合的不同类型时,这变得真的很有用。

开始之前:使用 VSCode 调试 TypeScript 类型的技巧

我经常希望 TypeScript 有一个类型调试器,这样你就可以在编译时准确地看到编译器在特定行上的想法,就像你可以用运行时值做的那样。在没有这个的情况下,我倾向于通过将类型分配给类型变量并在 VSCode 中悬停它们来调试/测试我的类型,看看 IntelliSense 如何评估它。这就是我在整篇文章中测试我们创建的类型时要做的。

TypeScript 复制代码
type MyComplexType<T,Q> =  {....}

type test = MyComplexType<"test value", "another test value">

我们的示例:构建观鸟网站

这里是场景:我们正在用 TypeScript 构建一个观鸟网站,它显示不同鸟类的信息,让你跟踪你发现的鸟类目击记录。有一个前端和一个用于获取数据的 API。

我们希望确保我们发出的请求包含正确的参数,然后我们希望能够以类型安全的方式使用我们获得的返回数据。为此,我们需要一些 API 类型。

我们有一个 API 规范可以用来构建,它准确告诉我们请求需要哪些参数,以及响应返回什么。出于本文的目的,这是一个非常简化的 API 规范。

我们运行了一个脚本来自动为该规范生成类型,生成的接口给了我们所有的路径、它们允许的方法,以及这些方法的参数和响应类型。

我们生成的 API 类型看起来像这样:

TypeScript 复制代码
export interface schema {
    '/birds': {
        get: {
            parameters: {
                query?: {
                    /**
                     * @description 按类型过滤鸟类
                     * @example waders
                     */
                    type?: string;
                    /**
                     * @description 列出特定栖息地的鸟类
                     * @example wetlands
                     */
                    habitat?: string;
                    /** @description 按颜色过滤鸟类 */
                    colour?: string;
                };
            };
            response: {
                content: {
                    /**
                     * Format: int64
                     * @example 10
                     */
                    id?: number;
                    /** @example Avocet */
                    name?: string;
                    /**
                     * @description 鸟的种类
                     * @example wader
                     */
                    type?: string;
                    /**
                     * @description 鸟类可以找到的地方
                     * @example [
                     *       "lakes",
                     *       "wetlands"
                     *     ]
                     */
                    habitats?: string[];
                    /** @description 鸟的颜色 */
                    colours?: string[];
                    /** @description 需要注意的任何独特特征 */
                    distinctiveFeatures?: string;
                    /** @description 翼展,以厘米为单位 */
                    wingspan?: number;
                    /** @description 图片的 URL */
                    image?: string;
                };
            };
        };
    };
    '/users': {
        post: {
            requestBody?: {
                content: {
                    /** @description 用户姓名 */
                    name?: string;
                    /** @description 用户邮箱地址 */
                    email?: string;
                    /** @description 用户最喜爱的鸟类(鸟类 ID) */
                    favouriteBird?: number[];
                };
            };
            response: {
                content: {
                    /** @example 21 */
                    id?: number;
                    /** @example Billie Sandpiper */
                    name?: string;
                    /** @example [12, 14] */
                    favouriteBirds?: number[];
                    /** @example billie@example.com */
                    email?: string;
                };
            };
        };
    };
}

要进行 API 调用本身,我们通常会使用原生的 fetch 函数。但这本身不会对我们创建的那些类型做任何事情。

TypeScript 复制代码
const rsp = await fetch('<https://api.example.com/bird/12>');
const data = await rsp.json(); // 此时不知道这是什么类型

fetch 不是一个泛型函数,所以我们不能传递类型注解来说明请求和响应的形状:

TypeScript 复制代码
await fetch<GetBirdRequest, GetBirdResponse>(....) // 你不能这样做

我们可以 告诉 fetch 它返回什么类型,但这非常冗长和不便(而且每次使用时都必须这样做):

TypeScript 复制代码
import schema from './api'
[...]
const rsp = await fetch('<https://api.example.com/birds/12>')
const data = await rsp.json() as schema['/bird/{birdId}']['get']['response']['content']

当然,我们可以提取这些类型,这样就不必如此冗长了,但是我们需要为每个想要的响应类型都这样做。

TypeScript 复制代码
type ListBirdsResponse = paths['/birds']['get']['response']['content'];
type GetBirdResponse = paths['/bird/{birdId}']['get']['response']['content'];
type AddSightingResponse = paths['/users/{userId}/sightings']['post']['response']['content'];
// 我已经觉得无聊了。

所以,如果我们想要在参数和响应中更容易的类型安全,我们需要构建某种围绕 fetch 的包装器来编码类型信息。由于我们有 API schema 的生成类型,我们可以让 TypeScript 告诉我们 类型,而不是每次都指定它们:它可以仅从 API 调用的路径和方法推断参数和响应类型。

我们将构建一个 createFetcher 函数,它接受一个路径和一个 HTTP 方法,并返回另一个函数,该函数确切地知道它期望什么参数,以及你得到什么返回类型。

我们称返回函数的函数为高阶函数。

TypeScript 复制代码
function createFetcher(path, method) {
    return async (params) => { // ... }
}

const getBird = createFetcher('/birds/{birdId}', 'get')
const data = await getBird({ path: { birdId: 12 } })

getBird 传递错误的参数 --- 或不够的参数 --- 将导致一些友好的错误:

TypeScript 复制代码
const data = await getBird({});

// Argument of type '{}' is not assignable to parameter of type 'Params<"/birds/{birdId}", "get">'.
// Property 'path' is missing in type '{}' but required in type '{ path: { birdId: number; }; }'.ts(2345)

悬停在 data 上将给我们响应类型:

TypeScript 复制代码
const data: {
    id?: number;
    name?: string;
    type?: string;
    habitats?: string[];
    colours?: string[];
    distinctiveFeatures?: string;
    wingspan?: number;
    image?: string;
}[];

构建泛型 createFetcher 函数

为了给我们这些请求和响应的正确类型,我们的 createFetcher 函数需要知道:

  • API schema 的样子

  • 我们正在调用该 API schema 中的哪个路径

  • 我们使用的是哪种方法(因为参数和响应可能在不同方法间有所不同)。

第一个很容易:我们可以在定义此函数的文件中导入 API schema 的类型,所以它会在作用域内。

对于第二和第三点,我们将向函数传递两个类型参数 ,一个用于路径 P(例如 /birds/{birdId}),一个用于方法 M(例如 get)。

TypeScript 复制代码
import { schema } from './api'

function createFetcher<P, M>(path: P, method: M) {
    return async (params) => { // TODO }
}

在 API schema 中包含着嵌套对象,使用路径作为键,然后方法作为这些嵌套对象的键:

TypeScript 复制代码
export interface schema {
        '/birds/{birdId}': {
            get: {
            parameters: {
              query?: {
              [...]

我们需要明确告诉 TypeScript 第一个类型参数 P(上面例子中的 /birds/{birdId})是我们 schema 的键,第二个类型参数 M(例子中的 get)是 schema[P] 的键。我们用 extends 来做这件事。

TypeScript 复制代码
import { schema } from './api'

function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params) => { // TODO }
}

注意:这不是条件类型,即使我们使用了 extends 关键字:它实际上是一个带有约束的类型参数。这个上下文中的 extends 如果你有任何面向对象编程经验,或者在 JS 中使用过类,可能会很熟悉:T extends X 意味着"类型 T 包含类型 X 的属性",约束限制了可以传入的类型种类。

上面的代码告诉 TypeScript 它应该能够对 我们给它的类型参数做什么样的事情 。例如,我们可以使用它们在我们的 schema 中查找嵌套类型,因为我们特别告诉 TypeScript P 指的是 schema 的键,M 指的是 schema[P] 对象的键。

此时我们没有给它特定的 路径或方法,而是说"我们在这里传入的任何路径都应该有效,因为它是 keyof schema"。如果我们尝试将此类型用于 schema 中不存在的东西,编译器会给我们一个错误。

TypeScript 复制代码
const wrong = createFetcher('cheese', 'get');
// error: Argument of type '"cheese"' is not assignable to parameter of type 'keyof schema'.ts(2345)

获取数据

我们的 createFetcher 函数将在底层使用 fetch。(注意:出于本文的目的,我们不担心错误处理,但通常我会在这里用 try/catch 包装 fetch 并确保任何错误都得到适当处理。)

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async params => {
        const baseUrl = '<https://api.example.com>';
        const fetchUrl = new URL(path, baseUrl);

        const options: RequestInit = {
            method: method as string
        };
        const data = await fetch(fetchUrl, options);
        return await data.json();
    };
}

注意这里我们需要显式地将我们的 method 参数(M extends keyof schema[P])转换为字符串,以便将其分配给 RequestInit 中的 method 属性(它期望一个字符串)。即使我们知道它是一个字符串,但从技术上讲,M 可能不是字符串(我们知道它不会是,但 TypeScript 编译器不知道。)

这个 fetch 还不能工作 ,因为我们正在用路径的模板字符串版本(例如 /birds/{birdId})进行获取。我们需要从请求的参数中获取路径值,但首先我们需要知道我们期望什么参数(以及我们是否期望任何参数)。

解包参数

现在我们需要定义我们的 Params 类型,它属于我们返回的部分应用函数中进行实际获取的参数:

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params?: Params) => { ... // 获取逻辑 }
}

// type Params = ???

我们将此参数标记为可选的,因为某些请求可能具有可以完全省略的参数(例如带有可选搜索查询参数的"列出所有鸟类"请求)。

就像 createFetcher 函数一样,Params 类型也需要知道它正在处理什么 schema、路径和方法,以便告诉我们该路径和方法组合的正确参数。所以这将具有与 createFetcher 完全相同的类型参数。

TypeScript 复制代码
type Params<P extends keyof schema, M extends keyof schema[P]> = {};

这里是生成的 schema 类型中我们参数的几个例子:

TypeScript 复制代码
"/birds/{birdId}": {
    get: {
    parameters: {
      path: {
        birdId: number
      }
    }
    ...
  }
},
"/users": {
  post: {
    requestBody?: {
      content: {
        name?: string
        email?: string
        favouriteBirdIds?: number[]
      }
    }
    ...
  }
}

所有路径 + 方法组合都在我们的规范中定义了 parameters,但它们可能实际上没有在请求中使用。在 POST /users 的例子中,query?: never 意味着没有查询,你可以完全省略该字段。

requestBody.content 可以包含任意键/值对,就像 POST /users 那样,或者在没有主体的请求中,requestBody 字段应该完全省略(就像 GET /birds/{birdId} 那样,它在生成的类型中有 requestBody?: never)。

我们希望确保在我们不同的请求中捕获各种参数组合,而不让你必须显式提供不需要的字段。我们想要自动做到这一点。

使用 extends 和 infer 推断类型

这里是重点:我们现在将以依赖于 PM 的值的方式从 API 规范中获取各种参数的类型。

首先,我们必须用 extends 检查我们的类型是否包含我们正在寻找的字段,然后我们使用 infer 关键字从条件类型中获取那些嵌套类型。然后我们可以在从条件返回的分支的类型中使用这些推断的类型

TypeScript 复制代码
type Params<P extends keyof schema, M extends keyof schema[P]> = schema[P][M] extends {
    parameters: {
        query?: infer Q;
        path?: infer PP;
        requestBody?: { content: infer RB };
    };
}
    ? {
          query: Q;
          path: PP;
          requestBody: RB;
      }
    : never;

注意我们的 requestBody 实际上是一个嵌套对象 --- 你可以遍历对象直到找到要推断的属性,这很巧妙。

最后,如果条件 匹配,我们返回 never,因为条件赋值时你总是必须有两个分支,即使你知道你的类型将始终匹配你正在评估的条件,第二种情况永远不会发生。

never 说:"这不应该发生"。与 undefined(意味着"这个值还没有初始化")不同,never 告诉 TypeScript 编译器这里永远不会有值,如果有,就出了问题。如果我们尝试给类型为 never 的东西赋值,编译器会对我们大喊大叫。

我们可以通过为特定路径和方法声明类型来测试它:

TypeScript 复制代码
type GetBirdParams = Params<'/birds/{birdId}', 'get'>;

在 VS Code 中悬停在这上面它会给我们:

TypeScript 复制代码
type GetBirdParams = {
    query: undefined;
    path: {
        birdId: number;
    };
    requestBody: undefined;
};

现在让我们将这个 Params 类型放入 createFetcher 中,看看我们的编译器说什么。

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params?: Params<P,M>) => { ... }
}

const getBird = createFetcher("/birds/{birdId}", "get")
getBird({ path: { birdId: 1 }})

不幸的是,最后一行给我们一个类型错误:

TypeScript 复制代码
Argument of type '{ path: { birdId: number; }; }' is not assignable to parameter of type '{ query: undefined; path: { birdId: number; }; requestBody: undefined; }'.

Type '{ path: { birdId: number; }; }' is missing the following properties from type '{ query: undefined; path: { birdId: number; }; requestBody: undefined; }': query, requestBody

嗯。path 字段是正确的,但似乎我们仍然必须显式提供 queryrequestBody 字段来让 TypeScript 满意。它们是 undefined 因为 /birds/{birdId} 在 schema 中没有查询字符串或请求主体,但我们在 Params 中将这些字段声明为非可选的。

我们将修复它,使这些参数中不需要的任何字段都用 ? 标记为可选 。我们可以通过检查哪些参数是未定义的,并在最终结果中使它们可选来做到这一点。我将为我们的 Params 创建一个包装器类型来做这件事,并将原始 Params 重命名为 ParamsInner

TypeScript 复制代码
type ParamsInner<P extends keyof schema, M extends keyof schema[P]> = schema[P][M] extends {
    parameters: {
        query?: infer Q;
        path?: infer PP;
    };
    requestBody?: { content: infer RB };
}
    ? {
          query: Q;
          path: PP;
          requestBody: RB;
      }
    : never;

export type Params<P extends keyof schema, M extends keyof schema[P]> = (unknown extends ParamsInner<P, M>['query'] ? { query?: never } : { query?: ParamsInner<P, M>['query'] }) & (unknown extends ParamsInner<P, M>['path'] ? { path?: never } : { path: ParamsInner<P, M>['path'] }) & (unknown extends ParamsInner<P, M>['requestBody'] ? { requestBody?: never } : { requestBody: ParamsInner<P, M>['requestBody'] });

这里我们有三个条件类型的联合 。如果一个字段返回为 unknown(因为我们的路径 + 方法组合没有它),我们用类型 never(因为它永远不应该存在)将其标记为可选

如果我们提取的参数上存在 requestBodypath,我们将使必要的字段成为必需的。query 即使存在也应该仍然是可选的,因为查询参数可能是可选的(例如可选搜索过滤器)。

这里有一个陷阱:确保使用 unknown extends 而不是 extends unknown[unknown](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown)[any](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown)的类型安全等价物。因为它在类型层次结构的顶部每个类型都继承自 unknown,但 unknown 只能继承自它自己。 如果你颠倒这个 extends 语句的顺序,你最终会在这种情况下得到所有返回为 never 的东西(例如 ParamsInner<P, M>['query'] extends unknown 将始终为真,即使它有值)。

现在让我们确保我们的类型看起来正确:

TypeScript 复制代码
const getBird = createFetcher('/birds/{birdId}', 'get');
getBird({ path: { birdId: 1 } });

万岁,没有错误!让我们做一些错误的事情来确保,通过完全删除路径参数。

TypeScript 复制代码
getBird({});

这应该出错:

TypeScript 复制代码
Argument of type '{}' is not assignable to parameter of type 'Params<"/birds/{birdId}", "get">'.

Property 'path' is missing in type '{}' but required in type '{ path: { birdId: number; }; }'.

太棒了!现在参数类型正确了,让我们确保我们在 createFetcher 中正确处理参数。

替换路径中的模板参数

首先,我们将检查我们传入的路径中 {花括号} 中的任何路径参数,并使用正则表达式用适当的参数值替换它们。我们可以用 /{([^}])}/ 匹配一对花括号内的任何不是右花括号的字符 ,使用捕获组(在圆括号中)捕获内部的字符,这样我们可以再次使用它。match() 给我们一个 {paramName} 形式的匹配数组,我们可以遍历它,去掉花括号,并在提供的路径中对每个实例执行字符串替换。

同样,我们需要在这里将 path 转换为字符串,因为 TypeScript 需要确保它绝对是字符串,然后我们才能对它进行字符串操作。

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params?: Params<P, M>) => {
        /** 新部分 **/
        const pathParams = path.match(/{([^}]+)}/g);
        let pathWithValues = path as string;
        if (params?.path) {
            pathParams?.forEach(param => {
                const paramName = param.replace(/{|}/g, '');
                pathWithValues = pathWithValues.replace(param, params?.path?.[paramName]);
                console.log({ paramName, path: pathWithValues });
            });
        }
        /** 新部分结束 **/

        const fetchUrl = new URL(pathWithValues, '<https://api.example.com>');
        const options: RequestInit = {
            method: method as string
        };
        const data = await fetch(fetchUrl, options);
        return await data.json();
    };
}

附加查询字符串

然后我们可以将 query 添加到 URL(如果有的话),通过将每个查询键/值对附加到我们 URL 的搜索参数:

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params?: Params<P, M>) => {
        const templateParams = path.match(/{([^}]+)}/g);
        let realPath = path as string;
        if (params?.path) {
            templateParams?.forEach(templateParam => {
                const paramName = templateParam.replace(/{|}/g, '');
                realPath = realPath.replace(templateParam, params?.path?.[paramName]);
            });
        }

        const baseUrl = '<http://api.example.com>';
        const fetchUrl = new URL(realPath, baseUrl);

        /** 新部分 **/
        if (params?.query) {
            Object.entries(params.query).forEach(([key, value]) => {
                fetchUrl.searchParams.append(key, value as string);
            });
        }

        /** 新部分结束 **/

        const baseUrl = '<http://api.example.com>';
        const options: RequestInit = {
            method: method as string
        };
        const data = await fetch(fetchUrl, options);
        return await data.json();
    };
}

发送请求主体

最后,我们添加请求主体(如果有的话)。这需要与适当的内容类型(出于本文的目的是 JSON)一起放在 fetch 选项中。

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(basePath: P, method: M) {
    return async (params?: Params<P, M>) => {
        const templateParams = path.match(/{([^}]+)}/g);
        let realPath = path as string;
        if (params?.path) {
            templateParams?.forEach(templateParam => {
                const paramName = templateParam.replace(/{|}/g, '');
                realPath = realPath.replace(templateParam, params?.path?.[paramName]);
            });
        }

        const baseUrl = '<http://api.example.com>';
        const fetchUrl = new URL(realPath, baseUrl);

        if (params?.query) {
            Object.entries(params.query).forEach(([key, value]) => {
                fetchUrl.searchParams.append(key, value as string);
            });
        }

        const options: RequestInit = {
            method: method as string
        };

        /** 新部分 **/
        if (params?.requestBody) {
            options.body = JSON.stringify(params.requestBody);
            options.headers = {
                'Content-Type': 'application/json'
            };
        }
        /** 新部分结束 **/

        const baseUrl = '<http://api.example.com>';
        const data = await fetch(fetchUrl, options);
        return await data.json();
    };
}

整理响应类型

现在我们知道需要传入什么,是时候定义函数最终返回什么了。现在,我们的响应类型是 any,这是 Response.json() 的默认返回类型。

所有请求都有响应,即使是空的。在本文中,为了简单起见,我们每个路径和方法组合只有一个可能的响应。

例如,我们的 /bird/{birdId} 路径将返回有关鸟类的信息:

TypeScript 复制代码
[...]
"/birds/birdId": {
    get: {
        [...]
        responseBody: {
            content: {
				id?: number
				/** @example Avocet */
				name?: string
				/**
				* @description 鸟的种类
				* @example wader
				*/
				type?: string
				/**
				* @description 鸟类可以找到的地方
				* @example [
				*       "lakes",
				*       "wetlands"
				*     ]
				*/
				habitats?: string[]
				colours?: string[]
				/** @description 需要注意的任何独特特征 */
				distinctiveFeatures?: string
				/** @description 翼展,以厘米为单位 */
				wingspan?: number
				/** @description 图片的 URL */
				image?: string
            }
        }
    }
}

就像我们之前做的一样,我们将使用 extendsinfer 提取这些类型,将相同的类型参数传递给我们的 Response 类型(我称之为 ResponseT 来区别于原生的 Response)。

responseBody 一样,response 包含一个包含我们想要的数据的嵌套对象;但 response 将始终存在,永远不是可选的 ,所以我们不需要担心这一点。我们可以再次使用 infer 提取类型:

TypeScript 复制代码
type ResponseT<P extends keyof schema, M extends keyof schema[M]> = schema[P][M] extends { response: { content: infer R } } ? R : never;

因为 Response.json() 返回 Promise<any>,我们需要将返回类型转换ResponseT<P, M>

TypeScript 复制代码
[...]
return fetch(fetchUrl, options).then(
	(res) => res.json() as ResponseT<P, M>
)

完整的类型化 fetch 函数

TypeScript 复制代码
function createFetcher<P extends keyof schema, M extends keyof schema[P]>(path: P, method: M) {
    return async (params?: Params<P, M>) => {
        const templateParams = path.match(/{([^}]+)}/g);
        let realPath = path as string;
        if (params?.path) {
            templateParams?.forEach(templateParam => {
                const paramName = templateParam.replace(/{|}/g, '');
                realPath = realPath.replace(templateParam, params?.path?.[paramName]);
            });
        }

        const baseUrl = '<http://api.example.com>';
        const fetchUrl = new URL(realPath, baseUrl);

        if (params?.query) {
            Object.entries(params.query).forEach(([key, value]) => {
                fetchUrl.searchParams.append(key, value as string);
            });
        }

        const options: RequestInit = {
            method: method as string
        };

        if (params?.requestBody) {
            options.body = JSON.stringify(params.requestBody);
            options.headers = {
                'Content-Type': 'application/json'
            };
        }
        return fetch(fetchUrl, options).then(res => res.json() as ResponseT<P, M>);
    };
}

让我们看看编译器对它的看法:

TypeScript 复制代码
const getBird = createFetcher('/birds/{birdId}', 'get');

记住,我们的 createFetcher 函数是一个高阶函数,它返回异步函数。所以我们不需要在这里 await 任何东西 - 只有在我们实际进行 API 调用时才需要。

悬停在 getBird 上给我们:

TypeScript 复制代码
(params?.requestBody) {
init
const getBird: (params?: Params<"/birds/{birdId}", "get"> | undefined) => Promise<{
  id?: number;
  name?: string;
  type?: string;
  habitats?: string[];
  colours?: string[];
  distinctiveFeatures?: string;
  wingspan?: number;
  image?: string;
}>

看起来不错!让我们试试另一个:

TypeScript 复制代码
const addSighting = createFetcher('/users/{userId}/sightings', 'post');

得到:

TypeScript 复制代码
const addSighting: (params?: Params<'/users/{userId}/sightings', 'post'> | undefined) => Promise<{
    id?: number;
    birdId?: number;
    timestamp?: string;
    lat?: number;
    long?: number;
    notes?: string;
}>;

当我们想要实际进行 API 调用时,我们可以像调用任何异步函数一样调用这些函数中的每一个:

TypeScript 复制代码
const listBirds = createFetcher('/birds', 'get');
const allBirds = await listBirds();

const getBird = createFetcher('/birds/{birdId}', 'get');
const bird = await getBird({ path: { birdId: 12 } });

const addSighting = createFetcher('/users/{userId}/sightings', 'post');
const mySighting = await addSighting({
    path: { userId: 1 },
    requestBody: {
        birdId: 226,
        timestamp: '2025-06-04T13:00:00Z',
        lat: 51.4870924,
        long: 0.2228486,
        notes: '我听到它在树上唱歌!'
    }
});

现在你已经掌握了本文所写的内容 --- 如何使用条件类型和 infer 创建一个完全类型化的 fetch 函数。

相关推荐
Hashan7 分钟前
微信小程序:扁平化的无限级树
前端·微信小程序·uni-app
胡gh7 分钟前
面试官问你如何实现居中?别慌,这里可有的是东西bb
前端·css·面试
遂心_7 分钟前
深入响应式原理:从 Object.defineProperty 到 Proxy 的进化之路
前端·javascript
PineappleCoder7 分钟前
图解 setTimeout + 循环:var 共享变量 vs let 独立绑定
前端·javascript
季禮祥7 分钟前
你的Vite应用需要@vitejs/plugin-legacy构建Legacy包吗
前端·javascript·vite
小徐_23338 分钟前
uni-app 无法实现全局 Toast?这个方法做到了!
前端·uni-app
言兴9 分钟前
面试题之React组件通信:从基础到高级实践
前端·javascript·面试
用户36668239157319 分钟前
day06 - 表单
前端
归于尽9 分钟前
Web Workers:前端多线程解决方案
前端
阿慧勇闯大前端12 分钟前
前端面试:你说一下JS中作用域是什么东西?
前端·面试