
原文链接: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 类型 —— 这意味着所有使用 useQuery 或 useSuspenseQuery 的应用代码,其数据类型都会是 any。
本文的其余部分将正确地为所有内容添加类型。我们需要做一些看似 “离谱” 的操作,但希望能从中学习到新知识,甚至获得一些乐趣。
迭代一
如何进行最小化改进?目前,服务函数缺少返回类型是最大的问题 —— 任何使用查询数据的地方都会得到 any 类型。我们非常希望应用代码中的数据类型是正确的。
TanStack 服务函数本质上就是普通函数 —— 它们的特殊之处在于可以从客户端或服务器端调用,但归根结底还是函数。它们总是接收一个参数,该参数包含一个 data 属性(用于存储函数定义的标准参数,还可以传递 headers 等内容,但我们在此不关心)。
我们能不能给函数添加一个泛型,代表服务函数?一旦有了函数类型,就可以使用 TypeScript 内置的 Parameters 和 ReturnType 工具类型。让我们看看效果:
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 语句呢?因为函数实现中,x 和 y 的类型都是 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 的实用知识。即使你永远不需要解决这个特定问题 —— 说实话,你很可能不需要 —— 这些工具和技能也具有广泛的适用性。