t3code 这个代号听起来挺唬人,其实它不是什么商业产品,也不是某个大厂的开源框架,它是我最近整理的一套个人代码工程规范加脚手架。起名的时候正好手边摆着三本厚书——一本讲类型系统,一本讲测试,一本讲整洁架构,三样东西都跟我的日常开发纠缠不清,于是干脆叫了三段截出来的名字。这项目要解决的事情很具体:让 TypeScript 的类型安全不仅仅停留在编译期,而是真正贯穿到项目运行的每一个触点;同时用尽可能精简的依赖面,减少我在后期维护时反复踩坑的概率。
如果你也跟我一样,受够了"写起来很爽、维护起来想骂人"的代码,或者正在从"会写功能"往"能把项目做好、做稳"的阶段过渡,那这篇文章值得你花十几分钟看完。我会结合 t3code 的实际搭建过程,把项目设计的思路、关键模块的实现方式、以及我在实战里排过的雷全部拆开讲。
1. 项目概述:t3code 到底在解决什么问题
1.1 从一个混乱的中型项目说起
先说为什么会有这个项目。上个月我接手了一个半死不活的中型 Node 服务,代码量两万行出头,不算大,但维护成本高得离谱。问题不是没人写注释,也不是代码写得丑,而是整个项目没有任何"边界感":数据库实体直接散落在路由回调里、业务逻辑跟请求参数耦合在一起、工具函数里藏着一堆修改全局状态的副作用。我每次改一个字段,都要用 Ctrl+F 全项目搜一遍,生怕漏了哪个隐式依赖。
这种状态其实非常典型。很多项目死不了,但也活不好,根源就在于代码的组织方式没有形成层级。当时我花了两天时间整理改造方案,后来干脆抽象出一套自己的代码结构规范,也就是 t3code 的前身。它的核心信条非常简单:把项目拆成三个职责明确的层次,每一层只允许跟相邻层对话,不允许跳层访问。这三个层次分别是类型域、服务域和接口域,对应到代码里就是 types 目录、core 目录和 handlers 目录。
听起来像是老生常谈的分层架构,但 t3code 跟传统三层架构有个关键区别:我把"类型"单独拎出来作为一个独立的一等模块。大多数项目里类型定义都是边写边加,散落在各个文件里,最后变成一锅粥。t3code 要求所有跨层共享的类型必须提前定义、统一管理,这不是为了形式主义,而是为了让编译器和编辑器帮我们把大部分低级错误挡在门外。我在重构过程中体会特别深:一旦类型收敛到一处,项目里大量隐藏的接口契约问题直接显形,很多 bug 在跑起来之前就被类型检查掐死了。
1.2 这套东西适合谁,不适合谁
先说适合谁。如果你正处在一个挺尴尬的阶段:业务功能写得出,但项目一复杂就乱,或者你带的团队里每个人写代码风格都不一样,急需一套统一约束,那 t3code 很适合你。它不挑技术栈,不依赖某个特定框架,核心就依赖 TypeScript 配一个轻量运行时,你完全可以把它嫁接到现有的 Express、Fastify 或者纯 Node 项目里。
但如果你要的是一个开箱即用的重型脚手架,希望 npm create 一条命令装好数据库 ORM、权限框架、消息队列、容器编排,那 t3code 可能不是你要的东西。它的设计哲学恰恰是反其道而行:能用语言原生能力解决的,就不引入依赖;能用一个 function 解决的,就不包装成 Class;能靠静态类型约束住的,就不写运行时的防御判断。这种"少即是多"的取向,适合那些愿意为长期的清爽维护性支付一点前期设计成本的人。
我自己给它定的定位是"一套可以用一辈子的代码底层模板",你可以基于它长出任何形态的上层业务。下面我就把它的核心设计思路和具体落地步骤一步步展开。
2. 架构设计与取舍思路
2.1 为什么基石必须是 TypeScript,而不是 JavaScript
t3code 这个项目在技术选型时,第一件没有犹豫的事就是语言层必须用 TypeScript。但这里我想说点反常识的看法:很多人用 TypeScript 只是为了拿到"类型报错提示",其实连类型系统一半的好处都没用到。t3code 真正依赖的是 TypeScript 的可辨识联合类型和穷尽性检查。
可辨识联合类型是什么?生活化一点说,就像快递柜里的小格子,每个格子外面贴着单据类型,有的是普通件、有的是生鲜件、有的是大件,每种件的处理流程都不一样。如果你拿到的单据类型不匹配,系统会直接提示你这个问题。在代码里,我经常用这种类型来强制所有业务分支都被正确处理。比如一个订单状态,可能有 pending、paid、shipped、cancelled,每一种都携带自己的附加字段。以前用 JavaScript 写,我可能会在代码里到处判断 status === 'paid',一旦拼错字符串,只有运行时才能发现。在 t3code 里,我让编译器把所有可能的订单状态列出来,你处理漏了一个分支,直接编译不过。
这套机制最值钱的场景就是数据流转。数据从 HTTP 请求进来,到服务层处理,再到持久化输出,每个环节的数据形状如果有偏差,传统做法是等到线上报警,t3code 的做法是在代码还没跑起来的时候就逼你面对。实际项目里我用它消灭了一整类"字段丢了""类型写错了""枚举值记不清"的 bug。
2.2 依赖面的断舍离:能不用就不用
设计 t3code 时,我给自己定了一条硬性指标:任何第三方依赖,必须能说清楚它解决的是"不可抗拒的问题",还是只是"图省事"。比如 HTTP 框架属于前者,因为直接用原生 http 模块写路由确实太原始;但像 lodash 这种工具库就属于后者,因为现代 JavaScript 和 TypeScript 的原生能力已经覆盖了绝大多数场景。
很多项目的复杂度失控,不是业务多难,而是依赖树长得太快。你装了一个工具库,它自己又拖了十几个传递依赖,任何一个小版本升级都可能引发"蝴蝶效应"。t3code 底子非常干净,核心运行时的依赖一只手数得过来。依赖少的直接收益是全项目可以做到零构建焦虑,升级不心惊,扫描漏洞也快。
当然,有些"省事"该省也得省。比如类型校验库,我见过很多人为了省事,在项目里引入 zod 之类的运行时校验方案,这确实能把参数校验写得短。t3code 的折中方案是:编译期能解决的靠类型系统,运行时必须要校验的(比如接口边界输入、外部系统返回数据)我再引入最小化的校验函数,自己手写也不超过二十行。用类型系统做大部分约束,把运行时校验留给真正的不可信边界,这个思路是 t3code 的一个主干。
2.3 三层模型的边界:类型域、服务域、接口域
t3code 代码库的物理结构围绕三个核心目录展开,每个目录都担当不同的角色。我把它画成一个简单的依赖方向:handlers 可以引用 core,core 可以引用 types,types 谁都不引用,独立在最底层。反过来是不允许的,core 绝不允许直接操作 HTTP 请求对象,types 里绝不允许出现任何业务逻辑代码。
types 目录存放所有跨层共享的数据描述:实体类型、值对象类型、可辨识联合的事件类型、函数签名等。它是全项目的"宪法",改这里需要非常谨慎。
core 目录是业务逻辑的本体,包括服务类、领域函数和与之配套的纯函数工具。它只知道自己操作的数据长什么样,不知道数据从哪儿来、到哪儿去。这样设计最大的好处是核心逻辑可以脱离 HTTP 环境被单独测试,我甚至可以直接在 REPL 环境里调用它调试。
handlers 目录负责与外部世界打交道:解析请求、验证输入、把 core 的计算结果序列化返回。它要做的事情尽量薄,只做翻译和适配,不掺杂业务判断。
这套分层做下来,项目里"改数据模型导致全盘崩坏"的情况基本绝迹了。因为改动会先在 types 层暴露所有受影响的签名,编译错误列表就是一份完整的改动影响报表。
3. 核心细节:配置一段能直接抄走的 t3code 模板
3.1 初始化二叉树一般的 tsconfig
t3code 对 TypeScript 编译配置的要求比一般项目严格不少。我见过太多项目直接用 tsconfig 默认参数,那等于把 TypeScript 的高阶防护功能全关了。下面这份配置是我在 t3code 里用的,你可以直接作为起点。
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "sourceMap": true, "outDir": "dist", "rootDir": "src" }, "include": ["src"], "exclude": ["node_modules", "dist", "test"] }别嫌这些选项多,里面每一个都在实战里救过我。noUncheckedIndexedAccess会让所有数组下标的访问结果带上undefined风险,表面上是给你添麻烦,实际上是逼你处理"取不到值"的情况。exactOptionalPropertyTypes则严格区分"属性不存在"和"属性值为 undefined",这个细节最容易在序列化接口时产生隐性 bug,开了以后立刻就暴露。
我特意没开strictNullChecks之外的某些更冷门的选项,因为配置太激进会在早期劝退团队里的新人,平衡才是可持续的关键。这组配置我使用了接近一年,在多个项目间复制粘贴,效果都很稳定。
3.2 三层目录的样板结构
直接展示一下我创建新项目时的初始目录骨架:
src/ ├── types/ │ ├── index.ts │ └── entities.ts ├── core/ │ ├── error.ts │ ├── services/ │ │ └── userService.ts │ └── utils/ │ └── result.ts ├── handlers/ │ ├── http/ │ │ └── userHandler.ts │ └── cli/ │ └── userCli.ts ├── config.ts └── index.tstypes/index.ts只做重新导出,真正的实体定义放在entities.ts,这样导入路径会很好看,不会出现一长串目录引用。core/error.ts定义了统一的错误类型,core/result.ts封装函数的返回结果,让业务逻辑不依赖异常机制来传递常规失败。handlers/http和handlers/cli分开是因为同一个服务逻辑可能被多个入口复用。
3.3 用 Result 类型让函数不再"一半靠返回,一半靠抛错"
很多 JavaScript 项目里,函数灾难性失败靠 throw,轻微失败靠返回 undefined 或者 null,调用方处理起来非常割裂。t3code 里的核心函数统一使用 Result 模式,这是我从函数式编程里借来的思路,但实现得特别轻量。
export type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E }; export function ok<T>(value: T): Result<T, never> { return { ok: true, value }; } export function fail<E extends Error>(error: E): Result<never, E> { return { ok: false, error }; }这个 Result 类型像是一颗摇骰子:要么成功,要么失败,没有第三种含混状态。调用方看到ok分支才能拿到 value,看到ok: false就必须面对 error。这种写法让函数的失败路径变得显式,再也不会出现"忘了处理返回值为空"的情况。
在核心服务代码里,我很少直接抛异常。数据校验失败返回fail(new ValidationError(...)),数据库查询为空返回fail(new NotFoundError(...)),正常情况下返回ok(data)。只有真正不可恢复的编程错误才使用 throw,因为它们的目标是立刻终止程序,而不是被业务代码捕获。
4. 实操过程:从零搭建一个 t3code 风格的小功能
为了让你能直观看到这架构跑起来的样子,我拿一个非常常见的"用户注册"功能演示完整的实现链路。虽然业务简单,但每一层的边界、类型定义和错误处理都会全部到位,你可以照着这个模式扩展任何业务。
4.1 环境准备与初始化命令
本地建议使用 Node.js 18 以上版本,因为 t3code 大量利用原生 fetch、原生测试运行器,这些在 18 之前都还是实验状态。包管理器我推荐 pnpm,它的依赖隔离逻辑干净,安装快,磁盘占用小。如果你没有 pnpm,用 npm 也行,不影响代码逻辑。
初始化项目可以这样执行:
mkdir t3code-demo cd t3code-demo pnpm init pnpm add -D typescript @types/node tsx注意这里我把 tsx 加进了开发依赖,它是用来在开发环境直接执行 TypeScript 的轻量 runner,不需要额外配置,比 ts-node 的体验好很多。之后初始化 tsconfig,把上文那份配置放进去,然后创建src目录结构。
4.2 定义核心实体与错误类型
先给用户注册功能定好类型。我把用户对象单独抽一层,是因为注册接口接收的输入、数据库里存的数据、返回给前端的响应,三者其实不是完全一样的形状,混在一起容易埋雷。
// src/types/entities.ts export interface User { id: string; email: string; nickname: string; createdAt: Date; status: 'active' | 'disabled'; } export interface CreateUserInput { email: string; nickname: string; password: string; }这里我没有把密码写进 User,也没有直接使用一个全量的 UserDTO 做输入,因为接口层需要限制输入范围。input 类型和实体类型分开之后,字段失控的问题自然就消失了。
错误类型集中在 core 层:
// src/core/error.ts export class AppError extends Error { readonly code: string; constructor(code: string, message: string) { super(message); this.code = code; this.name = new.target.name; } } export class ValidationError extends AppError { constructor(message: string) { super('VALIDATION_ERROR', message); } } export class ConflictError extends AppError { constructor(message: string) { super('CONFLICT', message); } }用 new.target.name 取代手写 name,是为了子类继承出新的错误类型时,错误名还能保持一致。这是一个非常细但很有用的点。
4.3 业务核心:注册服务实现
核心服务是纯函数化的模块,它不关心请求从哪来。我假设外部已经传入一个UserRepository接口,所有数据落地动作都先抽象到这里。
// src/core/services/userService.ts import type { User, CreateUserInput } from '../../types/entities.js'; import { Result, ok, fail } from '../utils/result.js'; import { ConflictError, ValidationError } from '../error.js'; export interface UserRepository { findByEmail(email: string): Promise<User | null>; insert(user: User): Promise<User>; } export async function createUser( repo: UserRepository, input: CreateUserInput, ): Promise<Result<User, ValidationError | ConflictError>> { const email = input.email.trim().toLowerCase(); if (!email.includes('@') || !input.nickname.trim()) { return fail(new ValidationError('邮箱或昵称不符合要求')); } const existing = await repo.findByEmail(email); if (existing) { return fail(new ConflictError('该邮箱已被注册')); } const user: User = { id: crypto.randomUUID(), email, nickname: input.nickname.trim(), createdAt: new Date(), status: 'active', }; return ok(await repo.insert(user)); }注意两个细节。一是crypto.randomUUID()是 Node 原生能力,不需要引第三方库。二是函数返回Result,而不是抛异常,这样调用者一眼就能看到有两个失败分支需要处理。我在user构造时没有把密码存进实体,密码处理属于更底层的安全问题,交给存储适配器处理,业务层不碰加密细节,职责更清晰。
4.4 HTTP 接入层:让控制器保持干爽
HTTP handler 要做的事情就三件:把请求体解析出来,调 core 里的服务,把 Result 翻译成 HTTP 响应。
// src/handlers/http/userHandler.ts import { createUser, type UserRepository } from '../../core/services/userService.js'; import { ok, fail } from '../../core/utils/result.js'; export function createUserHandler(repo: UserRepository) { return async (req: Request): Promise<Response> => { if (req.method !== 'POST') { return new Response('Method Not Allowed', { status: 405 }); } let body: unknown; try { body = await req.json(); } catch { return Response.json({ error: '请求体不是合法 JSON' }, { status: 400 }); } // 只取出我们关心的几个字段,不依赖未知对象属性 const input = { email: typeof body.email === 'string' ? body.email : '', nickname: typeof body.nickname === 'string' ? body.nickname : '', password: typeof body.password === 'string' ? body.password : '', }; const result = await createUser(repo, input); if (result.ok) { return Response.json({ data: result.value }, { status: 201 }); } if (result.error instanceof ValidationError) { return Response.json({ error: result.error.message }, { status: 400 }); } if (result.error instanceof ConflictError) { return Response.json({ error: result.error.message }, { status: 409 }); } return Response.json({ error: '服务器内部错误' }, { status: 500 }); }; }这里我没有把 body 直接断言成 CreateUserInput,很大概率会踩坑。运行时拿到的数据永远是 unknown,必须先收窄再使用,这是我的安全底线。
4.5 测试:写在业务旁边,而不是另一个仓库
用了 t3code 之后,我的测试文件习惯改成放在每个模块隔壁,命名*.test.ts, Node 原生 test runner 可以直接跑,不依赖 jest 或 vitest 这些重量级工具。
// src/core/services/userService.test.ts import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createUser } from './userService.js'; test('createUser 应该返回冲突错误当邮箱已存在', async () => { const repo: UserRepository = { findByEmail: async () => ({ id: 'old', email: 'a@b.com', nickname: 'a', createdAt: new Date(), status: 'active' }), insert: async (user) => user, }; const result = await createUser(repo, { email: 'A@B.com', nickname: '老用户', password: 'x', }); assert.equal(result.ok, false); });执行测试只需要一行命令:
pnpm tsx --test src/**/*.test.ts这样的测试矩阵覆盖了核心业务逻辑,依赖被 mock 掉了,HTTP 层不需要启动真实端口也能保证业务正确。整个测试链路没有额外依赖,新人上手非常轻松。
5. 常见问题与排错技巧实录
5.1 数组越界访问竟然在编译期就抓到
我一开noUncheckedIndexedAccess的时候,项目里瞬间冒出来几十个类型错误,基本都是数组下标访问导致的。说实话当时有点抱怨,但后面确实靠它抓到了一个实实在在的 bug。
那是一个配置项的解析逻辑,我写了一段const region = configs[i].region,理论上 i 不会越界,但一旦前面的过滤条件有遗漏,这里就会静默读到一个 undefined。传统 JavaScript 里这个 bug 会一直潜伏到线上环境,表现为配置某个模块突然失效,排查起来非常痛苦。开了严格选项之后,编译器直接提示 region 可能是 undefined,逼我在使用前确认。正确的处理是补一个判断:
const item = configs[i]; if (!item) { throw new InvalidConfigError(`下标 ${i} 不存在`); } const region = item.region;类似这种小判断每写一个,就等于在程序运行前埋了一颗防地雷的探测器,排查成本直线下降。
5.2 类型收窄失败的经典场景
使用可辨识联合类型时,最容易犯的错误是以为靠 if 判断就一定能收窄类型,其实一旦你把对象属性用解构方式取出来,TypeScript 就丢失了属性之间的关联信息。比如:
const status = payment.status; if (status === 'paid') { // 这里依然拿不到 paid 状态下的特有字段 }正确做法是直接对对象本身收窄:
if (payment.status === 'paid') { payment.transactionId // 这个字段被正确推断出来 }这个坑我踩过一次后,现在已经变成团队 code review 的铁律:要做类型分支判断,不要预先解构,保持引用原对象。
5.3 异步函数错误堆栈神秘消失
用 Result 模式之后再配 async 函数,有一个问题是调试时堆栈信息不完整。因为错误是通过返回值传递的,异常被亲手捕获后创建,堆栈只有创建错误时的那一帧上下文,并不包含完整的调用链。这在排查深层嵌套时有点不便。
我目前的应对方式是在核心边界处把上下文信息拼进错误消息。例如在 service 失败时不仅返回错误类型,还可以在错误对象上追加context字段,记录当时的入参摘要。这样虽然无法恢复完整调用栈,但足以定位业务端的问题根源。还有一个小技巧:在fail(new ValidationError('...'))前写一两行临时console.error,调试完再删,比对着抽象错误瞎猜快得多。
5.4 环境变量与配置的十宗罪
项目里最难排查的问题之一就是环境变量。t3code 对配置管理有一个简单粗暴的规定:所有环境变量集中到config.ts读取,任何业务模块不得直接调用process.env。这样可以避免拼写错误散布整个项目。
// src/config.ts function getRequiredEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`缺少必需的环境变量: ${name}`); } return value; } export const config = { port: Number.parseInt(getRequiredEnv('PORT'), 10), jwtSecret: getRequiredEnv('JWT_SECRET'), };注意端口不能用字符串直接用,Number.parseInt失败时还要处理 NaN。集中读取加启动校验,属于一次写、终身受益的配置方案。
5.5 常见问题速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 数组 index 处出现 undefined | 没有开 noUncheckedIndexedAccess | 开启选项,对可选结果显式判空 |
| 解构后类型丢失 | 先解构再判断可辨识联合 | 改为直接对原对象属性做分支判断 |
| 错误堆栈信息不完整 | Result 模式返回错误导致上下文丢失 | 在错误对象上附加业务上下文 |
| 环境变量取值处全是魔法字符串 | 散落读取 process.env | 集中在 config.ts 做读取和启动校验 |
| 接口返回字段时多时少 | 直接返回实体对象 | 定义独立 DTO,手工映射字段 |
| 依赖升级把全项目干崩 | 依赖面铺太广 | 控制第三方依赖数量,定期审视依赖树 |
6. 老话重提:一些实操后的碎碎念
整套 t3code 从设计到现在,我前后迭代了三个版本。第一版死磕类型体操,为了写出"完美的泛型签名"浪费了大量时间,后来发现收益很低。第二版又走到另一个极端,为了极简把很多本来清晰的东西压缩成了黑魔法。现在的第三版找到了平衡点:类型要强,但不炫技;依赖要少,但不拒绝工具;分层要清晰,但不搞过度抽象。
我个人的建议是,如果你打算在自己的项目里试试这套模式,不要一次性推翻重构所有代码。挑一个新功能或者一个独立模块,用 t3code 的分层思路从零写一遍,跑通之后再决定要不要逐步迁移。代码规范这种东西,说一千道一万都不如亲手运行一次来得可信。t3code 没有什么神秘魔法,它就是逼着你在写代码的每个岔路口多看一眼"这样设计以后改起来会不会痛",然后用类型系统和分层边界把大部分"痛"提前挡住。这也是我做完这个项目之后最大的一个体会:好的代码结构不一定让你写第一版时更爽,但它一定让你在第七次改需求时依然睡得着觉。