最近在折腾新的全栈项目的时候,我把“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/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): 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 配置上。
我的排查思路是这样的:
- 确认后端服务能正常编译。如果 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()表示字段可以不存在,值为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 流程,就会发现每一步都很顺,因为这些底层结构已经把“坑”提前填平了。