第 22 章

全栈类型安全:tRPC 原理 + Prisma/Drizzle ORM

第22章:全栈类型安全:tRPC 原理与 Prisma/Drizzle ORM 类型推导

理解全栈类型安全是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用tRPC 原理与 Prisma/Drizzle ORM 类型推导?关键的设计决策和陷阱是什么?

读完本章你将理解:


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 流程。

本章评分
4.5  / 5  (7 评分)

💬 留言讨论