如果你正用 TypeScript 写项目,多半经历过这些时刻:代码里飘着几十个any,提交信息五花八门,代码风格随成员心情漂移,CI 偶尔挂了却没人说得清是哪一步出的问题。我做了一件不太讨巧的事——把“代码洁癖”变成一套可以复制的工程流程,代号就叫t3code,前后跑了快八个月,效果比我想象中稳定。这篇文章就从项目命名、核心设计、实操配置到踩坑记录,完整拆一遍这套东西。
t3code的命名其实很简单,T3 代表三个词:Think、Type、Transform。动手写代码之前先把需求想清楚,写的过程里用 TypeScript 类型把边界锁死,最后靠自动化脚本把坏味道转成好味道。你也可以把它理解成“第三次重构的工程代码模板”,因为这套配置是我三番五次推翻重来后的产物。它不是某个业务系统,也不是框架,而是一套面向前端/全栈工程化的基础设施,从编译器选项、代码检查、提交钩子,到 CI 流水线、版本发布,全部用配置文件和脚本串成一条链路。
什么人适合参考这套东西?独立开发者、三五个人的小团队、开源项目维护者,尤其是那种“已经受不了手工维护代码质量”的人。如果你只是写一次性脚本或者快速验证 demo,那 t3code 对你来说过度了,没必要为了一个十来行的脚本引入一堆规则。后面我会专门讲过度工程的问题,这是实际使用中绕不开的坎。
1. T3Code 是什么:一个把“代码洁癖”变成流程的工程实践
1.1 项目命名与核心含义
很多人第一次听到 t3code 会以为是某个框架,其实它更像是个人长期 side project 的代称。我在设计这套东西时给自己定了三个原则,正好对应 T3 的拆解:
- Think:任何功能落地前,先确认边界条件。输入是什么,输出是什么,异常情况怎么处理。
- Type:用类型系统把这些边界条件表达出来,让编译器帮你拦下一大批低级错误。
- Transform:通过自动化和规范化的脚本,把人工 Review 才能发现的坏味道提前在提交前处理掉。
举一个生活化的类比:以前每次开新项目,我都要手动配 ESLint、配 Prettier、配 Husky、配 CI,感觉就像每次搬家都要重新设置路由器,麻烦且容易漏。t3code 就是把这个“重新设置路由器”的过程标准化了,项目一初始化,插上电,网络就能通。这也是它最大的价值——质量约束前置,而前置的手段不是靠提醒,而是靠流程。
1.2 这个项目能解决什么问题
跑了大半年,我总结出 t3code 主要解决三个痛点:
| 痛点 | 常规表现 | t3code 的应对 |
|---|---|---|
| 代码风格漂移 | 缩进、引号、行宽因人而异,Review 时吵不清楚 | Prettier 统一格式,保存即格式化 |
| 类型形同虚设 | 到处都是 any,接口字段随意漏 | TypeScript 严格模式 + 运行时边界校验 |
| 提交流程混乱 | commit message 写了“fix stuff”,难以追溯 | commitlint 强制约定式提交 |
适合用 t3code 的人,通常是已经被这三个问题折磨过一轮的开发者。比如你维护一个开源组件库,外部用户给你提 PR,如果 PR 的提交信息全是乱写的,你根本没法写 changelog;如果你自己的 side project 放了一两个月再打开,发现连自己都看不懂当时为什么那么写,那也说明工具链没跟上。t3code 就是把“人靠自觉”改成“机器把关”。
1.3 整体技术栈与工具选型逻辑
技术栈选型是这套东西里最容易踩坑的部分,我最终定下的是这么一组:TypeScript 5.x + pnpm workspace + ESLint 9(flat config) + Prettier + Husky + lint-staged + commitlint + changesets + GitHub Actions。每个工具解决一个问题,彼此衔接,尽量不重叠。
为什么选 pnpm 而不是 npm/yarn?主要是依赖安装速度和磁盘占用。pnpm 的硬链接机制在 monorepo 场景下优势非常明显,而且它默认的严格 node_modules 结构能逼你显式声明依赖,避免“幽灵依赖”问题。在 t3code 里我用 workspace 把packages/core、packages/cli、apps/docs这些东西放在一个仓库管理,彼此独立版本互不影响。
为什么不直接上 Nx 或者 Turborepo?t3code 的目标是“轻、清晰、能看懂”,不是搞企业级构建系统。Turborepo 的缓存确实香,但引入它意味着学习成本上升,而且对于我个人项目的体量来说,收益不大。工具链有一点必须记住:每加一个工具都是在给未来加一个维护成本,所以能不加就不加,不能为了“显得专业”堆科技树。
ESLint 9 我特意选的 flat config 版本。它把原来一大堆.eslintrc、overrides、parserOptions的复杂结构拍平成了单个eslint.config.js,配置逻辑更直白。后面在第三章我会给出具体配置,那是全文的实操重点之一。
2. 类型安全建设:把 TypeScript 用到“较真”的程度
2.1 编译器配置的几个关键选项
t3code 的类型安全地基是tsconfig.json。很多人项目里 TypeScript 装了一堆,配置却还是默认值,那其实只是“用着 TypeScript 的 JavaScript”。我这份配置里最关键的是下面几个选项:
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "moduleResolution": "bundler", "noPropertyAccessFromIndexSignature": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true } }逐一说一下为什么开它们。noUncheckedIndexedAccess是把数组/对象的索引访问返回值从T变成T | undefined,很多人刚开这个会觉得烦,因为你习惯写const item = arr[i],然后直接用item.name,开了之后编译器会告诉你item可能是 undefined。这恰恰是对的,它逼你处理越界风险。实际项目里最典型的就是循环中通过索引访问数组元素,你以为是安全的,但运行时 index 可能超出长度,这就是线上事故的温床。
exactOptionalPropertyTypes解决的是一个隐蔽问题:TypeScript 里interface User { name?: string }的name字段,很多人会赋undefined,觉得这代表“没传”。但语义上name?: string意味着字段要么不出现,要么值是string,undefined不是合法的string。开启后,obj.name = undefined会被直接报错,代码就必须明确区分“字段缺失”和“字段为 undefined”,这对 API 数据处理尤其重要。
moduleResolution: bundler是 TypeScript 5.x 针对 Vite/Webpack 等打包器的新策略,支持package.json里的exports字段解析。如果你的项目跑在 Node.js 原生环境,可能要用NodeNext,这个选择取决于运行环境,不是越新越好。
2.2 类型收窄与泛型约束的落地方式
开完编译选项,代码怎么写才能享受这些约束?我在 t3code 里定了一条军规:除非和第三方库打交道或者做类型体操,禁止显式any。你可以开@typescript-eslint/no-explicit-any为 warn,让自己看见哪里偷懒了。
拿一个实际场景举例。假设你要封装一个 API 请求函数,最省事的写法是这样的:
async function fetchData(url: string): Promise<any> { const res = await fetch(url); return res.json(); }这段代码的问题太明显了:调用方拿到any,所有字段都没有约束,写错了编译器不会提醒。t3code 里的做法是定义一个有判别联合(discriminated union)的返回结构:
type ApiResult<T> = | { status: 'success'; data: T } | { status: 'error'; code: number; message: string }; async function fetchData<T>(url: string): Promise<ApiResult<T>> { try { const res = await fetch(url); if (!res.ok) { return { status: 'error', code: res.status, message: res.statusText }; } const data = (await res.json()) as T; return { status: 'success', data }; } catch (err) { return { status: 'error', code: 0, message: (err as Error).message }; } }有判别联合的好处是,调用方拿到返回值后不需要靠注释去猜,类型系统直接告诉你:如果是success分支,你才有.data可用;如果是error分支,你只能拿.code和.message。这比定义一个interface { data?: T; code?: number; message?: string }强得多,因为可选字段没法表达互斥关系,你可能忘了判断data是否为空就直接使用。
泛型约束我这里只点到为止。fetchData<T>中的T是调用方传入的类型,比如你请求/api/user就写fetchData<User>(...)。真正的类型体操像Conditional Types、Template Literal Types这种,除非你在写给所有人用的库,否则真心不建议上来就搞,能把判别联合、null收窄这些基础用扎实,已经能解决 80% 的实际问题。
2.3 “给类型上保险”:运行时校验与类型体操的平衡
这里必须说一个很多人忽略的常识:TypeScript 的类型只在编译期存在,运行时数据可不管你的类型定义。API 返回的 JSON、localStorage 里存的数据、用户输入的环境变量,这些外部边界的数据随时可能不符合预期。类型系统拦不住运行时脏数据,所以 t3code 在数据边界加了一层运行时校验。
我用的是 Zod,因为它对 TypeScript 的推断支持最好。以环境变量为例:
import { z } from 'zod'; const EnvSchema = z.object({ API_BASE_URL: z.string().url(), ENABLE_MOCK: z.boolean().default(false), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), }); type Env = z.infer<typeof EnvSchema>; const env: Env = EnvSchema.parse(process.env);这段代码的核心逻辑是:在应用入口处把process.env这个不可信对象 parse 一次,之后整个应用内部都用env,它既有静态类型保障,又有运行时校验托底。如果环境变量缺了API_BASE_URL或者格式不对,应用在启动时就会立刻报错,而不是等到跑业务逻辑时才莫名炸掉。这个“早失败”原则能帮你省掉大量排查时间。
注意,运行时校验不要用在全项目每个函数的入参上。我曾经在一个工具函数里也加了 Zod parse,结果性能测试发现那个函数被高频调用,每次 parse 都是额外开销,而且报错信息对内部调用毫无意义。我的经验是只在校验“外部输入”时用 Zod:环境变量、API 请求的响应体、localStorage 读取结果、CLI 参数。内部函数之间传的数据已经被上面的边界校验过了,就别重复造轮子。
3. 工程化规范:从代码风格到提交信息的统一
3.1 代码格式化:Prettier 怎么与 ESLint 协作
讲到工程化,第一步是统一格式化。这里最大的坑是很多人不知道 Prettier 和 ESLint 的分工。简单来说:Prettier 管“长什么样”,ESLint 管“有没有问题”。缩进、分号、引号、换行这些美学问题交给 Prettier;no-unused-vars、no-explicit-any、no-floating-promises这些质量问题交给 ESLint。如果它们之间的规则冲突,就会出现“ESLint 说要单引号,Prettier 说必须双引号”这种无休止的拉锯战。
解决冲突的手段是引入eslint-config-prettier,把 ESLint 里所有和 Prettier 冲突的规则关掉。我的 t3code 里 Prettier 配置长这样:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "all" }printWidth: 100是我试过 80 和 120 之后选的中间值。80 太挤,一行逻辑很容易被拆到三行;120 又太长,横向滚动看不完代码。100 在大多数屏幕和审查工具里都比较舒适。trailingComma: all是 macOS 上多行参数必备的,可以避免最后一行没加逗号导致 git diff 出现多余行,也让后续新增参数时改动更干净。
实际操作里,我建议你在编辑器里开启保存时自动格式化。VS Code 的设置是把 Prettier 设为默认格式化器,然后开启formatOnSave。这一步能做到“写完代码,格式已经统一”,不需要等提交钩子再来纠正。
3.2 ESLint 规则集与个性化调整
ESLint 9 的 flat config 长这样:
import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; export default tseslint.config( ...tseslint.configs.recommended, { rules: { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-floating-promises': 'error', 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, prettier, );这几条规则是我精心挑过的。@typescript-eslint/no-explicit-any设成 warn 而不是 error,是因为实际开发中偶尔确实需要any和第三方非类型化库对接,设为 error 会逼人去写危险的@ts-ignore,反而更糟。warn 能让你看见它,同时不打断提交流程。
@typescript-eslint/consistent-type-imports强制你写import type { SomeType }而不是import { SomeType }。这看起来只是风格差异,但如果你用的是 tsc 编译产物或者某些 tree-shaking 不彻底的工具链,import { SomeType }可能被保留为运行时导入,导致无用的代码被打进包里。这个规则用一次就能体会到价值。
@typescript-eslint/no-floating-promises是抓漏网之鱼的利器,它要求 Promise 必须被处理,不是await就是.catch。我见过无数线上 bug 源自某处someAsyncFunction()调完没 await,代码继续往下走,数据还没回来就用了。这条规则会直接报错,逼你想清楚异步依赖。
3.3 Git 提交纪律:lint-staged + commitlint
工程化的关键转折点是 Git 钩子。如果你只在 CI 里做检查,那么开发者的每次本地提交都已经把半成品写进了历史;正确做法是在提交之前就拦住问题。我用 Husky 配了两个钩子:
.husky/pre-commit:
pnpm lint-staged.husky/commit-msg:
pnpm dlx commitlint --edit "$1"lint-staged的配置放在package.json里:
{ "lint-staged": { "*.{ts,tsx}": ["prettier --write", "eslint --fix"], "*.{md,json,yml}": ["prettier --write"] } }这里有个重要细节:lint-staged只会处理你git add过的暂存文件,不会全量扫描。这样才能保证提交速度不会因为项目变大而变慢。它先跑 Prettier 重写格式,再跑 ESLint 修复质量问题,顺序不能反,因为 ESLint 修完后可能又引入格式问题,而 Prettier 通常不会触犯 ESLint 规则。
commitlint我用的@commitlint/config-conventional,提交信息必须是type(scope): subject格式,比如feat(core): add retry logic、fix(cli): handle empty input gracefully。为什么这么严格?因为提交信息是 changelog、git blame、版本回滚的重要线索。你一个月后看一条提交写fix stuff,根本不知道改了啥;写fix(core): handle empty input gracefully,一目了然。
4. 自动化流水线与发布流程:把重复劳动交给机器
4.1 本地命令设计
工程化工具链要让你“无脑执行”,所以命令设计非常重要。t3code 的package.json脚本如下:
{ "scripts": { "typecheck": "tsc --noEmit", "lint": "eslint .", "format": "prettier --write .", "test": "vitest run", "check": "pnpm typecheck && pnpm lint && pnpm test" } }pnpm check是我每天写代码时使用频率最高的命令,一下搞定类型检查、代码检查和单元测试。注意这里我用了&&而不是并行执行,原因是类型检查如果失败,lint 和 test 大概率也是白跑的,串行能更快暴露根因,避免三份输出混在一起干扰判断。
真正提交之前的完整流程是这样的:我先跑pnpm check确认所有检查通过,然后git add暂存文件,Husky 在 pre-commit 里跑 lint-staged 自动格式化暂存文件,最后在 commit-msg 钩子里校验提交信息格式。整个过程大概几秒钟,换来的是一份干净、可回溯的 Git 历史。
4.2 CI 里怎么配置检查项
本地钩子有它的局限:一个人可以绕过钩子强制提交,不同开发者的环境也可能不一样。所以 t3code 再叠加了一层 CI 检查,我用的是 GitHub Actions:
name: check on: push: branches: [main] pull_request: jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check - run: pnpm build一个容易被忽略的关键点是pnpm install --frozen-lockfile。这个参数会严格按照pnpm-lock.yaml的依赖版本安装,如果 lockfile 与package.json不一致,命令直接失败。它保证了 CI 环境里所有人装的依赖版本完全一致,而不是“装到最新兼容版”,否则本地可能好好的,CI 却因为某个依赖更新挂了。
cache: pnpm在setup-node里配置,配合前面的pnpm/action-setup,可以缓存全局 store,让 CI 安装依赖快很多。我实测一个有 500 个依赖的 monorepo,缓存命中时 install 时间从 40 秒降到了 8 秒左右。CI 跑得越快,开发者越愿意提交前先跑一遍检查。
CI 和本地检查有个明显的区别:CI 能看到 PR 分支的完整上下文。所以在 t3code 里,CI 对 PR 做全量 lint 和 build,本地 pre-commit 只做暂存区轻量处理。两个级别的检查目的不同,前者保证合入主分支的代码是健康的,后者保证开发者提交时不会被冗长检查打断思路。
4.3 版本发布与变更日志
当 t3code 里的包需要发布到 npm 时,我用 changesets 来统一管理版本和 changelog。流程是固定的:
pnpm changeset:交互式选择变更的包,填写变更说明,系统会在.changeset目录生成一个 markdown 文件。pnpm changeset version:根据变更类型自动升级版本号,并更新 CHANGELOG.md。pnpm changeset publish:执行 npm 发布。
语义化版本该怎么判断?我按这个标准执行:修复 bug、不改变现有 API 行为 → patch(1.0.x);新增功能、向后兼容 → minor(1.x.0);破坏性改动、API 不兼容 → major(2.0.0)。很多人在这件事上要么懒得上报 minor,要么在破坏性改动时不好意思升 major,导致下游用户的依赖锁定策略失效。t3code 的策略是:宁可早升 major,也别让使用者被意外破坏。
changesets 生成 changelog 的好处是,它把“改动说明”的时机放在了开发时,而不是发布时。你开发完一个功能顺手写一句说明,几个月后回顾版本记录时,一切清清楚楚。不用靠猜。
5. 实战踩坑记录:t3code 运行八个月后的那些坑
5.1 版本兼容性问题
工具链最大的坑往往不是配置本身,而是版本组合不兼容。我遇到的第一起事故是 ESLint 9 发布后,typescript-eslint插件还在 6.x 时代,两者一起工作直接崩溃,报错长这样:Cannot read properties of undefined (reading 'get')。排查了半天才知道是@typescript-eslint/parser和 ESLint 9 的 API 不兼容。解决办法是升级typescript-eslint到 7.x/8.x 的对应版本。
这种事在 JavaScript 生态里太常见了。我想给你三个建议:
- 每次升级大版本前,先看工具的官方迁移指南,比如
eslint.config.js的 flat config 指南。 - 不要盲目追新,稳定优先。t3code 里的依赖除非有安全漏洞,否则我一般不做大版本升级。
- 遇到诡异的报错信息先怀疑“版本不匹配”,而不是去猜业务代码。
还有一次是 pnpm workspace 内部包的版本依赖问题。某个包的package.json里写了对 workspace 包的workspace:*依赖,发布时如果忘记转换成正式版本号,npm 上就会引用一个不存在的版本。这个坑在 monorepo 发布时特别容易踩,changesets 的配置能自动处理转换,但前提是每条发布规则都配置正确。
5.2 “安静的错误”:tsc 没报错但有隐患
类型系统不是万能的。我举一个 t3code 里真实发生过的例子:
// 从 localStorage 读用户信息 const user = JSON.parse(localStorage.getItem('user') ?? '{}'); console.log(user.name); // TS 不报错,但运行时可能输出 undefinedJSON.parse默认返回any,所以user.name在编译期没有任何报错,直到运行时数据缺失,界面黑屏。这类“安静的错误”最可怕,因为它不会在 CI 里暴露,只有真实用户才能踩到。t3code 的解法是在边界处加 Zod 校验,前面说过的那套方案,让错误在入口处就暴露:
const UserSchema = z.object({ name: z.string(), age: z.number().optional(), }); const user = UserSchema.parse(JSON.parse(localStorage.getItem('user') ?? '{}'));如果localStorage里的数据不符合 schema,parse 会直接抛错,并告诉你具体是哪个字段、期望什么类型。这个错误可以被全局错误处理捕获,弹一个友好的提示,而不是让界面在白屏状态下静默失败。我花了很多时间才意识到一个道理:编译期类型是“可能正确”,运行时校验是“确保正确”,两者缺一不可。
5.3 lint-staged 只格式化暂存文件的陷阱
说到 lint-staged,一定有人会遇到一个困惑:我明明在本地提交前把所有文件都格式化好了,为什么 Git 历史里还能看到未格式化的代码?答案是 lint-staged 只处理你git add过的暂存文件。如果你同时改了 A 和 B 两个文件,但只git add A,提交时 pre-commit 只会检查 A,B 的格式问题完全不会被拦截,等你下个提交再 add B 时 B 才被统一格式化。
这个设计本身是合理的——只处理暂存区能保证提交性能,但不能只依赖它。我的策略是两层配合:pre-commit 用 lint-staged 做轻快速修,pre-push 再跑一次全量pnpm check,确保推到远端的分支整体是干净的。另外,如果仓库里有一些历史文件从没被格式化过,建议单独跑一次pnpm format处理干净,否则后续每次改动那个文件都会出现大量无关的格式 diff,Review 起来极度痛苦。
5.4 日常使用时的“过度工程”警告
写工程化配置最容易被自己的热情带偏,陷入“规则越多越好”的误区。我见过有人把 ESLint 规则开了 200 多条,团队每次提交都在处理“为什么这里不允许 unary operator”这种和业务毫无关系的问题,最后大家怨声载道,干脆一把梭--no-verify绕过钩子。要避免这种情况,我给 t3code 定了三条规则准入标准:
- 这个规则能防住真实 bug 吗?如果防不住,只是风格偏好,说明它应该交给 Prettier。
- 团队新人能在 1 分钟内理解为什么有这个规则吗?如果解释不通,这条规则就是个噪音。
- 违反规则时的报错信息是否清晰?一个好的规则报错时应该告诉你“该做什么”,而不是“不允许什么”。
举个例子,@typescript-eslint/consistent-type-imports这条规则就容易解释——“防止类型导入被误编译进运行时产物”,报错信息会直接提示改成import type。而像@typescript-eslint/no-magic-numbers这种强制把数字抽成常量的规则,它对小项目弊大于利,我会直接关掉。配置不是考试,不是规则越多分越高;好的配置应该是每个规则都有存在的理由,且能被团队理解。
6. 把 T3Code 复用到自己的项目里
如果你想把 t3code 的思路移植到自己的项目,不需要全部照搬,按顺序做这几步就够了:
- 先把
tsconfig.json里的严格选项开齐,特别是noUncheckedIndexedAccess和exactOptionalPropertyTypes,改代码适应新类型的过程会有点痛,但值得。 - 加上 Prettier + ESLint + lint-staged,从“提交前自己检查”变成“提交前自动检查”。
- 接上 commitlint,规范提交信息,这一步成本极低,收益立竿见影。
- 等以上都稳定了,再上 CI 和 changesets,把发布流程也自动化。
我的建议是,如果你只加一个工具,那就先加 lint-staged 加 Prettier;如果你只开一条严格类型规则,那就先开noUncheckedIndexedAccess。这两个改变对日常开发的影响最大,也最容易适应。
最后说点个人体会:t3code 能坚持下来,不是因为它配置得多花哨,而是它把质量检查放在了一个谁都绕不开的位置——提交之前。代码在进版本库之前,机器已经把风格、类型、基本逻辑都过了一遍,人工 Review 的精力就可以集中在设计意图和业务逻辑上。这种分工才是我做这套东西的真正收获。我自己现在的习惯是,写任何新项目都要先花 30 分钟把这套配置铺好,再写第一行业务代码,看起来慢,实际后续省掉的时间远超这些。