实现一个能够严格推导 TS 类型的前端 GraphQL 库
实现一个能够严格推导 TS 类型的前端 GraphQL 库
问题
我发现前端调用 GraphQL 时客观存在对返回值类型无法严格约束的问题。
考虑下面这段代码
1 | |
假设在这里,User的定义如下:
1 | |
在上面的代码中,user被推导为User类型,但是实际上,它只包含id和name两个字段。
而这种不完备的类型推断会导致一些潜在问题,例如我在使用user时,可能会忘记检查我并没有查询user.email,因此它的值是undefined,然而 TS 无法帮我们在编译时找到这个错误,这就导致了可能的运行时风险。
设想
那么,有没有可能在编译时就能发现这种错误呢?毕竟 TS 具有一套非常强大(甚至图灵完备)的类型系统,应该是可以做到的。
因此,我想到了一个解决方案,即不直接编写 GraphQL 查询语句,而是编写一个函数,该函数通过一种描述式的方法展现查询的调用,并且能够根据描述生成精确的返回值类型。
在我的想象中,它应该像这样被调用
1 | |
在这里,只需要传入一个 class User,就可以严格约束返回值类型。
为什么传入 class 而非直接在泛型中传入 interface 或 type?这是因为我们需要依赖于 TS 推导返回值的类型。而在 TS 中,一旦你指定了泛型参数,那么 TS 就会放弃推导类型。
假设我们这样定义queryMany函数:
1 | |
可以看到,在这里我们需要知道selector的返回值,因此这里使用了一个泛型参数R来告知 TS 推导其类型。然而,一旦我们为queryMany传入了泛型参数,如queryMany<User, any>,我们便无法让 TS 推导其返回值。即使这里将<T, R>改成了<T, R = any>或<T, R = unknown>,然后调用queryMany<User>,也无济于事,这只意味着 TS 会使用默认参数any或unknown给R赋值,而不是推导真正的返回值类型,而这样一来,我们这么费尽周章的行为也就没了意义。
因此,我们将queryMany的类型定义改写为下面的函数:
1 | |
这样一来,我们就在保留了 TS 推导返回值类型能力的同时,允许传入一个类来限定泛型。
这样做唯一的问题时要求类型不能仅仅使用 interface 或 type 定义,而是要定义为真实的类。不过,这方面的工作应当由代码生成器来做,并不是什么真正的问题。即使一定要用 interface 或 type,我们也可以直接提供原始版本的queryMany,将它命名为queryManyWithoutType,如下所示:
1 | |
然后这样调用该函数:
1 | |
具体哪种方式更好,见仁见智。这只是细枝末节的问题,不值得费太多笔墨。
重要的是,在这里,users 的类型被推断为Array<{ id: number; name: string; email: string; role: { id: number; name: string; }; }>
而非单纯的User[],这意味着我们获得了真正类型安全的返回值。
当然,除了queryMany之外,我们也应该提供queryOne函数:
1 | |
注意到,在这里我们传入了第二个可选参数{ id: Int },用于约束传入的 variables 的类型。同时,在最后添加了一个可选的对象参数,用于传入 variables。这里的Int就是 graphql 导出的GraphQLInt,没什么特别的。
同理,在这里,user 的类型被推断为{ name: string; email: string; role: { id: number; name: string; }; },而非单纯的User。
当然,我们还免不了要支持 mutation,用法自然与 query 完全一致:
1 | |
同理,这里 user 的类型被推断为{ name: string; email: string; },而不是User
(同样的,这里要求UpdateUserInput是一个导入的类,而不仅仅是个 interface 或 type)。
实现思路
下面是该 client 的实现
现在,让我们分析下面这个被称为Selector的匿名函数
1 | |
顾名思义,该函数用于选取要返回的字段。该函数的泛型参数是Selector<T, R>,在上面应该也已经展示过了,<T>表示该函数选取的实体类型,在这里是User,<R>就是该函数的返回值,这里要依赖于 TS 推导,稍有些复杂,我们待会儿再看。
此外,在该函数中,user是一个SelectorBuilder<T>,在这里被推导为SelectorBuilder<User>。顾名思义,SelectorBuilder是一个对象,用于建立描述,在这里应该很直观。
那么这个函数返回数组的具体类型是什么呢?实际上,该 Selector 返回的数组只是一个简单的树状结构:
1 | |
根据这个树状结构,我们可以很轻松地构建 GraphQL 查询字符串。更有趣的是,这类树形结构天生适合 TS 进行递归推导。
下面我们回顾一些关于 TS 的基本知识。在 TS 中,存在一种被称为“元组(Tuple)”的类型,就像这样:
1 | |
在这里,tuple 的类型不是Array<string | number>,而是一个长度为 2 的元组,第一个值的类型是string,第二个值的类型是number。
通过 TS 中的infer操作符,我们可以很轻松地定义两个工具类型,Head和Tail,分别用于取出一个元组的第一个值和剩余值:
1 | |
很直观,不是吗?infer在这里就相当于一个占位符,给条件语句的某一个部分命名,以供后续使用。同样,在 TS 中,infer只能用在像这样的条件语句中。
当然,你也可以像这样,用infer把头尾都取出来:
1 | |
这有什么用?事实上,通过infer,我们可以递归地解析刚刚生成的树状结构的类型。例如,我们定义一个ParseAST类型,该类型通过上面的语句,首先取出元组头部进行解析,再递归调用ParseAST<TT>,最后将这两个结果通过&连起来,这样我们就通过 TS 完成了对树状结构的解析。
如果这听起来还是有些抽象,不妨看个例子:
1 | |
在上面的代码中,ParseTest接受一个元组,每次递归地取出头部,分别将类型为string、number和boolean的类型转换成'foo'、'bar'和'baz'。注意在最后,我们还要注意递归终止条件,即当元组变为空元组时。在这里,我们简单地在最后使用使用never作为终止类型,因为任何类型与never进行|运算都不会改变。但如果我们这里使用&运算符,则应该使用unknown而非never,否则我们只会得到never作为结果。
现在,测试我们定义的ParseTest:
1 | |
在 VSCode 中,将鼠标悬浮在Result上方,可以看到"foo" | "bar" | "baz",符合我们的预期。

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