框架中的类型编程:tRPC & Prisma 中的泛型应用
框架中的类型编程:tRPC & Prisma 中的泛型应用
最开始想写框架中的类型编程这个题材,是因为突然看到了 Hono 这么个服务端框架,它的特色之一就是能分析请求的路径来提供参数的类型,比如定义 /:name/:page
的路径,在你调用 param()
方法时 Hono 能够自动提示出 'name' | 'page'
这样的联合类型。
这个题材值得花费精力的原因在于,它为刚入门 TypeScript 的同学展示了 TypeScript 的无限可能性(或者说自己还有多少要学的),也让已经有一定 TypeScript 编写经验的同学意识到,其实自己此前还有很多技巧没有用上。同时,这个题材中选择的框架,其展示出来的类型编程都是非常接地气的,个中技巧完全可以掰开理解化为己用。
始料未及的是,大纲时的小作文,最后变成了接近 9k 字的上下两篇,而且我感觉这完全可以做成一个新的专栏,名字就叫「框架中的类型编程」:-)。
在已有的上下两篇中,我们会介绍四个类型编程的场景:tRPC、Hono、Prisma 以及 EventEmitter。难度可以排列为 tRPC < EventEmitter < Prisma ≈ Hono,这几个例子中的概念实现相对独立,你可以根据自己的喜好以任意的顺序阅读。
tRPC: 链式调用中的泛型补全
tRPC 是一个基于 TypeScript 的 RPC 框架(怎么感觉说了好像没说),它的核心优势之一是在前后端提供统一的类型定义,想象一下,之前你请求服务端接口时,对它到底会返回个什么东西根本猜不到,最多自己手写一套类型把请求结果断言过去:
const result = await axios<PostResponse>('/api/post');
而使用 tRPC,请求其实就是调用一个已经拥有强类型的函数:
const postQuery = trpc.post.useQuery({ id });
在 useQuery 函数中,其入参与出参都具有完整的类型约束,而它实际上是通过这么一个 pipeline 的方式来生成的(tRPC 中叫 procedure):
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { createTRPCReact } from '@trpc/react-query';
export const t = initTRPC.create();
export const appRouter = t.router({
post: t.procedure
.input(
z.object({
id: z.number(),
})
)
.query(async (input) => {
// 拥有 number 类型!
const id = input.id;
const result = await prisma.posts.findUnique({
where: { id },
});
return result;
}),
});
以上代码的核心在于,通过 .input
方法 + Zod 的 Schema 定义,可以为接下来链式调用的 query 方法提供入参信息!还记得很早以前,第一次看到在链式调用中提供类型信息的时候,我其实还震惊了挺久,感觉怎么别人脑瓜子就这么好用呢。
但其实你琢磨一下,链式调用通常是通过 Class + 每个方法再次返回实例来实现的,而每个方法返回的实例,虽然值并不会发生变化,但上面的类型是可以发生变化的。因此我们的实现思路大致是酱紫的:
- 在 Class 上声明数个泛型。
- 方法调用时,使用从该次调用获得的类型,填充实例上的一部分泛型,再返回这个填充完毕的实例类型。
- 后续的方法直接依赖实例上的类型,这样就实现了链式调用的泛型填充效果。
我们从最简单的例子开始,首先定义一个只有一个泛型坑位的 Class,以及一个 input 方法,它也拥有一个泛型,其信息来源于自己的入参:
export class Pipeline<PipelineInputType = any> {
input<InputType>(input: InputType) {
return this as any;
}
}
// 泛型信息填充为 string
new Pipeline().input('linbudu');
// 泛型信息填充为 { name: string }
new Pipeline().input({
name: 'linbudu',
});
在 input 调用获得这个泛型信息后,直接返回一个 Pipeline<InputType>
类型,这样后续进行的链式调用,都可以直接引用 Class 上的 PipelineInputType 类型了:
export class Pipeline<PipelineInputType = any> {
input<InputType>(input: InputType): Pipeline<InputType> {
return this as any;
}
query(callback: (options: { input: PipelineInputType }) => any): void {}
}
new Pipeline().input('linbudu').query(({ input }) => {
// string 类型!
input.startsWith('');
});
new Pipeline()
.input({
name: 'linbudu',
})
.query(({ input }) => {
// { name: string; } 类型!
input.name.startsWith('');
});
到这里其实最重要的第一步就已经完成了,tRPC 中的这部分类型编程其实关键点就在于**「在链式调用中填充类型信息」**,但这个例子里只填充了一次多少显得不够筋道,我们还可以完善一点,就像 tRPC 中不仅可以通过 .input
定义 query 的入参类型,还可以通过 .output
约束 query 的出参类型。而你都完成了第一步,那这里其实也很简单了,不就是多一个泛型坑位的事吗?
export class Pipeline<PipelineInputType = any, PipelineOuputType = any> {
input<InputType>(input: InputType): Pipeline<InputType, PipelineOuputType> {
return this as any;
}
output<OuputType>(input: OuputType): Pipeline<PipelineInputType, OuputType> {
return this as any;
}
query(
callback: (options: { input: PipelineInputType }) => PipelineOuputType
): void {}
}
new Pipeline()
.input('linbudu')
.output(true)
.query(({ input }) => {
// 不能将类型“string”分配给类型“boolean”。
return input;
});
看起来不错!但是这里有个很奇怪的地方,我们是用一个值来作为类型约束的(.input('linbudu')
、.output(true)
),这非常的不规范,而 tRPC 的示例是通过 zod 来进行类型约束的,看起来就正经很多,而且也可以直接作为校验器进行验证。
.input(z.string())
.output(z.boolean())
至于 zod 、yup、superstruct、arktype...这些工具,其实大差不差,核心功能都是在创建一个 Validator 的同时提供与 Schema 一致的类型提示。但需要注意的是,z.string()
返回的并不是一个原始的 string 类型,而是 zod 中的 ZodString 类型,上面提供了如 z.string().regex()
、z.string().datetime()
这样的方法来实现命令式的校验。
那你是不是就会开始发愁,怎么从校验库的内置类型获得原始的 TypeScript 类型呢?其实,这些内置类型上都保留了一份原始类型——不仅仅是 zod,所有号称 First Class TypeScript Support 的校验库都是这么做的。在 zod 中,这个「后门」是 _output
属性:
z.string()['_output']; // string
z.number()['_output']; // string
z.object({
name: z.string(),
})['_output']; // { name: string }
另外,这些校验库其实也提供了内置的工具类型来提取原始类型,比如 zod 中的 ZodType 就是,我们可以实现一个类型来提取原始类型:
type InferType<T> = T extends z.ZodType<infer U> ? U : T;
type _ = InferType<ReturnType<typeof z.string>>; // string
// or
// type _ = InferType<z.ZodString>; // string
先假设我们只会使用 zod 来定义 Schema,现在我们要考虑的是,如何实现提取出 ZodString 类型,却使用 string 类型来填充泛型的效果。这其实就是类型编程中最经典最基础的操作,很多时候泛型第一次获得的类型无法直接利用,需要经过数次加工后才能传递给下一个泛型类型的消费方。
现在我们定义 InferredInputType 、InferredOutputType 这两个泛型参数,至于 input / output 方法中用于获得 ZodType 的泛型参数,并不需要占用 Pipeline 上的泛型坑位:
import { z } from 'zod';
type InferType<T> = T extends z.ZodType<infer U> ? U : T;
export class Pipeline<InferredInputType = any, InferredOutputType = any> {
input<InputType extends any>(
input: InputType
): Omit<Pipeline<InferType<InputType>, InferredOutputType>, 'input'> {
return this as any;
}
ouput<OuputType>(
input: OuputType
): Omit<Pipeline<InferredInputType, InferType<OuputType>>, 'output'> {
return this as any;
}
// query 完全是在消费泛型,不会产生新的泛型或更新泛型,所以不需要声明泛型
query(
callback: (options: { input: InferredInputType }) => InferredOutputType
): void {}
}
完整版的最终代码还多了一些东西,比如返回值类型现在是 Omit<Pipeline, 'input'>
这样的结构,原因在于我们希望每个方法调用在实例上只会调用一次,而不是 .input().input().input()
这样,因此可以用这种方式来从实例上移除当前被调用的方法。
如果上面的例子没有榨干你的 CPU,你很容易产出这样的疑问:如果我不只是在使用 zod 呢?刚刚上面提了那么多的校验库,如果我就是看别的更顺眼呢?tRPC 支持那么多校验库,它又是怎么实现的?
原理很简单——加机器。tRPC 中内置了对这些校验库的类型提取逻辑,校验库通常都会有一个 validate 方法(也有的叫 parse,create 之类的),这些方法的返回值也是原始类型,反正我就条件类型枚举呗,不是这个就是那个,总有一个分支能把你逮住咯,请看 tRPC 源码。
总结一下,tRPC 这个部分的类型编程还是比较简单的,主要就是「链式调用 x 泛型传递」这里比较绕一点,因此把它作为第一个场景,让你在冬日里停滞的大脑稍稍 warm up 一下。
Prisma: 泛型类型的流转
Prisma 是一个比较特殊的 ORM,特殊就特殊在它不像 TypeORM、Sequelize 这样是通过 TS 代码来定义数据库表结构,而是通过自己的 DSL:Prisma Schema 来定义结构的。Prisma Schema 会被映射到 TypeScript 类型,并通过内置的大量类型编程来实现严格的类型约束,比如以下的 Prisma Schema:
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
}
会编译出这样的 User 类型:
type User = {
id: number;
email: string;
name: string | null;
}
以及消费这个 User 类型的各种方法,以 findUnique 为例:
const prisma = new PrismaClient();
async function main() {
const queryUserRes = await prisma.user.findUnique({
select: {
id: true,
email: true,
},
where: {
id: 599,
},
});
queryUserRes?.id; // number
queryUserRes?.email; // string
queryUserRes?.name; // 不存在此属性
}
在上面的例子中,queryUserRes 的类型完全由 findUnique 方法中的 select 属性来决定——你 select 哪些属性,哪些属性就会出现在结果中,是不是很神奇?
这一节我们要实现的就是这么一个效果,简化后的代码大致是这样的:
declare function findUnique(params: ?): ?;
const res = findUnique({
where: {
id: 1,
},
select: {
id: true,
email: false,
},
});
res.id; // √
res.email; // x
如果你仔细读完了上一节的内容,那么多少会有一点思路,我们要做的还是泛型信息的流转变换,所以事前分析就相当有必要了:
- 首先,泛型信息由 select 属性的值提供,等价于其字面量类型。这个可以直接通过 extends 实现。
- 接着,从字面量类型中提取由类型为 true 的属性组成的部分,比如
{ id: true; name: false; }
的结果是{ id: true; }
,这个可以通过 Pick + 索引类型 + 条件类型来实现。 - 使用提取出的属性名,去 User 类型中匹配对应的类型。这个可以通过取交集来实现。
我们从第一步开始一步步完善这个方法,首先 findUnique 方法的参数会是个对象类型,属性包括 where 与 select:
type User = {
id: number;
email: string;
name: string | null;
};
interface FindUniqueParams<SelectFields> {
where: Partial<User>;
select: SelectFields;
}
而 FindUniqueParams 接口的 SelectFields 泛型,将来自于调用 findUnique 方法时的输入:
declare function findUnique<SelectFields>(
params: FindUniqueParams<SelectFields>
): SelectFields | undefined;
FindUniqueParams<SelectFields>
可能不太好理解,来看实际的例子:
const res = findUnique({
where: {
id: 1,
},
select: {
id: true,
},
});
在这里,整个 params 是 FindUniqueParams<SelectFields>
类型,那么有以下的等式:
{
where: { id: number };
select: { id: boolean }
}
// 等价于
{
where: Partial<User>;
select: SelectFields;
}
那么 SelectFields 不就是 { id: boolean }
类型?
这个时候,其实返回值 res 的类型就是 { id: boolean }
。接下来,我们可以取 { id: boolean }
与 User 类型的交集,即实现一个工具类型 ObjectIntersection<A, B>
,能够提取 A 与 B 这两个对象类型之间共同存在的属性,如果属性类型在两个对象类型中不一致,则以 B 为准。
这个时候我就要掏出一张两年前做的图了:
还有什么看着比较高大上的画图工具,请务必推荐给我...
TypeScript 内置的工具类型 Exclude 与 Extract,也可以用更好理解的方式称之为差集 Difference 与交集 Intersection,这里我们主要介绍交集:
type Extract<T, U> = T extends U ? T : never;
type AExtractB = Extract<1 | 2 | 3, 1 | 2 | 4>; // 1 | 2
之所以会有这种「集合」的表现,实际上得益于条件类型的分布式特性,如上面的例子实际上等价于:
type _AExtractB =
| (1 extends 1 | 2 | 4 ? 1 : never) // 1
| (2 extends 1 | 2 | 4 ? 2 : never) // 2
| (3 extends 1 | 2 | 4 ? 3 : never); // never
关于集合工具类型,真的能展开讲解非常多,分布式条件类型,一维,二维,二维的前序优先,二维的后序优先,blabla...,这里我们先收一下吧。
这里我们需要的也就是交集类型,但是要「稍微」扩展一下,因为我们需要取的是两个对象的交集,想想怎么转换到一维的交集?对象=键值对,那两个对象的键取交集,然后再在希望具有更高优先级属性类型的那个对象里 Pick 一下就好啦~
export type PlainObjectType = Record<string, any>;
// 一维集合的交集实现
export type Intersection<A, B> = A extends B ? A : never;
// 对象键集合的交集实现
export type ObjectKeysIntersection<
T extends PlainObjectType,
U extends PlainObjectType
> = Intersection<keyof T, keyof U>;
// 对象的交集实现!
export type ObjectIntersection<
T extends PlainObjectType,
U extends PlainObjectType
> = Pick<T, ObjectKeysIntersection<T, U>>;
再改写一下 findUnique 的签名,在返回值里进行交集运算:
declare function findUnique<SelectFields extends PlainObjectType>(
params: FindUniqueParams<SelectFields>
): ObjectIntersection<User, SelectFields> | undefined;
你会发现现在已经可以检查出没有 select 的属性了:
然而,如果添加个 email: false
,你会发现这种情况下错误也消失了:
同时,在这么做的过程中你还会感觉到一丝不对劲:select 应该只能出现 User 中存在的属性才对,所以应该是有类型提示的,另外属性也只能是 boolean 类型,现在却是来者不拒的状态?
我们继续完善实现,首先我们应该先给 SelectFields 提供类型约束,它只能是 User 中的属性 + boolean 才对:
type UserSelectConstraint = Partial<Record<keyof User, boolean>>;
把这个约束添加给所有引用 SelectFields 的地方:
interface FindUniqueParams<SelectFields extends UserSelectConstraint> {
where: Partial<User>;
select: SelectFields;
}
declare function findUnique<SelectFields extends UserSelectConstraint>(
params: FindUniqueParams<SelectFields>
): ObjectIntersection<User, SelectFields> | undefined;
这样一来我们就获得了类型提示,并且也不再允许非 boolean 类型的值:
你可能会发现这里还是允许非 User 的属性,这个我们最后说。
下一个问题,即使选择的属性值为 false,我们最终的结果里也还是会有这个属性?
这里还是可以用集合的思路解决:对于提取的 SelectFields 属性,我们提取其属性类型为 true 的子集即可,来实现一个 PickByValue 类型:
export type PickByValueType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
简单说明一下这个类型的实现:
- 遍历类型 T 的属性,在重映射中(
as
语法)过滤属性。- 如果属性的值类型满足 ValueType,则在重映射中返回这个属性。
- 否则,返回 never,则此属性会消失在结果的类型中。
最后再完善下 findUnique 的类型定义:
declare function findUnique<SelectFields extends UserSelectConstraint>(
params: FindUniqueParams<SelectFields>
): ObjectIntersection<User, PickByValueType<SelectFields, true>> | undefined;
完整的代码如下:
type User = {
id: number;
email: string;
name: string | null;
};
type UserSelectConstraint = Partial<Record<keyof User, boolean>>;
interface FindUniqueParams<SelectFields extends UserSelectConstraint> {
where: Partial<User>;
select: SelectFields;
}
export type PlainObjectType = Record<string, any>;
export type Intersection<A, B> = A extends B ? A : never;
export type ObjectKeysIntersection<
T extends PlainObjectType,
U extends PlainObjectType
> = Intersection<keyof T, keyof U>;
export type ObjectIntersection<
T extends PlainObjectType,
U extends PlainObjectType
> = Pick<T, ObjectKeysIntersection<T, U>>;
export type PickByValueType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
declare function findUnique<SelectFields extends UserSelectConstraint>(
params: FindUniqueParams<SelectFields>
): ObjectIntersection<User, PickByValueType<SelectFields, true>> | undefined;
最后来解释一下上面我们的 select 为何允许非 User 的属性,这是因为在 Prisma 中,select 的类型定义同样是从 Schema 生成的,它是一个已经明确的对象类型,而非我们上面从输入各种推导转换得到的。
总结一下,Prisma 这个示例比 tRPC 中稍微绕一些,但只要提前分析好泛型信息的流转,确认输入泛型如何转换能得到预期的结果,就没那么复杂啦。另外,强烈建议挑几个别的 Prisma 生成的方法来自己试一下从头实现,类型编程就跟五年高考三年模拟一样,光看解析是永远学不会的。
转载自:https://juejin.cn/post/7328564786596528139