尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
t3code实战:以TypeScript类型系统终结API文档,实现端到端类型安全的全栈应用
接触 TypeScript 差不多三年我越来越觉得前后端之间的那一层 API 文档是整个开发链路里最脆弱的东西。前端同学按文档调接口后端同学按文档出接口两边各写各的文档一过期线上就出事。t3code 这个项目就是我在这种背景下做的一个实验把一整套业务从数据库到页面全部放进 TypeScript 类型系统里让沟通直接变成类型检查。简单说t3code 是个基于 T3 Stack 的全栈应用代码库核心成员是 Next.js TypeScript tRPC Prisma Tailwind CSS。这条技术路线在圈子里越来越常见但真正把它跑通、跑稳、跑出工程化细节的人不算多。这篇文章不是官方文档的复述而是我把一个真实项目从零搭到上线、再维护几个月的全过程拆解里面会包括每一步为什么这样选型、实际踩过的坑以及那些文档里不会写的小教训。不管你是想入门的全栈新手还是已经写过不少 tRPC 想看看别人怎么组织的同学应该都能从中找到点东西。1. 被接口文档坑过之后t3code 想解决的那件小事1.1 一次联调事故是怎么改变我的写法的去年有个内部工具项目后端接口/api/articles/list返回结构里有一个字段叫tags原本是string[]后端某次重构把它改成了{ id: string; name: string }[]。文档没更新前端还是按字符串数组渲染结果页面上所有文章标签全部显示成[object Object]。这种问题不算致命但排查起来很糟心——前端没错后端也没错错的是文档这个沟通介质本身。t3code 的出发点就是干掉这层介质。如果前后端共享同一套类型定义字段改了TypeScript 编译直接报错根本走不到联调环节。这就是为什么我最终选择 T3 Stack 而不是其他方案它的核心思路不是少写代码而是让编译器替你把关。1.2 我理解的 T3 Stack 不是全家桶很多人把 T3 Stack 理解成一个脚手架套餐其实不太准确。它更像一组约定Next.js 负责渲染和路由TypeScript 负责把类型串起来tRPC 负责端到端类型的 API 层Prisma 负责数据库建模Tailwind 负责样式。五个工具单独拿出来都不算稀奇但它们组合起来有一个关键效果——类型从数据库表、到 API 参数、再到组件 props全程贯通。我给这个项目起名 t3code一方面是因为核心是那三个 TTypeScript、Tailwind、tRPC另一方面是它确实代表了我写代码的方式变化从先想接口、再写两边变成先写 schema、再让类型自己长出来。1.3 项目范围和功能边界t3code 不是一个玩具 demo也不是一个大而全的中台系统我刻意把它控制在一个真实但不过大的范围内用户注册登录、文章/笔记的增删改查、标签归类、基于关键词的简单检索。功能看着朴素但足够覆盖 tRPC 的 query、mutation、middleware、分页、错误处理这些核心用法也足够暴露我后面要讲的坑。如果一上来就奔着复杂业务去学习成本会被业务复杂度稀释最后变成调框架而不是理解框架。先在小项目里把链路吃透再往大处铺这是我给所有想尝试这套栈的人的第一条建议。2. tRPC 链路拆解一个函数调用如何在前后端之间流动2.1 tRPC 不是黑魔法它只是在 POST 一个 JSON很多初看 tRPC 的人会困惑前端的useQuery里明明是在调用函数它怎么知道该请求哪个地址答案其实很简单——tRPC 在运行时只做了一个动作把 procedure 的路径序列化进请求体发给唯一的 HTTP 端点。// server 端定义 const appRouter router({ article: { list: publicProcedure .input(z.object({ keyword: z.string().optional() })) .query(({ input }) listArticles(input)), }, }); // 前端调用 const { data } api.article.list.useQuery({ keyword: t3 });这个useQuery展开之后发送的请求就是一行 POST路径长这样/api/trpc/article.list?batch1。服务端收到之后按路径找到对应的 procedure执行函数把结果序列化传回。所谓端到端类型安全本质上就是用 TypeScript 的类型推断能力让编译器根据服务端的 router 类型推导出客户端的调用签名。这里没有运行时魔法也不涉及代码生成纯粹是类型层面的映射。我用一个生活化的类比理解它tRPC 不是什么通信协议它只是把电话总机换成了直接拨分机号。每个 procedure 就是一台分机路由路径就是分机号前端拨号时输入法TypeScript已经提前把每个分机能说什么话、该带什么参数都告诉你了。2.2 Router 与 Procedure 的划分策略实践下来router 的划分不要照着 一个表一个 router 的思维来而要按业务能力域来分。t3code 里我分了三个核心 routerRouter职责典型 procedureauthRouter登录态、注册、会话信息login.mutation、me.queryarticleRouter文章 CRUD、列表分页、检索list.query、detail.query、create.mutationtagRouter标签聚合、热门标签popular.query、list.query每个 router 内部保持只做一件事。比如 article 的list.query只负责查询不负责统计统计标签频率的事情放给 tagRouter。这样做的直接好处是后续调用useUtils().article.list.invalidate()时缓存失效的粒度可控不会因为一个 router 塞了太多不相干的逻辑而误伤整片客户端缓存。2.3 认证上下文把用户信息接进类型系统用户登录之后每个 procedure 内部怎么知道当前是谁tRPC 的做法是通过createContext在每次请求时组装上下文对象。// server/trpc.ts export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerAuthSession(); return { session, db }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const publicProcedure t.procedure; export const protectedProcedure t.procedure.use(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); });这一步是整个链路里最有价值的设计之一。一旦protectedProcedure里的ctx.user被类型标注为必定存在的用户对象所有受保护路由内部就不用再各处判断if (!ctx.user) return因为类型系统已经帮你保证了能走到这个函数体里用户必然已登录。我实测下来这能删掉差不多三分之一的安全检查代码而且比手写的判断更可靠。需要提醒的是createTRPCContext会在每个请求时执行所以里面不要放重量级初始化操作比如每次 new 一个 PrismaClient——那会让连接池管理彻底失效内存和数据库连接数都会出问题。3. Prisma 与 Zod 的分工数据模型这一层最容易乱3.1 Schema 设计时的三个习惯Prisma 的schema.prisma是 t3code 里所有类型的源头我把它当成团队共同语言来对待而不是普通的配置文件。三个习惯用了几个月效果很明显第一模型名用名词单数字段名一律小驼峰外键关系字段加_id后缀。比如article模型里写authorId关系字段author单独声明。这能避免 Prisma 自动生成类型时出现语义混淆。第二所有业务表都带createdAt和updatedAt不要省略。这不仅为了审计更重要的是很多查询排序和缓存失效策略都要依赖时间字段后面想补迁移成本比一开始加上去高得多。第三必须显式声明关系。t3code 里文章和标签是多对多关系我用了隐式多对多表Prisma 会自动维护_ArticleToTag关联表。但如果业务后续要往关联表上挂字段比如标注时间、标注人隐式表就改不动了。所以只要有一丁点扩展可能我建议直接建显式中间模型比如ArticleTagRelationmodel Article { id String id default(cuid()) title String content String tags ArticleTagRelation[] createdAt DateTime default(now()) } model Tag { id String id default(cuid()) name String unique articles ArticleTagRelation[] } model ArticleTagRelation { article Article relation(fields: [articleId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) articleId String tagId String createdAt DateTime default(now()) id([articleId, tagId]) }3.2 Zod 校验和 Prisma 输入的边界tRPC 的input解析用 ZodPrisma 的create/update接收的类型又是另一套。很多人一开始会犯同一个错让 Zod schema 的字段和 Prisma 的 model 字段一一对应然后直接拿 input 塞给 Prisma。这在简单场景没问题但业务一复杂就有隐患。我现在的做法是Zod schema 描述的是客户端传上来的内容Prisma input 描述的是要落库的数据结构中间夹一层薄薄的转换函数。比如创建文章时客户端传tags: [TypeScript, tRPC]转换函数负责把字符串数组映射成connectOrCreate需要的结构同时收口一些客户端不该传的字段比如authorId——它应该从ctx.user取而不是信任请求体。const createArticleSchema z.object({ title: z.string().min(1).max(120), content: z.string().min(1), tags: z.array(z.string()).max(5).optional(), }); export const createArticle protectedProcedure .input(createArticleSchema) .mutation(async ({ ctx, input }) { const tags input.tags?.map((name) ({ where: { name }, create: { name }, })); return ctx.db.article.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, tags: tags?.length ? { create: tags } : undefined, }, }); });这样分层之后Zod 负责门卫Prisma 负责账房职责清楚后续加权限逻辑、加审计字段都不用在两边反复改。3.3 查询性能select 白名单比 include 更可控Prisma 的include用起来很爽但我在 t3code 的列表页吃过亏。一次article.list查询为了省事include了作者和全部标签结果前端列表只需要标题和摘要等于每次请求都白取了文章全文和一堆用户字段。数据量小看不出来等文章积累到几百篇响应时间翻了一倍还多。后来我定了两条规矩。列表接口一律用select白名单只取要渲染的字段详情页要关联数据时才用include而且嵌套的select也要显式指定。另外凡是查列表我固定用skip/take做分页同时返回总数t3code 里就直接让list.query返回{ items, total, hasMore }这种结构。类型安全在这里还能顺手防手滑——前端少取字段编译不会报错但响应体里就会缺数据页面渲染出来不对。用select之后这种缺失在写代码的时候就能发现。4. 从空目录到页面出数据搭建过程中的实测步骤与报错4.1 环境准备与项目初始化t3code 的初始化用的是create-t3-app官方脚手架。这一步本身不复杂但有几个前置条件容易忽略我列一下实测版本要求Node.js 18我用的 20 LTSpnpm 8数据库本地开发直接用 Docker 起一个 PostgreSQL 15 最省事.env里必须配好DATABASE_URL和NEXTAUTH_SECRET缺一个启动就会报错初始化的时候脚手架会问你要不要启用 NextAuth、Prisma、Tailwind、tRPC。我的建议是第一次跑通时全选上别怕代码多因为后面要讲的很多坑恰好发生在这些模块的交互处先让整体架构跑起来再逐个精简。如果只选其中几个后面手动接入反而容易出兼容问题。4.2 打通第一个数据库查询到页面展示初始化完成后我走了一条最小闭环路线来验证整套体系定义 Article 模型 - 迁移生成表 - 写一个article.detail.query- 在页面里调用并渲染。步骤按顺序来# 1. 修改 prisma/schema.prisma定义好模型 # 2. 跑迁移生成数据表和客户端类型 npx prisma migrate dev --name init_article npx prisma generate// 3. 定义 tRPC procedure export const articleRouter router({ detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { return ctx.db.article.findUnique({ where: { id: input.id }, include: { tags: true }, }); }), });// 4. 页面里调用这里用的是 Next.js App Router 的 Server Side 方式 import { api } from ~/trpc/server; export default async function ArticlePage({ params }: { params: { id: string } }) { const article await api.article.detail({ id: params.id }); return ArticleView article{article} /; }跑通这一条之后整个项目的骨架就建立了。之后每加一个功能都是在这个闭环上加分支不会再有不知道怎么接的时刻。4.3 我在这个环节踩过的三个坑这个环节看着顺畅实际上我至少踩了三个值得记录的坑。第一个坑改了schema.prisma之后忘了跑prisma generate结果类型报错说db.article不存在。这个错误信息极具迷惑性因为它指向的是 Prisma Client 的用法而不是 schema。排查了十分钟才想起来客户端类型没重新生成。后来我养成了习惯每次迁移完顺手跑prisma generate没有例外。第二个坑tRPC v11 里createServerSideHelpers和 App Router 的配合。如果你像我一样初始化时选了 App Router服务端调用 tRPC 不要用createServerSideHelpers那套旧的 SSR 思路直接用新版的apiserver 端实例或者用server/trpc.ts里暴露的api对象。混用两套方式会导致超时或者类型对不上的诡异问题。第三个坑React 18 Strict Mode 下开发环境里useQuery会请求两次。这不是 bug是 StrictMode 故意触发的双调用用来暴露不纯函数。如果你在 mutation 里有副作用逻辑千万别依赖只跑一次幂等性要提前设计好。5. 部署上线前必须补的三张安全网类型系统的边界5.1 第一张网输入校验不能只靠前端端到端类型安全解决的只是类型对得上不等于数据是合法。我见过不少人以为有了 tRPC Zod 就万事大吉实际上 ZOD 校验是服务端 procedure 执行前唯一一道防线而它默认只校验类型和简单约束。比如文章标题最长 120 字符、tags 最多 5 个这些业务规则必须写成 Zod schema不能寄希望于客户端表单校验。更隐蔽的是实体是否属于当前用户这种归属校验它和类型无关是纯业务逻辑。t3code 里我写了一个小约定所有 mutation 的第一个动作必须是查出目标实体并检查归属者否则直接抛FORBIDDEN。这个动作不要偷懒因为类型安全不会帮你挡住越权访问。5.2 第二张网错误信息别直接透出到页面tRPC 默认会把 procedure 里抛出的错误原样返回。开发时很方便生产环境却是隐患——查错的堆栈、数据库表名、内部逻辑都不该让用户看到。我在 t3code 里自定义了errorFormatter把错误收敛成{ code, message }两层内部错误在生产环境统一替换成服务器开小差了。// server/trpc.ts function errorFormatter({ error, shape }) { const isDev process.env.NODE_ENV ! production; return { ...shape, message: isDev ? error.message : shape.code INTERNAL_SERVER_ERROR ? 服务器开小差了 : shape.message, }; }另一个容易忽略的点生产环境的日志端要记录完整错误页面端只给用户看精简信息。两头各留一份排查问题时不至于抓瞎。5.3 第三张网单点查库与缓存失效节奏tRPC 的客户端缓存是 React Query 管理的默认缓存生命周期挺长。t3code 早期遇到过一个问题文章更新之后列表页数据不刷新用户反复确认点了保存为什么不生效。根因是 mutation 成功后没有正确失效相关 query。解法也很标准在每个 mutation 的onSuccess里调用对应列表的 invalidateconst utils api.useUtils(); const createArticle api.article.create.useMutation({ onSuccess: async () { await utils.article.list.invalidate(); await utils.tag.popular.invalidate(); }, });这里有个细节容易被忽略一次创建操作可能影响多个 query文章列表、详情、标签热榜每个都要显式失效漏一个就会被缓存骗一次。上线前把 mutation 和它影响的 query 清单列一遍能省掉很多线上已修复但用户看不到的沟通成本。6. 跑了三个月之后的体会t3code 还能怎么长6.1 从单机教程走向多人协作的形态变化t3code 跑到现在最明显的变化是类型就是文档这件事在我这个几人小团队里形成了正循环。新同学接手某个列表页不用再去翻接口文档直接看articleRouter的定义输入输出一目了然。那种前后端各有一套字段名凭默契猜的沟通消耗基本消失了。当然它也有边界。类型安全覆盖不到性能问题、覆盖不到业务规则变化也不可能替代设计评审。真要说实用性最强的部分反而是 tRPC 把上下文和校验收口到一处之后代码审查的工作量肉眼可见地下降了——一个 mutation 几十行逻辑摊开摆在面前比分散在 controller、service、validation 三层里好审得多。6.2 后续准备做的两个方向我接下来的打算一是给 t3code 增加基于 tRPC subscription 的实时能力比如文章评论数实时刷新用 v11 的订阅机制走 SSE 通道绕开成本偏高的 WebSocket 基础设施。二是把数据层挪到 Drizzle 上做一轮对比——这不代表 Prisma 不好而是我想搞清楚在 select 频繁的场景下SQL 生成器的体感差异有多大。技术选型不能靠信仰得靠实测数据。6.3 如果你想在自己的项目里复制这条路最后分享几条我这几个月攒下的实操心得都是文档里不会明确写的事一刚开始别追求所有数据都走 tRPC。页面里有一些跟后端无关的静态配置直接从共享常量文件里拿就好不必强行套 procedure否则 router 文件会膨胀得很难读。二middleware 是 tRPC 最容易上头的功能但要克制。日志、鉴权、数据权限这种横切逻辑进 middleware 合理但具体的业务校验尽量留在 procedure 内部因为业务规则会频繁变放 middleware 里反而降低可读性。三schema 文件里写中文注释。类型能表达结构但表达不了业务意图。比如这个字段决定文章是否在首页展示一句注释顶得上后面排查时的两小时。t3code 本身还在迭代它不是什么了不起的作品更像是我给自己搭建的一间类型安全实验室。如果你也厌倦了在文档和代码之间来回翻译找个周末拿这个小而完整的项目练一遍手会比看十篇架构文章收获更大。整个过程中遇到类型报错记得先怀疑是不是忘了 generate这大概是我最想提前递给你的一把伞。
RELATED

相关推荐

t3code:基于T3技术栈的全栈工程骨架解析与实战

t3code:基于T3技术栈的全栈工程骨架解析与实战

t3code 这个项目,最初就是被我自己一次次重复初始化给逼出来的。团队里每开一个全栈项目,都要重新搭 Next.js、接 TypeScript、配 Prisma、连通 tRPC、再调整 Tailwind,这套流程走顺了也要一个下午,走不顺就是一天起步。后来我把这…

📅 2026/10/26 8:50:05
AI驱动的项目管理决策平台:易趋系统解析

AI驱动的项目管理决策平台:易趋系统解析

1. 项目概述:AI驱动的项目管理决策平台"易趋"是一款基于人工智能技术的项目管理智能辅助系统,它通过深度学习和数据分析技术,为项目管理者提供实时的决策支持。这个平台最核心的价值在于能够将复杂的项目管理过程转化为可视化的数据…

📅 2026/10/26 8:50:04
WinHex定位FAT32文件首扇区:从MBR/DBR到起始簇号换算

WinHex定位FAT32文件首扇区:从MBR/DBR到起始簇号换算

简介:这是一份面向IT运维、数据恢复与安全分析初学者的PPT课件,聚焦用WinHex查看磁盘底层数据并定位文件首个扇区。内容围绕MBR与分区表、DBR、FAT32根目录及目录项展开,串联分区起始扇区、FAT表位置、簇号与扇区号换算等关键概念&#xff0c…

📅 2026/10/26 8:45:04
MORE NEWS

更多资讯

📰

AI原生应用中的持续学习技术实践与优化

1. AI原生应用与持续学习的技术耦合当我们在2023年谈论AI原生应用时,已经不再是指简单调用API的"AI外挂式"开发。真正的AI原生意味着从架构设计阶段就将机器学习能力作为核心组件,而持续学习则是这种架构能够长期保持竞争力的关键所在。我参与…

📰

Notepad++便携版配置指南:U盘免安装、正则替换与插件避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

嵌入式硬件选型指南:从51到STM32、ESP32主流单片机对比与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

绿色版Notepad++便携实战:配置、编码、运行与正则列编辑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

PowerBuilder DataWindow 数据库开发机制与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

OpenMontage 开源视频剪辑自动化框架:从零搭建到批量渲染实战

1. 从零搭建 OpenMontage:一个开源视频剪辑自动化框架的完整实践第一次看到 OpenMontage 这个名字,我下意识以为是某个视频剪辑软件的社区版。真正上手之后才发现,它其实是一套面向开发者的开源视频剪辑自动化框架——把时间线、轨道、素材、…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬