ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript全栈端到端类型安全实践:tRPC+Zod+Prisma详解

TypeScript全栈端到端类型安全实践:tRPC+Zod+Prisma详解

最近在折腾新的全栈项目的时候,我把“t3code”从头到尾捋了一遍。它本质上是一套以 TypeScript 为核心、围绕端到端类型安全来组织的工程化实践:从前端路由、数据请求,到后端接口、数据库查询,所有环节都共享同一份类型定义和校验规则。这样做的好处非常直接——改动一个字段,编译器和编辑器会帮我把所有受影响的地方都标出来,而不是等到接口联调时才被运行时错误砸中。

这篇内容不是官方文档的翻译,而是我从初始化仓库到跑通核心链路之后整理的完整笔记。如果你正在做前后端分离的项目,经常因为接口字段对不上而反复沟通,或者想找一个适合中小团队快速起步的 TypeScript 全栈模板,那这篇文章应该能帮你省下不少弯路。文里涉及的工具选型、目录结构、关键配置和排查思路,都是可以直接落到自己项目里对照使用的。

1. 项目整体设计与技术选型

1.1 为什么选择 TypeScript 全栈同构

先聊一个最基础的问题:为什么要把 TypeScript 同时用在前端和后端。很多项目其实前端用了 TypeScript,后端还是 JavaScript,或者是 Java、Go 这类别的语言。这两种形态我都经历过,最直观的感受是——类型断层。

前端定义了一个User接口,后端返回的数据里可能多了一个字段、少了一个字段,或者某个字段换个了名字。这种错误在编译期完全看不出来,只有等到页面渲染出来才发现数据是 undefined。更难受的是,一旦接口有变更,前端文档和后端代码很容易失同步,新人接手的时候根本不知道该信谁。

t3code 的思路是:既然前后端都用 TypeScript,那就让它们共享同一份类型定义,甚至让接口本身的调用方式也被类型约束。这样一来,数据流从数据库到 UI 的每一跳都是可追踪的,改一处,编译器就会提示你改下一处。这不是什么高深的设计理念,但它能实实在在减少“低级错误”在联调阶段的占比。

1.2 核心方案选型背后的取舍

整个方案围绕三个核心组件展开:

组件选择理由
后端接口层tRPC无需手写 REST 路由和文档,过程调用天然携带类型
数据校验Zod与 TypeScript 类型无缝集成,运行时校验与编译期类型互转
ORM 与数据库Prisma + SQLite开发环境零配置,schema 即类型来源,迁移方便

tRPC 可能是这套方案里最值得解释的一个选择。很多人第一次看到 tRPC 会疑惑:它和 REST、GraphQL 有什么区别?简单说,REST 是“面向资源”的,你需要定义 URL、方法、参数和返回结构;GraphQL 是“面向查询”的,你需要定义 schema 和 resolver。而 tRPC 是“面向函数”的——你直接在后端写一个函数,前端像调用本地函数一样调用它,类型自动推导。

这个取舍对中小项目尤其友好:不需要维护接口文档,不需要生成 client SDK,前后端共用一套类型,改完后端函数签名,前端调用处立刻报错。缺点是它更适合前后端都由你控制的场景,如果未来要开放 API 给第三方,还是得另外做一层 REST 或 GraphQL 网关。

Zod 的加入则是为了补上 TypeScript 在运行时的盲区。TypeScript 的类型只在编译期存在,运行时数据是否真的符合预期,TypeScript 管不了。Zod 让同一份 schema 既能在编译期推导出类型,又能在运行时做校验,等于给接口穿上了一层防弹衣。

Prisma 的选择更多是从开发效率出发。SQLite 在开发环境下不需要安装任何外部服务,数据存在一个文件里,配合 Prisma Migrate 可以像 Git 一样管理数据库结构的变更。

1.3 这套方案替我省掉了什么

说实话,我见过太多项目花费大量精力在“接口对接”这件本不该消耗太多精力的事情上。传统的开发流程大致是这样:后端写接口,然后写 Swagger 文档或 Postman 集合,前端照着文档 mock 数据、调接口、处理各种边界情况,遇到字段对不上还要反复在群里确认。

t3code 把这条链路上的很多环节直接消灭了。后端函数签名就是接口契约,前端的调用自动获得完整的类型提示和约束。如果后端修改了返回结构,前端代码里的类型会立刻告诉你哪里需要同步修改。同时 Zod 的运行时校验也会拦截来自外部的脏数据,而不是让脏数据流到业务逻辑里才暴雷。

对一个三到五人的小团队来说,这套组合能省下的是大量“沟通成本”和“联调成本”,换来的是“改得动、查得到、编得过”的安心感。

2. 核心架构拆解:类型共享与 API 设计

2.1 仓库结构与数据流设计

先看整个仓库的目录结构。我采用的是 pnpm workspace 管理的 monorepo 形式,把“类型定义”“后端逻辑”“前端应用”拆到不同的包中,依赖关系清晰,改动互不污染:

t3code/ ├── apps/ │ ├── web/ # Next.js 前端应用 │ └── server/ # Node.js 后端服务,tRPC 路由在这里定义 ├── packages/ │ └── shared/ # 共享 Zod schema 与 TypeScript 类型 ├── package.json ├── pnpm-workspace.yaml └── tsconfig.base.json

数据流的方向是单向且明确的:Prisma Schema 定义数据库结构 -> Zod Schema 定义输入输出边界 -> tRPC Router 定义业务过程 -> Next.js 前端调用过程获取数据。这里面最巧妙的地方在于,Prisma 生成的类型和 Zod 推导出的类型都汇入到shared包中,前端和后端各自不维护自己的“接口模型”,只有一份真相。

举个例子。假设我们要实现一个“创建文章”的功能。传统的写法是:后端定义一个CreateArticleRequest,前端自己再定义一个几乎一模一样的接口,然后手动保证两者一致。在 t3code 里,只需要在shared包中用 Zod 写一份:

import { z } from 'zod'; export const CreateArticleSchema = z.object({ title: z.string().min(3).max(100), content: z.string().min(10), tags: z.array(z.string()).max(5).default([]), }); export type CreateArticleInput = z.infer<typeof CreateArticleSchema>;

这份 schema 既是前端的 TypeScript 类型来源,也是后端接口的运行时校验标准,两者永远不可能不一致。

2.2 tRPC Router 的定义与上下文处理

后端部分的核心是 tRPC 的 router 定义。很多教程会直接写一个简单的 procedure,但实际项目中还需要考虑认证、日志、错误处理这些横切关注点。t3code 的做法是先创建 router 工厂,再把公共逻辑挂进去:

// apps/server/src/router.ts import { initTRPC, TRPCError } from '@trpc/server'; import type { Context } from './context'; const t = initTRPC.context<Context>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } return next({ ctx: { ...ctx, user: ctx.user } }); }); export const appRouter = t.router({ article: t.router({ create: protectedProcedure .input(CreateArticleSchema) .mutation(async ({ ctx, input }) => { // 使用 ctx.prisma 写入数据库 return ctx.prisma.article.create({ data: { ...input, authorId: ctx.user.id, }, }); }), list: publicProcedure.query(async ({ ctx }) => { return ctx.prisma.article.findMany({ orderBy: { createdAt: 'desc' }, }); }), }), }); export type AppRouter = typeof appRouter;

这里的Context是连接数据库、获取用户信息的桥梁,通常由 HTTP 中间件创建。对于需要鉴权的接口,protectedProcedure会在进入业务逻辑之前检查用户是否已登录,未登录则抛出UNAUTHORIZED错误,前端会自动收到对应的错误类型。

值得注意的是 tRPC 对新手的门槛并不在语法本身,而在“怎么想清楚 router 的嵌套结构”。我习惯按业务域来拆:每个域的 router 是一个独立模块,顶层 router 只做聚合。这样单个文件的体积可控,多人协作时也几乎不会产生冲突。

2.3 共享类型的三层防护

类型共享并不只是“让 TypeScript 编译通过”这么简单。t3code 里我默认带了三层防护,每一层负责挡掉不同类型的问题:

第一层是编译期防护。前端代码里调用trpc.article.create.useMutation(),传入参数的类型必须满足CreateArticleInput,编辑器里会有完整的字段提示,类型不对直接在编译期报错。

第二层是运行时输入校验。即使请求到达了后端,Zod 依然会重新校验一次。这层能防住的是“绕过前端直接调接口”的行为,或者某些历史遗留客户端发送的不规范数据。很多安全问题和数据脏化问题,在这一层就可以拦截掉大部分。

第三层是输出序列化。Zod 的parse可以在返回数据之前再做一次清洗,把undefined转成null,剔除多余的字段,保证前端拿到的数据一定是干净的。这样做的好处是前端不需要写一堆防御性代码,可以更信任数据的结构。

三层的思路其实很像进高铁站的安检:进站前查一次票,安检处查一次违禁品,站台上再核对一下车次,多层的冗余不是为了重复劳动,而是为了在各自环节尽早发现并拦截问题。

3. 实操过程:从初始化到跑通核心链路

3.1 环境准备与仓库初始化

实操部分我用的是 macOS + pnpm 环境。Windows 用户需要注意 PowerShell 的脚本执行策略限制,其余步骤基本一致。

初始化项目时我先建了根目录和 workspace 配置:

mkdir t3code && cd t3code pnpm init

然后编辑pnpm-workspace.yaml:

packages: - 'apps/*' - 'packages/*'

根目录的package.json只需要保留 private 字段和统一的脚本命令。这里有个细节:根目录的依赖尽量少放,只放那些所有包都需要的东西,比如typescript和tsx,业务依赖一律放到对应包中。这样可以减少幽灵依赖——也就是某个包明明没声明却能用到的依赖,后续升级时很容易出问题。

接下来创建两个 app 和一个 shared 包。为了省事,可以直接用模板创建 Next.js 应用:

pnpm create next-app apps/web --ts --eslint --app

后端服务不需要框架,只需要一个能处理 HTTP 请求的轻量服务器。tRPC 官方推荐结合 Next.js 的 API 路由使用,但我这里想让边界更清晰一点,单独起一个 Node 服务,接入 tRPC 的独立适配器:

mkdir -p apps/server/src cd apps/server pnpm add @trpc/server zod prisma @prisma/client pnpm add -D typescript tsx @types/node

3.2 Prisma 数据库模型与迁移流程

Prisma 的 schema 是整个系统的“数据源头”。我在apps/server/prisma/schema.prisma中定义了一个最简单的用户-文章模型:

generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = "file:./dev.db" } model User { id String @id @default(cuid()) email String @unique name String? articles Article[] } model Article { id String @id @default(cuid()) title String content String tags String @default("[]") author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里tags在 SQLite 里没有数组类型,所以用 JSON 字符串存储。这一点要注意,后续从 Prisma 读出来时还需要JSON.parse,我通常在 shared 包里做一个ArticleSchema来处理这个转换,而不是散落在业务代码里。

写完 schema 后执行迁移:

cd apps/server pnpm prisma migrate dev --name init pnpm prisma generate

prisma generate会根据 schema 生成完整的 TypeScript 类型定义,之后在代码里import { PrismaClient } from '@prisma/client',所有模型都有自动补全和类型检查。

数据库层面的“设计原则”其实很简单:减少枚举和复杂的默认值逻辑,把校验规则交给 Zod 层处理。这里我的建议是,Prisma 只负责“存储结构”,不负责复杂的业务约束。比如文章标题不能超过 100 字,这个约束写在 Zod 里比写在数据库层面更灵活——将来如果某个调用方需要放宽限制,改一处 schema 即可,不用动数据库。

3.3 tRPC 后端接入与错误处理

后端服务的入口文件比较简单。我用了@trpc/server/adapters/standalone,这样可以避免引入 Express、Fastify 这类额外的框架,减少理解负担:

// apps/server/src/index.ts import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { appRouter } from './router'; import { createContext } from './context'; const server = createHTTPServer({ router: appRouter, createContext, }); server.listen(4000, () => { console.log('tRPC server running on http://localhost:4000'); });

createContext是一个同步或异步函数,每次请求都会执行。它的返回值会成为所有 procedure 的ctx参数。我在里面做了两件事:创建 PrismaClient 实例(并挂到一个全局单例上,避免开发环境热更新时创建太多连接),以及从请求头解析用户信息:

// apps/server/src/context.ts import { PrismaClient } from '@prisma/client'; import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone'; import type { TrpcContext } from './types'; const prisma = new PrismaClient(); export async function createContext({ req }: CreateHTTPContextOptions): Promise<TrpcContext> { const token = req.headers.authorization?.replace('Bearer ', ''); const user = token ? { id: 'mock-user-id' } : null; return { prisma, user }; }

这里的用户解析是 mock 的,真实项目中一般会接 JWT 校验或 session 中间件。但核心思想是:把与业务无关的解析过程放到 context 层,业务代码里不需要关心 token 是从哪来的、怎么校验的,只从ctx.user里拿当前用户即可。

错误处理方面,tRPC 的错误对象自带 code 字段,像UNAUTHORIZED、NOT_FOUND、BAD_REQUEST这些语义在前端可以直接映射成对应的用户提示。我在 router 外层包了一个 error formatter,把 Zod 的校验错误转换成统一的格式,前端拿到后就能在表单上逐字段展示错误信息。

3.4 前端接入 tRPC Client

前端部分是最能感受到“类型收益”的地方。我用@trpc/client创建一个泛型化的 api 实例:

// apps/web/src/utils/trpc.ts import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from 'server/src/router'; import { superjson } from './superjson'; export const trpc = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:4000/trpc', headers() { return { Authorization: 'Bearer mock-token' }; }, }), ], transformer: superjson, });

注意到没有,前端直接引入了server/src/router的类型。这一步是实现类型安全的“魔法时刻”,它意味着前端编译器会拿到后端 router 的完整结构,所有可调用的 procedure、输入输出类型一目了然。

在 React 组件中使用时,由于我没有用 @tanstack/react-query,直接用useEffect手动调用:

// apps/web/src/app/page.tsx 'use client'; import { useEffect, useState } from 'react'; import { trpc } from '../utils/trpc'; export default function HomePage() { const [articles, setArticles] = useState<Awaited<ReturnType<typeof trpc.article.list.query>>>([]); useEffect(() => { trpc.article.list.query().then(setArticles); }, []); return ( <ul> {articles.map((article) => ( <li key={article.id}>{article.title}</li> ))} </ul> ); }

这里有个实用小技巧:如果想拿到某个查询的返回类型,不需要手动声明接口,可以直接用Awaited<ReturnType<typeof trpc.article.list.query>>推导出来。这种做法在数据流复杂的时候特别好用,类型永远和真实接口保持一致,绝对不会出现“代码里写的类型和实际返回不匹配”的问题。

当然,实际项目中通常会配合 React Query 来处理缓存、重试、竞态这些逻辑,但手动调用能更清晰地让你看到 tRPC 客户端的工作原理。我建议你第一次做 demo 时先这样写一遍,理解了链路之后再加上数据请求库也不迟。

3.5 联调测试与脚本编排

前后端都启动之后,联调的过程几乎可以用“无缝”来形容。浏览器控制台不需要打开 Network 面板去对字段,代码编辑器已经告诉了你一切。但为了日常开发方便,我在根目录的package.json里编排了几个脚本:

{ "scripts": { "dev": "pnpm --parallel -r dev", "build": "pnpm -r build", "lint": "pnpm -r lint", "db:migrate": "pnpm --filter server db:migrate" } }

pnpm --parallel -r dev会同时启动 server 和 web 两个 dev 进程,终端里会看到两者的日志输出混在一起。如果嫌乱,可以用pnpm --filter单独启动某一个应用来排查问题。

运行到这里,一个最小可用的 t3code 项目就已经跑起来了。此时你应该能看到:前端页面上成功拉取到后端返回的文章列表,并且如果你试着在调用trpc.article.create时传入一个缺少title的对象,编译器和后端会同时给出错误提示。

4. 常见问题与排查技巧实录

4.1 类型推导失效怎么办

这是新手使用 tRPC + monorepo 时最容易踩的坑:前端代码里trpc.article.list这一行没有任何类型提示,或者报“找不到 module”。绝大多数情况下,问题出在 TypeScript 配置上。

我的排查思路是这样的:

  1. 确认后端服务能正常编译。如果 server 包的代码里有语法错误,类型生成就会中断。
  2. 检查tsconfig.json的 paths 配置。monorepo 里前后端可能各自有 tsconfig,但类型引用链必须畅通。最简单的做法是在根目录建一个tsconfig.base.json,公共的编译选项放在里面,各包继承它。
  3. 如果用了@trpc/client的createTRPCProxyClient,需要注意泛型参数必须是一个具体的Router类型,而不是typeof appRouter的某个中间变量。有时候因为循环引用或import type使用不当,类型信息会成为any,这时可以检查一下 server 的路由是否正常导出。

另外还要注意,server/src/router里的代码如果包含了只应该在 Node 环境运行的代码(比如直接访问process.env),前端导入类型时会把整个模块引入,可能导致编译错误。解决办法是把类型导出和运行时逻辑分离,或者确保 server 包里的环境变量读取都有默认值。

4.2 嵌套对象校验与 Zod 报错信息优化

Zod 的报错信息默认是英文,而且对于嵌套对象,错误路径像tags.0这种,直接展示给用户不太友好。我在项目里写了一个错误格式化函数:

import { z } from 'zod'; export function formatZodError(error: z.ZodError) { return error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message, })); }

在 mutation 的处理函数里,如果校验失败,tRPC 会抛出一个BAD_REQUEST错误,前端可以捕获它并解析成表单错误。实际经验是,给 Zod 的每个字段加上可读的message,比如z.string().min(3, { message: '标题至少需要3个字符' }),比在错误处理层统一翻译省事得多。

还有一个容易被忽略的细节:Zod 的default和optional的使用场景要分清楚。z.string().optional()表示字段可以不存在,值为undefined;z.string().default('')表示字段不存在时会替换为默认值。对于“用户没填但后端需要有个值”的场景,务必用default而不是optional,这样能避免后端逻辑里到处处理 undefined。

4.3 跨域、认证与批量请求的取舍

开发环境下前端跑在 3000 端口,后端在 4000 端口,浏览器会发起跨域请求。我直接在 standalone server 上开启了 CORS 中间件,允许 origin 为http://localhost:3000。这个配置在生产环境是一定要收紧的,否则任何网站都可以向你的后端发请求。

认证方面,生产项目建议使用 HTTPS + HttpOnly Cookie 来承载 session,而不是把 token 放在 localStorage。tRPC 的httpBatchLink支持自定义headers()函数,可以从 cookie 中读取 token 再附加到请求头里,这样能实现“浏览器自动携带凭证”的效果。

httpBatchLink是另一个需要重点理解的配置。它会把同一时间内的多个请求自动合并成一个batch请求发送,减少网络往返。但要注意:如果某些请求是文件上传、流式响应这类不适合批处理的场景,需要单独配置一个非批处理的 link,或者把它们放到独立的 procedure 中发送。

4.4 数据库连接与查询性能备忘

SQLite 在开发环境很顺手,但生产部署时并发写入能力有限。建议部署时切换到 PostgreSQL 等生产级数据库,只需修改 Prisma 的 datasource 配置和连接地址,代码层面几乎不需要改动。这也是把 Prisma 放在架构里的好处——数据库切换对业务代码的冲击被降到了最低。

另一个常见的性能问题:查询文章列表时,如果没有做分页,数据量大之后前端渲染会明显变慢。t3code 的示例里我用的是最简单的findMany(),但你实际业务中一定要补上分页参数。Prisma 支持skip和take,配合orderBy可以实现稳定的游标式分页。

4.5 常见问题速查表

现象可能原因解决方式
前端没有类型提示tsconfig paths 配置缺失或 server 包未构建检查根 tsconfig,确保 server 的导出声明正确
调用 mutation 后收到 500数据库迁移未执行或 Prisma Client 未生成执行pnpm prisma migrate dev和pnpm prisma generate
Zod 校验错误没有统一格式缺少 error formatter在 tRPC 初始化时传入errorFormatter,统一错误结构
多个请求在 Network 面板显示为一个这是httpBatchLink的预期行为不需要处理,若不想批量可改用httpLink
部署后接口地址不对前端写死了localhost:4000改用环境变量注入 API 地址,构建时替换

这些坑大多数都属于“配置问题”而不是“设计问题”,也就意味着花一次时间理顺之后,后续项目都可以复用同一套模板,成本被大幅度摊薄。

5. 从 Demo 到实际项目:扩展建议

5.1 认证体系与权限控制的演进

当前示例里的ctx.user是一个 mock 对象,真实项目中显然不能这样。我建议的演进路径是:先接入一个成熟的认证库,比如 NextAuth 或者 Lucia,让登录流程、session 管理、cookie 设置都由库来处理。tRPC 的 context 层只需要做一个映射——从请求中解析出用户 ID,再从数据库加载完整的用户信息。

权限控制建议在 router 层面做“中间件式”的下发。比如角色是admin的用户才能调用某个 mutation,那就在 procedure 链上加一个adminProcedure,逻辑上复用protectedProcedure+ 角色检查。不要在各个 mutation 里分散地手写权限判断,那样既容易漏,又难维护。

5.2 服务端渲染与数据获取的取舍

Next.js 应用中有一个常见的团队争论点:应该用客户端组件配合 tRPC,还是在服务端组件里直接调用 tRPC 查询。我的建议是:需要实时数据的场景走客户端,需要 SEO 的场景走服务端。

页面对搜索引擎友好时,你需要在服务端把数据拿好再渲染。这时可以用 tRPC 的 server-side caller,也就是不通过 HTTP,而是直接在 Node 进程内调用 router 的方法。这种方式不仅省掉了一次网络请求,还能保持类型安全。

但要注意:服务端调用时就不会走 HTTP 层的 header 解析了,所以 context 里的认证信息需要手动注入。通常我会在页面的服务端代码里先从 Next.js 的 request 中拿到 session,再构造对应的 context 传给 caller。

5.3 把 t3code 做成内部模板

实际操作中,你会发现每次从零搭建这份结构还是挺花时间的。所以我的建议是,把已经调通的这个仓库打一个 tag,或者推到 Git 模板仓库,作为团队新项目的基础。内部可以沉淀一份 README,写清楚:

  • 新增一个业务模块需要改哪些文件
  • 数据库迁移的流程是什么
  • 前后端类型不一致时从哪里排查
  • 部署时有哪些环境变量需要配置

这些内容其实比业务代码本身更有价值。因为代码是“一次性”的,而规范是“每时每刻”在影响团队效率的。

我个人在实际操作中的体会是:t3code 的核心并不是某个具体工具,而是“类型安全优先”这件事带来的连锁反应。刚开始会觉得多了一个需要学习的 tRPC、Zod,多了一些抽象的层级,但当你经历过一次“改一个字段导致所有调用处同步更新”的体验,就很难再退回从前那种靠人肉维护接口一致性的开发模式了。

最后再分享一个小技巧:在 React 项目中接入 tRPC 后,可以配合 TypeScript 的satisfies关键字约束组件 props 与某个 procedure 输入的映射关系。这样前端每个页面组件都明确知道自己消费的是哪个接口,代码的可读性和可维护性会有质的提升。之后你再往这个项目里加日志、监控、CI 流程,就会发现每一步都很顺,因为这些底层结构已经把“坑”提前填平了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进