第 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 评分)

💬 留言讨论