ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

t3code 编码实践:从数据库到前端的端到端类型安全

t3code 编码实践:从数据库到前端的端到端类型安全 1. 从“t3code”这个代号说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“React”“Vue”那样有明确的官方文档也不像“Docker”“K8s”那样有庞大的社区。它更像是一个在特定圈子里流传的代号一个被反复提及却很少有人系统讲清楚的东西。我在几个技术社区里翻了一圈发现大家对它的理解五花八门有人以为是某种编码规范有人觉得是某个内部工具链的简称还有人把它和“T3 Stack”联系起来。这些猜测都有道理但都不完整。先把结论放在前面t3code 本质上是一套围绕“类型安全”和“端到端一致性”构建的编码实践集合。它不是一个具体的库也不是一个框架而是一种在特定技术选型下自然形成的开发范式。这个范式最早在采用 T3 StackTypeScript tRPC Tailwind Next.js 这一套组合的项目中被总结出来后来逐渐演变成一种更通用的编码思路。它的核心诉求很简单让类型系统从数据库一路贯穿到前端组件中间不出现任何断裂。为什么这件事值得单独拿出来讲因为大多数项目在类型安全上都是“半吊子”。数据库用一套类型API 层用另一套前端再手写一套。三套类型之间靠人工同步一旦有人改了数据库字段忘了改前端线上就出问题。t3code 要解决的就是这个痛点。它适合那些已经受够了“类型对不上”的团队也适合个人开发者想从一开始就把工程底子打扎实。不管你用的是不是 T3 Stack这套思路都能借鉴。我见过太多项目在初期为了赶进度把类型定义写得乱七八糟等到业务复杂了再想重构成本高得吓人。t3code 的价值就在于它提供了一条从第一天就可以走的路而且这条路不需要你引入什么重型工具靠的是对现有技术栈的合理编排。2. t3code 的底层逻辑类型如何从数据库“流”到按钮2.1 传统分层架构里的类型断裂点要理解 t3code 为什么这样设计得先看看传统架构哪里出了问题。一个典型的 Web 应用分成三层数据层、服务层、视图层。数据层用 ORM 定义模型比如 Prisma 的 schema服务层写业务逻辑通常是一堆 API 路由视图层是前端组件负责渲染和交互。问题出在层与层之间的“翻译”上。Prisma 生成的类型是给 Node.js 用的前端拿不到。于是你要么手写 TypeScript 接口要么用代码生成工具从 OpenAPI 文档里生成。手写容易漏生成工具又重又慢而且生成的类型往往和实际运行时行为有偏差。更麻烦的是当 API 的输入输出结构发生变化时前端不会自动报错只有等到运行时才暴露。我做过一个统计在一个中等规模的项目里因为类型不同步导致的 bug 占总 bug 数的 15% 到 20%。这些 bug 有个共同特点——它们本可以在编译阶段就被拦住。t3code 的思路就是把这些“本可以”变成“一定”。2.2 单一事实来源schema 即契约t3code 的第一个原则是数据库 schema 是唯一的类型事实来源。不管你用 Prisma、Drizzle 还是 TypeORM模型定义文件就是契约。所有其他类型都应该从它派生而不是另起炉灶。以 Prisma 为例当你定义了一个User模型model User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) }Prisma 会自动生成User类型。这个类型包含了所有字段和它们的 TypeScript 类型。关键在于这个类型不应该被手动复制到前端。前端应该通过某种机制直接引用它或者引用一个从它派生出来的、经过裁剪的类型。这里有个细节很多人会忽略数据库里的DateTime类型在序列化后会变成字符串。如果你直接把 Prisma 的User类型丢给前端前端会以为createdAt是Date对象实际上拿到的是 ISO 字符串。t3code 的做法是在 API 边界做一次显式的类型转换把Date转成string并把这个转换后的类型作为前端的输入类型。这个转换过程是类型安全的因为 TypeScript 会检查你是否真的做了转换。2.3 端到端类型推断的三种实现路径实现端到端类型安全有三条主流路径t3code 并不绑定其中某一条而是根据项目情况选择。第一条是tRPC 路线。tRPC 允许你直接调用后端函数类型自动推断。你写一个getUser的 resolver前端trpc.user.getUser.useQuery()就能拿到完整的返回类型。这条路径最省心但要求前后端在同一个 TypeScript 项目里或者至少共享类型定义。第二条是GraphQL 代码生成。通过 GraphQL Code Generator从 schema 生成前端可用的类型和 hooks。这条路径适合前后端分离的团队但配置起来比较繁琐而且生成的代码体积不小。第三条是OpenAPI 类型生成。后端用 Swagger 或类似工具生成 OpenAPI 文档前端用openapi-typescript之类的工具生成类型。这条路径最通用但类型精度取决于文档的质量有时候会丢失一些细节。t3code 的实践建议是如果团队全栈用 TypeScript优先选 tRPC如果有非 TypeScript 的消费方选 OpenAPIGraphQL 只在已经有 GraphQL 基础设施时才考虑。这个选择逻辑背后是对“类型保真度”和“维护成本”的权衡。3. 落地 t3code从零搭建一个类型安全的请求链路3.1 项目初始化时就要定好的三件事很多项目在初始化时随便选了一套模板后面想改就难了。t3code 要求在项目第一天就明确三件事ORM 选型、API 层形态、前端数据获取方式。这三者必须能串起来。ORM 方面Prisma 和 Drizzle 是目前类型支持最好的两个。Prisma 的 schema 语法更直观Drizzle 更贴近 SQL 且类型推断更激进。我个人的经验是如果团队里有人对 SQL 很熟选 Drizzle如果希望快速上手且生态成熟选 Prisma。两者都能满足 t3code 的要求。API 层形态决定了类型如何暴露。用 Next.js 的 App Router 时可以用 Server Actions 配合 tRPC也可以用 Route Handlers 配合 OpenAPI。Server Actions 的好处是少一层网络调用但类型共享只在同一个 Next.js 项目内有效。如果未来要拆出独立的 API 服务Route Handlers 更稳妥。前端数据获取方式要和 API 层匹配。tRPC 配 React Query 是经典组合OpenAPI 配 SWR 或 TanStack Query 也行。关键是不要在前端手写请求函数和类型一切从生成的客户端里来。3.2 用 tRPC 打通前后端的实操步骤假设你已经用create-t3-app初始化了一个项目接下来要做的是定义第一个 router。在server/api/routers/user.ts里import { z } from zod; import { publicProcedure, router } from ../trpc; import { db } from /server/db; export const userRouter router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) { const user await db.user.findUnique({ where: { id: input.id }, select: { id: true, email: true, name: true, createdAt: true }, }); if (!user) return null; return { ...user, createdAt: user.createdAt.toISOString(), }; }), });注意这里做了两件事一是用select明确指定返回字段避免把密码等敏感字段带出来二是把createdAt从Date转成string。这个转换是显式的TypeScript 会推断出返回类型里createdAt是string。然后在server/api/root.ts里注册import { userRouter } from ./routers/user; export const appRouter router({ user: userRouter, }); export type AppRouter typeof appRouter;前端组件里import { api } from /utils/api; export function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading } api.user.getById.useQuery({ id: userId }); if (isLoading) return div加载中.../div; if (!user) return div用户不存在/div; return ( div h1{user.name ?? 未命名}/h1 p{user.email}/p time{new Date(user.createdAt).toLocaleDateString()}/time /div ); }这里user.createdAt的类型是string因为后端已经转换过了。如果你在后端忘了转换前端会拿到Date类型但运行时是字符串TypeScript 不会报错这就是隐患。t3code 的纪律是在 API 边界做所有必要的序列化转换并让类型反映转换后的结果。3.3 不用 tRPC 时怎么保持类型一致有些项目因为历史原因不能用 tRPC比如后端是 Python 或 Go。这时候 t3code 的思路依然适用只是实现方式变了。核心做法是后端生成 OpenAPI 文档前端用工具生成类型和请求客户端。以 FastAPI 为例它自带 OpenAPI 文档生成。前端用openapi-typescript生成类型npx openapi-typescript http://localhost:8000/openapi.json -o src/types/api.d.ts然后用openapi-fetch创建类型安全的客户端import createClient from openapi-fetch; import type { paths } from ./types/api; const client createClientpaths({ baseUrl: http://localhost:8000 }); const { data, error } await client.GET(/users/{id}, { params: { path: { id: 123 } }, });这样data的类型就是后端定义的响应类型。如果后端改了字段重新生成一次类型前端编译时就会报错。这个流程需要加到 CI 里确保每次后端变更都能及时反映到前端。注意OpenAPI 生成的类型精度取决于后端框架的注解质量。FastAPI 的 Pydantic 模型能生成很精确的类型但有些框架生成的类型全是any那就失去意义了。选型时要先验证这一点。4. 那些只有踩过坑才知道的 t3code 细节4.1 日期和 Decimal 的序列化陷阱日期问题我在前面提过但实际项目里还有更隐蔽的坑。比如 Prisma 的Decimal类型在 Node.js 里是Decimal对象序列化成 JSON 后变成字符串。如果你在前端用number类型去接TypeScript 不会报错但运行时做算术运算就会出问题。t3code 的处理方式是在 API 层统一转换。对于金额字段要么转成number注意精度丢失风险要么保持string并在前端用专门的库处理。我倾向于保持string因为金额计算用浮点数本身就是危险的。// 后端 return { ...order, amount: order.amount.toString(), // Decimal - string }; // 前端类型自动推断为 string另一个容易忽略的是BigInt。Prisma 支持BigInt字段但JSON.stringify不能直接序列化BigInt会抛错。你需要在 API 层把它转成string或number。这个错误在开发环境可能不会触发因为开发数据量小但生产环境一旦遇到大整数就崩了。4.2 可选字段的“undefined vs null”之争TypeScript 里undefined和null是两个不同的东西但在数据库和 JSON 里它们经常被混为一谈。Prisma 的可选字段返回null但 TypeScript 的可选属性是undefined。如果你直接把 Prisma 的返回类型暴露给前端前端会以为字段可能是undefined实际上拿到的是null。t3code 的建议是在 API 层统一把null转成undefined或者反过来。选一种然后坚持。我通常选择保留null因为 JSON 里没有undefinednull更符合实际传输的数据。然后在 TypeScript 类型里显式标注| null。// 后端返回 return { name: user.name, // string | null }; // 前端类型 // name: string | null这样前端在处理时就必须考虑null的情况不会因为忘了判断而出现Cannot read property of null。4.3 循环引用导致的类型推断失败当两个模型互相引用时比如User有postsPost有authorPrisma 生成的类型会形成循环引用。如果你在 API 层直接返回嵌套对象TypeScript 的类型推断可能会变得非常慢甚至在某些编辑器里卡死。解决办法是限制嵌套深度。不要返回完整的关联对象而是返回 ID 或者一层嵌套。比如返回Post时带上authorId而不是完整的author对象。前端如果需要作者信息再单独请求一次。这样既减少了类型复杂度也避免了过度获取数据。// 不推荐 const post await db.post.findUnique({ where: { id }, include: { author: { include: { posts: true } } }, }); // 推荐 const post await db.post.findUnique({ where: { id }, select: { id: true, title: true, authorId: true }, });这个原则在 t3code 里叫“按需获取扁平优先”。它牺牲了一点便利性换来了类型系统的稳定和性能的可控。5. 把 t3code 思路扩展到非 TypeScript 场景5.1 在 Python 项目里借鉴类型流Python 有类型注解也有 Pydantic 这样的运行时校验库。t3code 的思路可以部分移植用 Pydantic 定义 API 的输入输出模型这些模型同时作为 OpenAPI 文档的来源。前端通过生成的类型来消费。关键区别在于Python 的类型注解是“渐进式”的运行时不会强制检查。所以你需要 Pydantic 在 API 边界做校验确保传入的数据符合预期。这比 TypeScript 的编译时检查多了一层运行时开销但换来的是对非 TypeScript 消费方的兼容。from pydantic import BaseModel from datetime import datetime class UserResponse(BaseModel): id: str email: str name: str | None created_at: datetime class Config: json_encoders { datetime: lambda v: v.isoformat() }这样 FastAPI 会自动生成 OpenAPI 文档前端生成类型后就能得到created_at: string。5.2 移动端和第三方消费方的类型同步当你的 API 要被 iOS、Android 或第三方调用时端到端类型安全就断了。这时候 t3code 的降级方案是把 OpenAPI 文档作为契约用代码生成保证各端类型一致。Swift 可以用swift-openapi-generatorKotlin 可以用openapi-generator的 Kotlin 插件。这些工具从同一份 OpenAPI 文档生成各端的模型和客户端。虽然不如 TypeScript 那样无缝但至少保证了字段名和类型的一致性。这里有个经验OpenAPI 文档的版本要纳入版本控制每次 API 变更都要更新文档并重新生成各端代码。这个流程听起来麻烦但比手动同步类型可靠得多。我见过一个团队因为忘了更新 iOS 端的模型导致线上接口返回的字段名对不上排查了半天才发现是文档没同步。6. 我在这套实践里踩过的三个真实坑第一个坑是过度依赖类型推断导致编译变慢。在一个大型项目里tRPC 的 router 嵌套了五六层TypeScript 的类型推断时间从几秒涨到了几十秒。编辑器里每次保存都要等半天。后来我把 router 拆成了多个独立的子 router并且用inferRouterOutputs显式提取类型而不是让 TypeScript 自动推断整个链路。这个改动把编译时间降回了可接受的范围。第二个坑是Zod schema 和 Prisma schema 不一致。tRPC 用 Zod 做输入校验Prisma 用 schema 定义模型。有一次我改了 Prisma 的字段类型忘了改 Zod schema结果前端传的数据通过了 Zod 校验但写数据库时报错。后来我养成了一个习惯每次改 Prisma schema先全局搜索对应的 Zod schema一起改。更好的做法是用zod-prisma-types这样的工具从 Prisma schema 自动生成 Zod schema彻底消除不一致的可能。第三个坑是错误处理没有类型化。tRPC 的错误默认是TRPCError但前端拿到的错误对象类型很宽泛。我在前端写error.message时经常拿到undefined因为错误可能来自网络层而不是 tRPC 层。后来我封装了一个getErrorMessage函数统一处理各种错误形态并在 tRPC 的 error formatter 里把错误结构标准化。这样前端拿到的错误类型就是确定的不用再猜。7. 关于 t3code 的一些常见误解有人觉得 t3code 就是“用 tRPC”这不对。tRPC 只是实现手段之一核心是类型从数据库到 UI 的贯通。你用 OpenAPI 也能做到只是配置多一点。有人觉得 t3code 只适合小项目大项目类型太多会失控。实际情况恰恰相反项目越大类型断裂的代价越高t3code 的收益越明显。大项目需要的是纪律和工具链而不是放弃类型安全。还有人觉得 t3code 会增加开发负担每写一个接口都要定义类型。但你要想清楚这些类型本来就要定义只是以前定义在三个地方现在定义在一个地方。前期多花十分钟后期省下的是几小时的调试时间。我在实际项目里推行这套做法时最大的阻力不是技术而是习惯。大家习惯了“先跑起来再说”觉得类型是束缚。但跑起来之后呢改一个字段要全局搜索生怕漏了哪里。t3code 把这种恐惧消除了你改数据库 schema编译器会告诉你哪里需要跟着改。这种安全感一旦体验过就回不去了。最后分享一个小技巧如果你不确定某个类型应该定义在哪一层就问自己“这个类型的变化频率有多高”。数据库字段变化频率低定义在 schema 层API 响应结构变化频率中等定义在 API 层UI 组件的 props 变化频率高定义在组件附近。按照变化频率分层类型的维护成本最低。
返回列表