资讯详情

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

📅 2026/10/26 16:48:28 | 华诺云谱 👁 阅读
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.infertypeof 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.contextContext().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.yamlpackages: - 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/node3.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 generateprisma 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): PromiseTrpcContext { 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 createTRPCProxyClientAppRouter({ 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] useStateAwaitedReturnTypetypeof trpc.article.list.query([]); useEffect(() { trpc.article.list.query().then(setArticles); }, []); return ( ul {articles.map((article) ( li key{article.id}{article.title}/li ))} /ul ); }这里有个实用小技巧如果想拿到某个查询的返回类型不需要手动声明接口可以直接用AwaitedReturnTypetypeof 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 配置上。我的排查思路是这样的确认后端服务能正常编译。如果 server 包的代码里有语法错误类型生成就会中断。检查tsconfig.json的 paths 配置。monorepo 里前后端可能各自有 tsconfig但类型引用链必须畅通。最简单的做法是在根目录建一个tsconfig.base.json公共的编译选项放在里面各包继承它。如果用了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()表示字段可以不存在值为undefinedz.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 generateZod 校验错误没有统一格式缺少 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 流程就会发现每一步都很顺因为这些底层结构已经把“坑”提前填平了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑