【翻译】玩转 TypeScript 泛型

0 阅读14分钟

原文链接:frontendmasters.com/blog/fun-wi…

作者:Adam Rackis

泛型是 TypeScript 中一项极其强大的特性。关于 TypeScript(尤其是泛型)的内容层出不穷,而本文将有所不同,会对泛型进行更深入的探讨。

这不会是一篇泛泛而谈的泛型入门教程(双关语无意)。相反,我们将实现一个非常、非常小众的使用场景,在此过程中涵盖泛型的一些高级用法、条件类型以及其他实用特性。

泛型与条件类型快速回顾

让我们快速了解本文的核心概念。为了尽可能简洁,我们将使用一些刻意设计的简单示例。

如果你已是专家,可直接跳过本节。如果不确定自己的掌握程度,建议通读一遍;若本节内容对你而言并非老生常谈,那么在继续阅读后文之前,你可能需要先查阅一些复习资料。

泛型

可以把泛型看作是 “类型层面的函数参数”。这是什么意思呢?通常来说,函数参数是值(或值的引用,但我们在此不纠结这一点)。

function arrayLength(arr: any[]) {
  return arr.length;
}

这里的 arr 是一个数组,目前它的类型是 any[](任意类型的数组)。如果我们想让这个数组的类型更精确,可以添加一个泛型参数:

function arrayLengthTyped<T>(arr: T[]) {
  return arr.length;
}

现在,每当我们调用这个方法并传入一个数组时,泛型参数 T 会自动推断为该数组元素的类型。需要明确的是,尽管 T 让方法定义更精确,但在这个例子中它完全是多余的 —— 原始方法已经足够好用。arr 的值是 any[] 类型,但这并不重要:无论数组元素是什么类型,length 属性始终存在。

让我们从一个无用的函数转向另一个。来实现我们自己的过滤函数:

function filterUntyped(array: any[], predicate: (item: any) => boolean): any[] {
  return array.filter(predicate);
}

这次我们确实遇到了问题:传入的断言函数(predicate)完全没有类型校验。

type User = {
  name: string;
};

const users: User[] = [];

filterUntyped(users, user => user.nameX === "John");

我们传入了一个处理数组元素的函数,但显然用错了 —— 每个用户对象上根本没有 nameX 属性。这正是泛型的用武之地:

function filterTyped<T>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

现在 TypeScript 会校验所有内容:

filterTyped(users, user => user.nameX === "John");
// -----------------------------^^^^^
// 类型“User”上不存在属性“nameX”。是否指“name”?

我们甚至可以限制泛型参数的范围。假设我们有多种不同的用户类型:

type User = {
  name: string;
};

type AdminUser = User & {
  role: string;
};

type BannedUser = User & {
  reason: string;
};

出于某种奇怪的原因,我们想复用之前的 filterTyped 函数,但只让它适用于任意 User 类型。

如果你想直接去掉泛型,写成这样:

function filterUser(array: User[], predicate: (item: User) => boolean): User[] {
  return array.filter(predicate);
}

那可就太急了。这个函数虽然看似合理,但最终会丢失返回类型的精度:

const adminUsers: AdminUser[] = [];
const adminUsersNamedAdam = filterUser(adminUsers, user => user.name === "Adam");

变量 adminUsersNamedAdam 的类型会被推断为 User[]—— 这是必然的,因为 filterUser 明确声明返回 User[]

正确的解决方案是回到泛型版本,但限制 T 的可接受值:

function filterUserCorrect<T extends User>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

现在返回类型会被正确推断:它与传入数组的类型完全一致。同时,我们只能传入符合 User 类型(即具有字符串类型 name 属性)的参数来调用这个函数。

条件类型

条件类型本质上允许我们 “询问” 类型相关的问题,并根据答案构建新的类型。

type IsArray<T> = T extends any[] ? true : false;

type YesIsArray = IsArray<number[]>; // 类型为 true
type NoIsNotArray = IsArray<number>; // 类型为 false

这里 YesIsArray 是字面量类型 true,而 NoIsNotArray 是字面量类型 false。这显然没什么实际用途 —— 条件类型的真正价值通常体现在类型推断中:

type ArrayOf<T> = T extends Array<infer U> ? U : never;

type NumberType = ArrayOf<number[]>; // 类型为 number
type NeverType = ArrayOf<number>; // 类型为 never

这里 NumberType 的类型是 number,而 NeverType 不出所料是 never。当然,我们可以(也应该)在这些工具类型中使用泛型约束:

type ArrayOf2<T extends Array<any>> = T extends Array<infer U> ? U : never;

type NumberType2 = ArrayOf2<number[]>; // 类型为 number
type NeverType2 = ArrayOf2<number>;
// ------------------------^^^^^^^
// 类型“number”不满足约束“any[]”

现在我们禁止将非数组类型传入 ArrayOf2,因此永远不用担心会得到 never 类型。

开始实战

我最近写了两篇关于使用 TanStack Start 实现 单次请求突变(single flight mutations)的文章。为了实现这个功能,我们精心组合了 react-query 的配置选项。我们的查询函数(实际执行数据获取的函数)特意设计为对 TanStack 服务函数的单次调用。然后,这个查询函数及其接收的参数 payload 被放入 react-query 的 meta 选项中。

之后,在服务器端的中间件中,我们接收查询键(query keys),查找该查询对应的服务函数和参数 payload,以便重新获取其数据。

在这些工作中,我们构建了一个简单的工具函数,以消除查询函数和 meta 选项之间的代码重复:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这个工具函数接收查询键、服务函数和可选的参数 payload,返回我们需要的一些查询选项。它确保了查询函数和 meta 选项始终与获取数据的服务函数保持同步。我们这样组合使用它:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

这个概念验证版本虽然能用,但完全没有类型校验。我们的服务函数和参数 payload 都被标记为 any,这不仅无法限制无效的参数 payload,更严重的是,所有使用这个函数的查询钩子(query hooks)都会将查询到的数据报告为 any 类型。

本文将实现 refetchedQueryOptions 函数的全类型版本 —— 这比看起来要难得多!

成功标准

以下是我们完整的测试设置:

import { QueryKey, queryOptions } from "@tanstack/react-query";
import { createServerFn } from "@tanstack/react-start";

// ============================ 当前实现 ============================

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

// ============== 测试用服务函数 ==============

const serverFnWithArgs = createServerFn({ method: "GET" })
  .inputValidator((arg: { value: string }) => arg)
  .handler(async () => {
    return { value: "Hello World" };
  });

const serverFnWithoutArgs = createServerFn({ method: "GET" }).handler(async () => {
  return { value: "Hello World" };
});

// ============================ 测试用例 ============================

refetchedQueryOptions(["test"], serverFnWithArgs, { value: "" });
refetchedQueryOptions(["test"], serverFnWithoutArgs);

// 错误的参数类型
// 失败 - 未使用的 '@ts-expect-error' 指令
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs, 123);

// 缺少必需的参数
// 失败 - 未使用的 '@ts-expect-error' 指令
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs);

顶部是 refetchedQueryOptions 函数的当前实现。下面是两个用于测试的服务函数:一个带参数,一个不带参数。再往下是四个对 refetchedQueryOptions 的调用,用于验证类型校验是否正常工作。前两个调用我们期望成功,后两个调用我们期望报错 —— 通过 // @ts-expect-error 指令来验证这一点。该指令的作用是:期望下一行代码会产生错误。如果下一行确实有错误,则一切正常;如果没有错误,则 @ts-expect-error 指令本身会报错。

在上面的初始实现中,我们期望的错误并没有出现。这是合理的,因为所有内容都被标记为 any,而且 arg 参数是可选的,所以实际上可以传入任何值。

即使你愿意接受不完美的类型校验,当前的解决方案也没什么用处。由于 serverFn 被标记为 any,我们的 queryFn 会返回 any 类型 —— 这意味着所有使用 useQueryuseSuspenseQuery 的应用代码,其数据类型都会是 any

本文的其余部分将正确地为所有内容添加类型。我们需要做一些看似 “离谱” 的操作,但希望能从中学习到新知识,甚至获得一些乐趣。

迭代一

如何进行最小化改进?目前,服务函数缺少返回类型是最大的问题 —— 任何使用查询数据的地方都会得到 any 类型。我们非常希望应用代码中的数据类型是正确的。

TanStack 服务函数本质上就是普通函数 —— 它们的特殊之处在于可以从客户端或服务器端调用,但归根结底还是函数。它们总是接收一个参数,该参数包含一个 data 属性(用于存储函数定义的标准参数,还可以传递 headers 等内容,但我们在此不关心)。

我们能不能给函数添加一个泛型,代表服务函数?一旦有了函数类型,就可以使用 TypeScript 内置的 ParametersReturnType 工具类型。让我们看看效果:

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"],
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

我们将泛型约束为一个接收 arg(含 data 属性)的函数。此外,现在可以在 arg 的参数定义中使用 T 泛型 —— 这里的 arg: Parameters<T>[0]["data"] 表示:无论我们的函数是什么类型,arg 的类型都与该函数接收的主参数中的 data 属性类型相同。

效果如何?让我们检查测试用例:

refetchedQueryOptions(["test"], serverFnWithArgs, { value: "" });
refetchedQueryOptions(["test"], serverFnWithoutArgs);
// 错误:期望传入 3 个参数,但只得到 2 个

// 错误的参数类型
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs, 123);

// 缺少必需的参数
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs);

我们遇到了一个问题:对于不接收任何参数的查询函数,我们仍然需要传入一个参数。这是合理的 ——refetchedQueryOptions 确实定义了 arg 参数,需要传入该参数。需要说明的是,为这个参数传入 undefined 完全可行:

refetchedQueryOptions(["test"], serverFnWithoutArgs, undefined);

这解决了所有问题 —— 我们的测试代码现在没有任何错误。对于绝大多数应用来说,这可能已经足够了。我接下来要展示的改进可能并不值得付出这么多努力。但是,通过这些努力,我们可能会学到一些关于 TypeScript 的有趣知识,而且如果我们足够 “特别”,甚至可能会觉得很有趣。

错误的思路

你可能认为让 arg 变成可选参数就能解决所有问题。不幸的是,当我们这样做时,arg 在所有地方都会变成可选的 —— 包括我们希望它是必需的场景:

// 缺少必需的参数
// 失败 - 未使用的 '@ts-expect-error' 指令
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs);

如果你是 TypeScript 高级用户,可能会认为我们需要条件类型:检测推断出的参数类型(data 参数中的类型),如果它不是 undefined,就要求传入该参数;如果是 undefined,就不要求传入。但遗憾的是,没有一种简单的方法可以用条件类型表示 “不传入任何参数”。我尝试过,但从未完全实现。可能我遗漏了什么(如果你能解决这个问题,欢迎留言),但即使有技巧可以实现,也有一个更直接、更符合惯例的解决方案。

我们本质上希望在不同情况下有不同的函数签名:当传入的服务函数需要参数时,我们就传入参数;当服务函数不需要参数时,我们就不传入参数。在计算机科学中,不同的函数签名通常被称为函数重载(function overloading),而 TypeScript 支持这一特性。

TypeScript 中的函数重载

举一个最简单的例子:假设你想写一个 add 函数,有两个版本:一个接收两个数字并相加,另一个接收两个字符串并拼接。概念上,我们想要的是这样:

function add(x: number, y: number): number {
  return x + y;
}

function add(x: string, y: string): string {
  return x + y;
}

但这是无效的 —— 因为 JavaScript 是动态类型语言,在同一个作用域中不能有多个同名函数。不过,TypeScript 允许我们重载函数,但机制略有不同。正确的做法是这样:

function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: string | number, y: string | number): string | number {
  if (typeof x === "string" && typeof y === "string") {
    return x + y;
  }
  if (typeof x === "number" && typeof y === "number") {
    return x + y;
  }
  throw new Error("无效的参数");
}

我们首先定义函数签名:

function add(x: number, y: number): number;
function add(x: string, y: string): string;

这些签名定义了函数的实际 API:我们声明这个函数可以接收两个数字并返回一个数字,或者接收两个字符串并返回一个字符串。

然后是函数的实际实现:

function add(x: string | number, y: string | number): string | number {
  if (typeof x === "string" && typeof y === "string") {
    return x + y;
  }
  if (typeof x === "number" && typeof y === "number") {
    return x + y;
  }
  throw new Error("无效的参数");
}

输入类型和返回类型都必须是所有签名的联合类型。换句话说,实际实现必须能接受所有签名定义的参数。

现在,当我们尝试调用这个函数时,只能看到定义好的签名:

这个实现有点奇怪。你可能会疑惑,为什么我们需要:

throw new Error("无效的参数");

这个函数的有效调用方式只有两种:传入两个字符串或两个数字 —— 这是 TypeScript 允许的所有情况。那为什么 TypeScript 要求我们在最后添加 throw 语句呢?因为函数实现中,xy 的类型都是 string | number,所以在 TypeScript 看来,x 可能是字符串而 y 可能是数字。目前 TypeScript 还无法理解,这种组合在之前的重载签名中是被禁止的。

构建解决方案

所以我们要对 refetchedQueryOptions 进行两次重载:一次用于接收参数的服务函数,一次用于不接收参数的服务函数。如何定义这两种情况呢?这就是有趣的地方。

首先,定义一个表示任意异步函数的类型:

type AnyAsyncFn = (...args: any[]) => Promise<any>;

这看起来像是在浪费时间,但很快它会帮我们减少重复代码,并大大提高代码清晰度。

接下来,定义一个接收异步函数并提取其参数类型的类型。条件类型非常适合这个场景。之前我们见过类似的条件类型,用于提取数组元素的类型:

type ArrayOf<T extends Array<any>> = T extends Array<infer U> ? U : never;

我们检查 T 是否继承自数组,然后将 infer U 直接放入数组类型已有的泛型位置。让我们用类似的方法获取异步函数的参数类型:

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends { data: infer TResult } ? TResult : undefined;

Parameters<T> 类型可以从函数类型中提取参数。我们获取第一个参数(函数可以有多个参数,但服务函数只有一个),在这个参数上查找 data 属性,如果存在则推断其类型,否则返回 undefined

有了这个类型,我们就可以开始 “询问” 类型相关的问题了:

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends undefined ? false : true;

我们还可以创建其他工具类型:

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

我们构建了一些工具类型,它们接收一个函数类型,判断该函数是否有服务函数参数。

TypeScript 重载的一个主要缺点是,我们不能依赖推断的返回类型,因此必须手动定义返回类型:

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn: (_?: any) => Promise<T>;
  meta: any;
};

现在,我们可以定义重载签名了:

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn>,
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;

一个版本用于接收参数的服务函数(需要传入参数),另一个版本用于不接收参数的服务函数(无需传入参数)。

完整实现如下:

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return {
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  };
}

就这样,我们完成了!

泛型与条件类型结合,可以产生极其强大的效果。只要思路正确,你可以对类型提出非常有用的 “问题”,从而构建出精确符合需求的 API。

总结思考

希望这个针对小众场景的深入探讨,能让你学到至少一项关于 TypeScript 的实用知识。即使你永远不需要解决这个特定问题 —— 说实话,你很可能不需要 —— 这些工具和技能也具有广泛的适用性。