首页 / 资讯中心 / 文章详情

t3code:一套TypeScript全栈类型安全工程模板的选型与落地实践

t3code:一套TypeScript全栈类型安全工程模板的选型与落地实践 ★ FEATURED ARTICLE
t3code 这个名字乍看只是个随手起的项目代号但如果你团队里经常为了“前后端类型对不上”“新模块起步半天”“环境配置一人一个样”这类事吵架那这套东西就是冲着这些痛点来的。它本质上是一套以 TypeScript 为核心的工程化模板与编码约定把数据模型、接口契约、前端调用、环境配置全部串在一条类型安全的链路上让整个团队在一个项目里共享同一份“世界真相”。这篇文章我会从设计思路拆起把你可能关心的选型理由、目录结构、落地步骤、踩坑记录都过一遍看完就能照着搭出一套顺手的版本。最开始接触到 t3code 这个代号是听一个做全栈的朋友提起来的。他说团队里来了三个新同学每人第一周写的代码风格都不一样有人用 any 到处飞有人把接口地址硬编码在页面里还有人为了取一个字段自己又包了一层对象。问题不在人在于项目没有一个统一的“骨架”。t3code 就是他们内部为了解决这件事沉淀出来的一套模板一套规范了数据流、接口层、环境变量、生成脚本的 TypeScript 工程骨架推倒重来的成本几乎为零。这篇文章不只是介绍目录长什么样我更想把每个设计决策背后的为什么讲清楚比如为什么要用 zod 校验环境变量为什么模块生成器要连着路由和类型一起生成为什么接口层不直接放 axios 实例。这些都是我在实际落地中反复权衡过的点希望你看完能直接拿去用少交学费。1. t3code 是什么一套 TypeScript 全栈工程方案的设计思路1.1 项目的来源与核心需求t3code 并不是一个框架也不是一个库它更像是一套“把约定固化下来”的工程模板。它的目标场景非常明确中小型全栈项目团队规模三五人到十几人前端用 React 或 Vue后端用 Node.jsNestJS 或 Express数据库以 PostgreSQL 为主整个团队希望用 TypeScript 覆盖从前端到后端的全部代码。这套模板要解决的核心问题有三个。第一个是类型割裂前后端各写各的类型接口一变更前端往往要翻后端代码才能找到新字段名改完还容易漏。第二个是起步成本高每开一个新模块都要手动建路由、建控制器、建页面文件、配路径别名少则半小时多则一下午。第三个是环境配置混乱有人用 .env.development有人用 .env.local有人把密钥放到代码里提交项目在不同电脑上跑起来结果都不一样。t3code 的核心主张就是一句话凡是能被代码自动生成的就不要手工编写凡是能被类型系统约束的就不要靠口头约定。基于这个主张它把所有规则都固化在模板和脚本里。你拿到项目之后不需要读十页 README跑一条命令一个新模块的路由、控制器、前端页面、类型定义就全部生成好了而且类型是通的。1.2 这套方案解决了哪几类实际问题我根据自己用下来的经验把 t3code 解决的问题归纳为四类每一类都是实际开发中真实存在的麻烦。第一类接口联调的成本失控。传统前后端联调后端把接口写好了前端拿着文档开始对接接口文档一旦不更新前端就只能去问后端。t3code 的做法是让后端接口的入参和出参模型成为唯一的类型来源前端通过自动生成的类型文件引用它。后端改了字段名前端 TypeScript 编译直接报错问题在写代码阶段就暴露而不是上线前联调才发现。第二类新业务模块的重复建设。我见过不少团队新开一个“用户反馈”模块要复制一遍“商品列表”模块的目录结构再改掉里面的名称。复制粘贴这件事本身不算大问题问题在于经常会漏掉配置文件、忘记注册路由、路径名改一半。t3code 的模块生成器会把整个生命周期打通从后端路由到前端页面一把梭生成完直接能跑起来改业务。第三类环境和依赖的不一致。这种问题排查起来最磨人。我遇到过一次本地跑得好好的同事拉代码却启动报错查了半天是环境变量里少了数据库连接字段而代码里压根没有对这个字段的校验漏了也不报错直到运行时才挂。t3code 会用校验库在进程启动时强校验所有环境变量缺一项、错一项直接明确报错告诉你哪里有问题。第四类代码风格与架构漂移。项目一开始架构清晰随着人员流动和业务堆叠Controller 里开始写业务逻辑页面里开始拼 SQL架构慢慢就“烂”了。t3code 通过分层约定和依赖方向限制把合理的架构固定在模板里新来的同事照着写写不出结构太离谱的代码。2. 核心设计思路与技术选型解析2.1 技术栈选型与取舍逻辑t3code 的技术栈选型不是追新而是选“大家都能上手、生态稳定、类型支持好”的组合。它的基础底座是 TypeScript 4.x后端框架推荐 NestJS 或 Express tsrpc 二选一前端推荐 React TanStack Query 或 Vue Pinia数据库访问层使用 Prisma环境变量校验使用 zodAPI 层封装使用 axios 或 fetch。整套栈的核心是 TypeScript所有选型都围绕“类型安全”这一个指标展开。为什么后端我倾向于 NestJS不是因为 Spring 那种重度依赖注入风格适合所有项目而是 NestJS 的模块化结构与 t3code 的模块生成器天然契合。NestJS 本身用装饰器和类来组织路由与依赖元数据很规整自动生成代码的模板好写。Express 本身够简单灵活但它的灵活性对工程模板来说反而是缺点因为规范要管住灵活性成本就高了。Prisma 是我在实践里觉得最顺手的一个选择。它通过 schema 文件定义数据模型然后自动生成完全的 TypeScript 类型这一点正好配合 t3code 的“单一事实来源”原则。你只需要维护一份 schema数据库表结构、后端类型、前端类型全部从它推导出来省掉了手工维护多份文件的噩梦。也有人用 TypeORM 或 MikroORM它们也不差但从开箱体验和类型生成质量来说Prisma 更适合模板化。2.2 目录结构与模块分层约定目录结构决定了团队协同的边界规则。t3code 的分层是严格的单向依赖大致结构如下t3code-app/ ├── packages/ │ ├── shared/ # 共享类型、工具函数、常量 │ │ ├── types/ │ │ ├── utils/ │ │ └── constants/ │ ├── server/ # 后端服务 │ │ ├── src/ │ │ │ ├── modules/ # 业务模块按模块划分 │ │ │ ├── common/ # 公共中间件、守卫、拦截器 │ │ │ ├── config/ # 环境变量校验与配置解析 │ │ │ └── main.ts │ └── web/ # 前端应用 │ ├── src/ │ │ ├── pages/ │ │ ├── api/ # 接口调用层 │ │ ├── stores/ # 状态管理 │ │ └── components/这种结构的重点不是目录名称而是依赖方向。shared 包不依赖 server 和 webserver 依赖 sharedweb 依赖 shared。server 和 web 永远不直接互相引用哪怕它们是同一个仓库里的两个包。我用一个生活化的例子来类比shared 像是两家人共同遵守的“物品交接清单”server 是厨房负责做菜web 是餐厅负责上菜。厨房和餐厅不直接对话所有菜式、份量、价格都按照同一份清单来谁要改先把清单改了然后两边一起受影响。这样就不会出现厨房偷偷换菜、餐厅还在按旧清单宣传的情况。2.3 类型安全的契约设计从数据库到前端一杆到底t3code 最亮眼的地方在于契约设计。它定义了一条完整的类型链路Prisma Schema - 数据库类型 - 后端接口 DTO - 共享 API 响应类型 - 前端页面状态。具体怎么串起来第一环Prisma Schema 定义数据库模型比如用户表model User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }第二环Prisma 在每次 migrate 或者 generate 之后自动生成User类型后端可以直接引用。第三环后端的接口出参类型不是自己手写的而是从 Prisma 类型里派生出来的比如用Pick或者 Omit 挑选必要字段而不是定义一个新的“UserResponseDTO”类。第四环这个接口出参类型通过 shared 包发布给前端。前端调接口时拿到的数据就是这个类型的实例天然是完整的类型信息。这里有个关键细节后端不能直接把 Prisma 生成的完整 User 类型暴露给前端。因为数据库模型可能在业务边界外泄露敏感字段比如密码哈希、内部标记。t3code 的做法是强制每个接口声明自己的“公开视图类型”用映射类型来清洗字段// 后端接口处 export type UserPublic PickUser, id | email | name | createdAt;这个UserPublic类型会进入 shared 包里前端只能看到这个类型不知道 User 表还有其他字段。这样既保证类型自动推导又守住接口的字段边界两边都安全。3. t3code 的实操落地从初始化到模块生成全流程3.1 初始化项目与基础配置初始化 t3code 项目并不复杂前提是你已经装好了 Node.js 18 以上版本、pnpm 或者 yarn、Docker用来跑数据库。建议用 pnpm 管理 monorepo因为 workspace 支持成熟安装速度快依赖隔离也干净。第一步用模板仓库初始化项目结构。如果是内部 git 仓库直接克隆模板如果自己从零搭需要手动建立 packages/shared、packages/server、packages/web 三个包。第二步在根目录配置 TypeScript 的 project references让 shared 包的编译结果可以被 server 和 web 直接引用同时保持各自独立的构建配置。根目录的 tsconfig.json 大致长这样{ compilerOptions: { target: ES2022, module: commonjs, moduleResolution: node, strict: true, declaration: true, composite: true }, references: [ { path: ./packages/shared }, { path: ./packages/server }, { path: ./packages/web } ] }这里的composite: true很关键它允许 TypeScript 的 Project References 机制在多个包之间做增量编译shared 类型一变引用了它的包会自动感知。3.2 环境变量与配置管理怎么做环境变量这个环节我吃过不少亏所以单独拎出来讲。t3code 的做法是用 zod 写一个 config schema在服务启动的最开始校验并解析全部环境变量失败就立即退出并打印错误。比如服务端要用的配置import { z } from zod; const envSchema z.object({ NODE_ENV: z.enum([development, test, production]).default(development), PORT: z.coerce.number().default(3000), DATABASE_URL: z.string().min(1), JWT_SECRET: z.string().min(32), REDIS_URL: z.string().url().optional(), }); export type EnvConfig z.infertypeof envSchema; export const config: EnvConfig envSchema.parse(process.env);为什么做校验而不是直接读process.env因为字符串类型的环境变量很容易出现低级错误端口写成字符串、变量名拼错、漏配某个必填项。用 zod 定义 schema 之后所有字段都会在启动时被检查错误信息会明确指出“DATABASE_URL 缺失”或“JWT_SECRET 长度不足”而不是等你跑到一半才崩溃。这里有一条实践建议JWT_SECRET 这类敏感值不要放进 .env 文件提交到 git 仓库用 .env.example 提交占位符真实值通过部署平台的 secrets 注入。本地开发可以用 .env.local并保证 .gitignore 把它忽略掉。3.3 数据模型定义与迁移流程数据模型是 t3code 的源点一切类型从这里开始。用 Prisma 做迁移时我建议遵循一套固定流程改 schema - 跑npx prisma format- 跑npx prisma migrate dev- 跑npx prisma generate。第四步经常被忽略但它恰恰是最重要的。Prisma generate 会把最新的模型类型重新生成到 node_modules 里如果你不跑那 server 代码里引用的 User 类型还是旧版本。建议把 generate 命令写进 package.json 的 postinstall 脚本里这样不管谁安装依赖类型都会自动同步最新。定义数据模型时要注意两个实践点。一个是尽量使用语义化字段名数据库里叫createdAtPostgreSQL 里默认生成是带时区的 timestamp但 Prisma 的 GraphQL 风格类型会自动映射前端拿到的类型就是字符串或 Date不用自己转换。另一个是关系字段要有意识地设计Prisma 的关系会自动生成外键查询能力但如果你不需要关系宁可不建减少隐式查询带来的性能隐患。3.4 模块生成器把重复劳动交给脚本t3code 最有实用价值的就是它的模块生成器。实际开发中每新增一个业务模块你就跑一条命令pnpm generate:module --name user-feedback这条命令会自动做以下事情在后端 modules 目录下创建 user-feedback 文件夹生成 controller 与 service 的骨架在 Prisma schema 里追加一个UserFeedback模型占位定义在前端 pages 目录下创建 user-feedback 页面骨架在 api 目录下创建对应的接口调用文件在 shared/types 里导出这个模块的前后端共享类型。生成完之后项目可以直接跑起来新模块的代码就已经能被路由访问到了。开发者只需要往骨架里填自己的业务逻辑不再需要手工做那些接线工作。模块生成器的实现并不复杂它的核心是几个模板文件和一段 node 脚本。脚本里用简单的字符串替换、文件复制和路径拼接就能完成。注意命名统一使用 kebab-case内部在代码里转成 PascalCase 和 camelCase。这里我特别强调一个我在实现中踩过的坑脚本一定要同时处理文件名、类名、路由前缀和数据库表名四处四者如果有一处不一致后面排查会让你怀疑人生。我自己写过一个早期版本只改了类名没改路由前缀导致接口路径还是旧的前端一直请求 404排查了半小时才发现是生成脚本的 bug。3.5 前端接入 API 的具体模式前端接 API 我推荐的方式是每个模块一个 API 文件API 文件里每个函数对应一个接口函数返回值带完整的类型标注。配合 TanStack Query 或 SWR 使用还能自动管理缓存、重试和请求取消。以 React TanStack Query 为例// packages/web/src/api/user.ts import { request } from ../utils/request; import type { UserPublic } from t3code/shared; export function fetchUserProfile(userId: string) { return request.getUserPublic(/api/users/${userId}); } // 在页面组件里 const { data: user, isLoading } useQuery({ queryKey: [user, userId], queryFn: () fetchUserProfile(userId), });这里我把request封装成了一个统一实例它在内部做了三件事自动附加认证 token统一处理 HTTP 错误码401 时跳转登录把响应数据解包。封装的好处是业务代码不会到处出现 axios 细节后续如果要换请求库只改一个文件就够了。4. 常见问题与排查技巧实录4.1 前后端类型不同步引发的编译错误使用 t3code 过程中最常遇到的问题就是前端编译报错说某个类型在 shared 里不存在。绝大多数情况下原因是 shared 包没有重新构建或者 monorepo 里没有正确链接到最新产物。排查路径我建议按三步走。第一步在 shared 包目录下跑一次pnpm build确认类型文件是否已经生成第二步检查 server 和 web 的 tsconfig 里引用的路径是否指向 shared 的源码而非旧产物目录第三步检查 pnpm workspace 的软链接是否正常必要时删掉 node_modules 重装。有一个细节值得留意如果你用的是 TypeScript Project References要确保 shared 包里的declaration和composite都被打开否则类型文件不会自动生成。4.2 循环依赖与运行时崩溃循环依赖在工程模板里很容易被触发尤其是 shared 包里的工具函数互相引用、service 之间互相调用时。Node.js 在处理循环依赖时不会报语法错误但经常出现“某个类未定义”或“undefined is not a constructor”的诡异运行时问题。我的排查经验是看到一个类似Cannot read properties of undefined (reading xxx)的错误先往循环依赖方向想。用madge工具扫描一下依赖图npx madge --circular packages/server/src如果检测出循环高概率是某两个 service 互相引用了。解决办法通常是抽取一个公共的 service 层或者用事件机制解耦而不是简单地调换 import 顺序。import 顺序可能暂时掩盖问题但下一次代码重组又会爆发。4.3 环境变量校验失败但报错不明确有时候配置明明没有明显问题启动却失败报错信息很笼统。这种情况大多是因为 zod 校验的默认错误信息不够友好。我建议在项目里自定义一个 env 校验的包装函数捕获 zodError逐条解析 issue 路径和 message输出成表格形式❌ 环境变量校验失败 DATABASE_URL: Invalid url JWT_SECRET: String must contain at least 32 character(s)这样看一眼就知道哪里漏了不用去翻冗长的 error stack。这个技巧虽然简单但能节省大量排查时间。4.4 构建体积与首屏性能优化t3code 模板默认是方便为主没有在性能上做极致优化但等到项目变大构建体积一定会成为问题。我习惯在三个层面做优化。第一层是代码分割。前端路由配置里使用 React.lazy 懒加载非首屏页面让首屏只加载必要代码。第二层是依赖优化。用 bundle analyzer 找出体积过大的第三方库比如 moment 换成 dayjslodash 按需引入。第三层是缓存优化。后端接口合理设置 Cache-Control 头配合 Redis 缓存热数据减少数据库压力。一个有意思的细节Prisma 生成的 client 体积并不小但它的 tree-shaking 效果近年来已经改善很多只要确保按需引用不必过度担心。真正需要注意的往往是隐藏在 shared 包里的公共模块被全部打进每个 bundle 的情况此时需要检查 sideEffects 字段配置。5. 使用 t3code 的注意事项与优化建议5.1 不是所有项目都适合 t3code虽然我花了不少笔墨推荐这套东西但必须诚实地说一句不是所有项目都适合直接用 t3code。如果你的项目是一个几十个服务的大型微服务系统每个服务独立部署、独立团队维护monorepo 加共享类型的做法反而会拖累你因为耦合度会始终存在。微服务之间用独立的 API 文档和 proto / OpenAPI 文件更合适而不是一口吃成一个共享类型大包。另外如果你的团队对 TypeScript 本身还不熟悉直接上这套模板可能适得其反。先让团队掌握类型推导、泛型、装饰器这些基础概念再引入工程模板效果会好得多。否则类型错误会成为新人每天遇到的第一个障碍学习曲线过于陡峭。5.2 三个值得坚持的团队约定第一约定接口返回类型必须从 shared 包导入禁止在页面里手写 any 类型。这一条是所有类型安全的底线一旦有人开了 any 的口子类型保护就会逐渐失效。第二约定数据库模型变更必须走 Prisma Migration禁止手工改表结构。手工 ALTER TABLE 确实快但很快迁移历史和实际库结构就会脱节导致同事本地环境起不来。第三约定新模块必须通过生成器创建禁止手动复制其他模块再改名。这条规则看起来死板但它保住了模块结构的长期整洁。手动复制极容易漏掉路由注册或类型导出生成器才是可复现的工程资产。这些约定看起来不起眼但它们决定了一个中型项目能否在半年后依然保持初期架构的干净程度。我在实际维护过程中最大的感受是架构的腐化不是哪一天突然发生的而是每天“破一次例”累积出来的。t3code 的价值正在于把最常见、最合理的规则提前固化成代码和脚本让团队的每一次提交都在标准的轨道上运行。你用它的时间越长省下来的力气就越可观。
阅读完成 · 觉得有帮助?
咨询建站