全栈类型安全:tRPC 原理 + Prisma/Drizzle ORM
第22章:全栈类型安全:tRPC 原理与 Prisma/Drizzle ORM 类型推导
理解全栈类型安全是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用tRPC 原理与 Prisma/Drizzle ORM 类型推导?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 问题:前后端类型漂移
- 三种全栈类型安全方案对比
- tRPC 核心原理
Level 1 · 你需要知道的(1-3年经验)
问题:前后端类型漂移
传统全栈项目里,前端和后端是两个独立的代码库,类型系统各自为政。
// 后端:Express + 手写接口
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
app.get('/api/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
});
// 前端:自己再定义一遍
interface User {
id: string;
name: string;
email: string;
// 漏写了 createdAt — 编译器不知道
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json() as User; // as 断言:假设 API 返回符合类型
}
三个月后,后端把 createdAt: Date 改成了 created_at: string(驼峰改蛇形)。后端代码更新了,但前端的接口定义没人动,因为编译器不会跨越 HTTP 边界检查类型。问题到生产环境才暴露。
这种"前后端类型漂移"是全栈 TypeScript 项目最常见的隐患。解决方案有三条路:
三种全栈类型安全方案对比
方案一:OpenAPI + 代码生成
# 后端输出 openapi.json,前端用工具生成类型
npx openapi-typescript openapi.json --output src/api.d.ts
# 或用 orval 生成带类型的请求函数
npx orval --input openapi.json --output src/api
流程:后端维护 OpenAPI Schema(手写或用装饰器生成)→ CI 触发生成 → 前端使用生成的类型。
优点:标准化,可跨语言(后端是 Go,前端是 TypeScript 照样能用)。
缺点:多了一个生成步骤,生成的类型文件不直观,Schema 必须手动保持与实现同步。
方案二:tRPC(零代码生成)
类型直接在 TypeScript 的模块系统内共享,无需任何生成步骤。适合前后端都是 TypeScript 的场景。
方案三:GraphQL + 代码生成
npx graphql-codegen
GraphQL schema 是类型的 source of truth,前后端都用它生成类型。比 OpenAPI 更灵活(客户端按需查询字段),但学习成本更高,生态更重。
| 对比维度 | OpenAPI + codegen | tRPC | GraphQL + codegen |
|---|---|---|---|
| 语言限制 | 无(跨语言) | 需要全栈 TypeScript | 无(跨语言) |
| 代码生成 | 需要 | 不需要 | 需要 |
| 类型同步 | CI 触发生成 | 实时,改服务端立即报错 | CI 触发生成 |
| 学习成本 | 中 | 低(会 TS 就会) | 高 |
| 适合场景 | 已有 REST API,多语言团队 | 全栈 TS,快速迭代 | 复杂查询,移动端 |
tRPC 核心原理
tRPC 的核心思路:服务端定义 procedure,客户端通过类型系统直接使用,不需要任何运行时的"桥"。
npm install @trpc/server @trpc/client @trpc/react-query zod
服务端:定义 Router
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const t = initTRPC.create();
// 基础构建块
const router = t.router;
const publicProcedure = t.procedure;
// 需要认证的 procedure
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
// 定义路由
export const appRouter = router({
// 查询(GET 操作)
getUser: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
// input.id 类型:string(UUID)— 由 zod schema 推导
const user = await prisma.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, email: true, createdAt: true },
});
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
// 返回类型由 Prisma 查询自动推导
}),
listUsers: publicProcedure
.input(z.object({
page: z.number().int().positive().default(1),
pageSize: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input }) => {
const { page, pageSize, search } = input;
const where = search
? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
: undefined;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return { users, total, page, pageSize };
}),
// 变更(POST/PUT/DELETE 操作)
createUser: publicProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const user = await prisma.user.create({ data: input });
return user;
}),
updateUser: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return prisma.user.update({ where: { id }, data });
}),
deleteUser: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input }) => {
await prisma.user.delete({ where: { id: input.id } });
return { success: true };
}),
});
// 导出路由类型 — 这是魔法的关键
export type AppRouter = typeof appRouter;
客户端:零代码生成,直接使用类型
// client/trpc.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // 只导入类型,不导入运行时代码
// 创建客户端
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
});
// 使用 — 类型完全来自服务端定义
async function example() {
// 参数类型:{ id: string }(从 z.object({ id: z.string().uuid() }) 推导)
const user = await trpc.getUser.query({ id: '123e4567-e89b-12d3-a456-426614174000' });
// user 类型:{ id: string; name: string; email: string; createdAt: Date }
// 完全来自 Prisma 查询 + Prisma 生成的类型
console.log(user.name.toUpperCase()); // string 方法可用
console.log(user.createdAt.toISOString()); // Date 方法可用
// 传错类型会立即报错
// await trpc.getUser.query({ id: 123 }); // 错误:number 不能赋给 string
}
React 中使用 tRPC + TanStack Query
// client/trpc-react.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
// App.tsx — 设置 Provider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc' })],
});
function App() {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
</trpc.Provider>
);
}
// UserList.tsx — 使用
function UserList() {
// data 类型:{ users: User[]; total: number; page: number; pageSize: number } | undefined
const { data, isLoading, error } = trpc.listUsers.useQuery({
page: 1,
pageSize: 20,
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.users.map(user => (
// user.name 是 string — 类型安全
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// 变更操作
function CreateUserForm() {
const createUser = trpc.createUser.useMutation({
onSuccess: (newUser) => {
// newUser 类型:User — 完整的 Prisma 模型
console.log(`Created: ${newUser.id}`);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
createUser.mutate({
name: (form.elements.namedItem('name') as HTMLInputElement).value,
email: (form.elements.namedItem('email') as HTMLInputElement).value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
类型共享的机制:import type { AppRouter } 只导入 TypeScript 类型信息,不导入任何运行时代码。构建工具在打包前端时会完全擦除这个 import。服务端代码不会出现在客户端 bundle 里。
Level 2 · 它是怎么运行的(3-5年经验)
Prisma:Schema 优先,类型自动生成
Prisma 的工作流程:写 schema.prisma → 运行 prisma generate → 获得完整类型。
// schema.prisma
model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Tag {
id String @id @default(uuid())
name String @unique
posts Post[]
}
Prisma 查询的返回类型随 select/include 变化
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
// 基础查询 — 返回完整 Post 对象
const post = await prisma.post.findUnique({ where: { id } });
// 类型:Post | null
// select — 只返回指定字段
const postTitle = await prisma.post.findUnique({
where: { id },
select: { id: true, title: true },
});
// 类型:{ id: string; title: string } | null(精确匹配 select)
// include — 添加关联数据
const postWithAuthor = await prisma.post.findUnique({
where: { id },
include: { author: true },
});
// 类型:(Post & { author: User }) | null
// 嵌套 select/include
const postWithTags = await prisma.post.findUnique({
where: { id },
include: {
tags: { select: { name: true } },
author: { select: { id: true, name: true } },
},
});
// 类型:(Post & { tags: { name: string }[]; author: { id: string; name: string } }) | null
Prisma.XxxGetPayload:提取复杂查询类型
// 当你需要把查询结果类型传递给其他函数时,用 GetPayload
type PostWithRelations = Prisma.PostGetPayload<{
include: {
author: { select: { id: true; name: true } };
tags: { select: { name: true } };
};
}>;
// 等价于:
// Post & {
// author: { id: string; name: string };
// tags: { name: string }[];
// }
// 在函数签名中使用
function formatPost(post: PostWithRelations): string {
return `${post.title} by ${post.author.name} [${post.tags.map(t => t.name).join(', ')}]`;
}
Prisma 事务类型
// 事务中所有操作类型安全
const result = await prisma.$transaction(async (tx) => {
// tx 类型与 prisma 相同,但在事务内
const user = await tx.user.create({ data: { name: 'Alice', email: '[email protected]' } });
const post = await tx.post.create({
data: {
title: 'Hello',
authorId: user.id, // user.id 是 string — 类型安全
},
});
return { user, post };
});
// result 类型:{ user: User; post: Post }
Drizzle:代码优先,更紧密的 TypeScript 集成
Drizzle 把 schema 定义在 TypeScript 文件里,不需要单独的 schema 语言。
npm install drizzle-orm @drizzle-kit pg
// schema.ts — 用 TypeScript 定义 schema
import { pgTable, text, integer, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
content: text('content'),
published: boolean('published').default(false).notNull(),
authorId: uuid('author_id').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// 定义关联
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
// db.ts — 使用 schema
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import { eq, like, and, desc } from 'drizzle-orm';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool, { schema });
// 查询:类型从 schema 自动推导
const user = await db.select().from(schema.users).where(eq(schema.users.id, id)).limit(1);
// 类型:{ id: string; name: string; email: string; age: number; createdAt: Date }[]
// insert 类型安全
const newUser = await db.insert(schema.users).values({
name: 'Alice',
email: '[email protected]',
age: 30,
// 传入不存在的字段会编译报错
}).returning();
// newUser 类型:{ id: string; name: string; email: string; age: number; createdAt: Date }[]
// 带关联的查询
const userWithPosts = await db.query.users.findFirst({
where: eq(schema.users.id, id),
with: { posts: true },
});
// 类型:{ id: string; name: string; ...; posts: Post[] } | undefined
Drizzle 的类型工具
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
// 从 schema 推导 Select 类型(查询结果)
type User = InferSelectModel<typeof schema.users>;
// { id: string; name: string; email: string; age: number; createdAt: Date }
// 从 schema 推导 Insert 类型(插入参数,id/createdAt 是可选的)
type NewUser = InferInsertModel<typeof schema.users>;
// { name: string; email: string; age: number; id?: string; createdAt?: Date }
Prisma vs Drizzle vs TypeORM 对比
| 维度 | Prisma | Drizzle | TypeORM |
|---|---|---|---|
| Schema 定义 | 独立的 .prisma 文件 |
TypeScript 代码 | 装饰器 + 类 |
| 类型推导精度 | 极高(select 影响返回类型) |
极高 | 中(返回完整实体) |
| 迁移工具 | 内置(Prisma Migrate) | 内置(drizzle-kit) | 内置 |
| 原始 SQL | $queryRaw + 模板字符串 |
sql 标签模板 |
query() |
| Bundle 大小 | 较大(有 query engine) | 小(纯 JS) | 中 |
| 关联查询 | include/关联 API |
with / join |
relations 装饰器 |
| 学习曲线 | 低(DSL 简单) | 中(需要理解 SQL 抽象) | 高(装饰器 + 复杂配置) |
| 适合场景 | 快速开发,schema 驱动 | 需要精细 SQL 控制 | 已有 Java/C# ORM 经验 |
TypeORM 的类型安全最弱:关联查询返回 Promise<Entity> 而不是精确类型,find 方法返回完整实体(无法按 select 缩窄),大量使用 any 和类型断言。新项目一般不再选择 TypeORM。
Level 3 · 规范怎么定义的(资深)
tRPC 的零代码生成方案利用了 TypeScript 的 import type 语法——客户端只导入服务端路由的类型信息,不导入任何运行时代码。构建工具在打包前端时会完全擦除 import type 语句。Prisma 的查询返回类型随 select/include 参数动态变化,这通过深度条件类型和映射类型实现。Drizzle 的 InferSelectModel/InferInsertModel 从 schema 定义中推导出查询结果和插入参数的精确类型,利用了 TypeScript 的类型推断和条件类型。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 在前端重复定义后端已有的接口 | 类型漂移的根源 | 用 tRPC 的 AppRouter 或 OpenAPI codegen 共享类型 |
res.json() as User 强制断言 |
API 返回任何内容都不报错 | tRPC 保证类型,或用 zod 验证响应 |
Prisma 查询结果不用 GetPayload,手写接口 |
接口与实际结果不一致,漂移 | Prisma.UserGetPayload<typeof query> |
Drizzle schema 改了,忘记更新 InferSelectModel 的用法 |
不会漂移(类型从 schema 自动推导),这不是问题 | 保持使用推导类型,不要手写 |
tRPC procedure 不加 .input() 验证 |
接受任意输入,运行时可能崩溃 | 总是用 zod schema 定义输入类型 |
总结
| 方案 | 何时选 |
|---|---|
| tRPC | 全栈 TypeScript,同一 monorepo,快速迭代 |
| OpenAPI + codegen | 已有 REST API,多语言团队,需要文档 |
| GraphQL + codegen | 复杂查询需求,移动端,多前端客户端 |
| Prisma | schema 优先,快速开发,需要强大的迁移工具 |
| Drizzle | 需要精细 SQL 控制,bundle 大小敏感,代码优先 |
下一章讨论如何测试 TypeScript 代码:运行时测试(vitest mock 类型化)和类型级测试(用 tsd 验证类型的正确性),以及如何把类型错误集成进 CI 流程。