实现一个能够严格推导 TS 类型的前端 GraphQL 库

实现一个能够严格推导 TS 类型的前端 GraphQL 库

问题

我发现前端调用 GraphQL 时客观存在对返回值类型无法严格约束的问题。

考虑下面这段代码

1
2
3
4
5
6
7
8
9
const query = gql`
query findUser($id: Int!) {
user(id: $id) {
id
name
}
}
`;
const { user } = await client.request<{ user: User }>(query, { id: 1 });

假设在这里,User的定义如下:

1
2
3
4
5
6
interface User {
id: number;
name: string;
email: string;
role: Role;
}

在上面的代码中,user被推导为User类型,但是实际上,它只包含idname两个字段。

而这种不完备的类型推断会导致一些潜在问题,例如我在使用user时,可能会忘记检查我并没有查询user.email,因此它的值是undefined,然而 TS 无法帮我们在编译时找到这个错误,这就导致了可能的运行时风险。

设想

那么,有没有可能在编译时就能发现这种错误呢?毕竟 TS 具有一套非常强大(甚至图灵完备)的类型系统,应该是可以做到的。

因此,我想到了一个解决方案,即不直接编写 GraphQL 查询语句,而是编写一个函数,该函数通过一种描述式的方法展现查询的调用,并且能够根据描述生成精确的返回值类型。

在我的想象中,它应该像这样被调用

1
2
3
4
5
6
const users = await client.queryMany(User)("users", (user) => [
user.id,
user.name,
user.email,
user.roles((role) => [role.id, role.name]),
]);

在这里,只需要传入一个 class User,就可以严格约束返回值类型。

为什么传入 class 而非直接在泛型中传入 interface 或 type?这是因为我们需要依赖于 TS 推导返回值的类型。而在 TS 中,一旦你指定了泛型参数,那么 TS 就会放弃推导类型。

假设我们这样定义queryMany函数:

1
type queryMany<T, R> = (name: string, selector: Selector<T, R>): ParseAST<R>

可以看到,在这里我们需要知道selector的返回值,因此这里使用了一个泛型参数R来告知 TS 推导其类型。然而,一旦我们为queryMany传入了泛型参数,如queryMany<User, any>,我们便无法让 TS 推导其返回值。即使这里将<T, R>改成了<T, R = any><T, R = unknown>,然后调用queryMany<User>,也无济于事,这只意味着 TS 会使用默认参数anyunknownR赋值,而不是推导真正的返回值类型,而这样一来,我们这么费尽周章的行为也就没了意义。

因此,我们将queryMany的类型定义改写为下面的函数:

1
2
3
4
5
6
type ClassType<T> = new (...args: any[]) => T;
type queryMany<T> =
(clazz: ClassType<T>): <R>(
name: string,
selector: Selector<T, R>
) => ParseAST<R>;

这样一来,我们就在保留了 TS 推导返回值类型能力的同时,允许传入一个类来限定泛型。

这样做唯一的问题时要求类型不能仅仅使用 interface 或 type 定义,而是要定义为真实的类。不过,这方面的工作应当由代码生成器来做,并不是什么真正的问题。即使一定要用 interface 或 type,我们也可以直接提供原始版本的queryMany,将它命名为queryManyWithoutType,如下所示:

1
2
type queryManyWithoutType<T, R> =
(name: string, selector: Selector<T, R>): ParseAST<R>;

然后这样调用该函数:

1
2
3
4
5
6
7
8
9
const users = await client.queryManyWithoutType(
"users",
(user: SelectorBuilder<User>) => [
user.id,
user.name,
user.email,
user.roles((role) => [role.id, role.name]),
]
);

具体哪种方式更好,见仁见智。这只是细枝末节的问题,不值得费太多笔墨。

重要的是,在这里,users 的类型被推断为Array<{ id: number; name: string; email: string; role: { id: number; name: string; }; }>
而非单纯的User[],这意味着我们获得了真正类型安全的返回值。

当然,除了queryMany之外,我们也应该提供queryOne函数:

1
2
3
4
5
const user = await client.queryOne(User, { id: Int })(
"user",
(user) => [user.name, user.email, user.role((role) => [role.id, role.name])],
{ id: 1 }
);

注意到,在这里我们传入了第二个可选参数{ id: Int },用于约束传入的 variables 的类型。同时,在最后添加了一个可选的对象参数,用于传入 variables。这里的Int就是 graphql 导出的GraphQLInt,没什么特别的。

同理,在这里,user 的类型被推断为{ name: string; email: string; role: { id: number; name: string; }; },而非单纯的User

当然,我们还免不了要支持 mutation,用法自然与 query 完全一致:

1
2
3
4
5
const user = await client.mutation(User, { id: Int, input: UpdateUserInput })(
"updateUser",
(user) => [user.name, user.email],
{ id: 1, input: { name: "new name" } }
);

同理,这里 user 的类型被推断为{ name: string; email: string; },而不是User
(同样的,这里要求UpdateUserInput是一个导入的类,而不仅仅是个 interface 或 type)。

实现思路

下面是该 client 的实现

现在,让我们分析下面这个被称为Selector的匿名函数

1
(user) => [user.name, user.email, user.role((role) => [role.id, role.name])];

顾名思义,该函数用于选取要返回的字段。该函数的泛型参数是Selector<T, R>,在上面应该也已经展示过了,<T>表示该函数选取的实体类型,在这里是User<R>就是该函数的返回值,这里要依赖于 TS 推导,稍有些复杂,我们待会儿再看。

此外,在该函数中,user是一个SelectorBuilder<T>,在这里被推导为SelectorBuilder<User>。顾名思义,SelectorBuilder是一个对象,用于建立描述,在这里应该很直观。

那么这个函数返回数组的具体类型是什么呢?实际上,该 Selector 返回的数组只是一个简单的树状结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[
{
name: "name",
type: "string",
children: null,
},
{
name: "email",
type: "string",
children: null,
},
{
name: "role",
type: "object",
children: [
{
name: "id",
type: "number",
children: null,
},
{
name: "name",
type: "string",
children: null,
},
],
},
];

根据这个树状结构,我们可以很轻松地构建 GraphQL 查询字符串。更有趣的是,这类树形结构天生适合 TS 进行递归推导。

下面我们回顾一些关于 TS 的基本知识。在 TS 中,存在一种被称为“元组(Tuple)”的类型,就像这样:

1
const tuple: [string, number] = ["foo", 42];

在这里,tuple 的类型不是Array<string | number>,而是一个长度为 2 的元组,第一个值的类型是string,第二个值的类型是number

通过 TS 中的infer操作符,我们可以很轻松地定义两个工具类型,HeadTail,分别用于取出一个元组的第一个值和剩余值:

1
2
3
4
5
type Head<T> = T extends [infer H, ...any[]] ? H : never;
type X = Head<[string, number, boolean]>; // => string

type Tail<T> = T extends [any, ...infer TT] ? TT : never;
type Y = Tail<[string, number, boolean]>; // => [number, boolean]

很直观,不是吗?infer在这里就相当于一个占位符,给条件语句的某一个部分命名,以供后续使用。同样,在 TS 中,infer只能用在像这样的条件语句中。

当然,你也可以像这样,用infer把头尾都取出来:

1
T extends [infer H, ...infer TT] ? ...

这有什么用?事实上,通过infer,我们可以递归地解析刚刚生成的树状结构的类型。例如,我们定义一个ParseAST类型,该类型通过上面的语句,首先取出元组头部进行解析,再递归调用ParseAST<TT>,最后将这两个结果通过&连起来,这样我们就通过 TS 完成了对树状结构的解析。

如果这听起来还是有些抽象,不妨看个例子:

1
2
3
4
5
6
7
8
9
type ParseTest<T> = T extends [infer H, ...infer TT]
? H extends string
? "foo" | ParseTest<TT>
: H extends number
? "bar" | ParseTest<TT>
: H extends boolean
? "baz" | ParseTest<TT>
: never
: never;

在上面的代码中,ParseTest接受一个元组,每次递归地取出头部,分别将类型为stringnumberboolean的类型转换成'foo''bar''baz'。注意在最后,我们还要注意递归终止条件,即当元组变为空元组时。在这里,我们简单地在最后使用使用never作为终止类型,因为任何类型与never进行|运算都不会改变。但如果我们这里使用&运算符,则应该使用unknown而非never,否则我们只会得到never作为结果。

现在,测试我们定义的ParseTest

1
type Result = ParseTest<[string, boolean, number]>;

在 VSCode 中,将鼠标悬浮在Result上方,可以看到"foo" | "bar" | "baz",符合我们的预期。

类型推导测试

在介绍完重要的前置知识后,这里再引入一个方便的工具类型Merge,用于将两个对象合并起来。这个类型的定义很简单,应该不用过多解释,一会儿会用到:

1
2
3
4
5
6
7
8
9
type Merge<A, B> = {
[K in keyof A | keyof B]: K extends keyof A & keyof B
? A[K] | B[K]
: K extends keyof B
? B[K]
: K extends keyof A
? A[K]
: never;
};

好了,该补充的都补充完了。现在我们的思路就很简单了,首先我们向函数中传入selector,函数调用selector,获取描述 GraphQL 查询字符串的树状结构,将它解析成真实的查询字符串,然后通过 graphql-request 或其他什么库发送到指定的后端地址获取数据。具体的实现逻辑想必很简单,困难的地方主要在于如何编写 TypeScript 的类型,不过我们已经在这里介绍完了所需的前置知识。

实现


实现一个能够严格推导 TS 类型的前端 GraphQL 库
https://snowfly-t.github.io/2023/03/03/实现一个能够严格推导TS类型的前端GraphQL库/
作者
Snowflyt
发布于
2023年3月3日
许可协议