资讯动态

TypeScript 类型安全与分层架构实战:t3code 代码工程规范解析

发布时间:2026/10/27 16:28:35 来源:尧图企业网站定制
t3code 这个代号听起来挺唬人其实它不是什么商业产品也不是某个大厂的开源框架它是我最近整理的一套个人代码工程规范加脚手架。起名的时候正好手边摆着三本厚书——一本讲类型系统一本讲测试一本讲整洁架构三样东西都跟我的日常开发纠缠不清于是干脆叫了三段截出来的名字。这项目要解决的事情很具体让 TypeScript 的类型安全不仅仅停留在编译期而是真正贯穿到项目运行的每一个触点同时用尽可能精简的依赖面减少我在后期维护时反复踩坑的概率。如果你也跟我一样受够了写起来很爽、维护起来想骂人的代码或者正在从会写功能往能把项目做好、做稳的阶段过渡那这篇文章值得你花十几分钟看完。我会结合 t3code 的实际搭建过程把项目设计的思路、关键模块的实现方式、以及我在实战里排过的雷全部拆开讲。1. 项目概述t3code 到底在解决什么问题1.1 从一个混乱的中型项目说起先说为什么会有这个项目。上个月我接手了一个半死不活的中型 Node 服务代码量两万行出头不算大但维护成本高得离谱。问题不是没人写注释也不是代码写得丑而是整个项目没有任何边界感数据库实体直接散落在路由回调里、业务逻辑跟请求参数耦合在一起、工具函数里藏着一堆修改全局状态的副作用。我每次改一个字段都要用 CtrlF 全项目搜一遍生怕漏了哪个隐式依赖。这种状态其实非常典型。很多项目死不了但也活不好根源就在于代码的组织方式没有形成层级。当时我花了两天时间整理改造方案后来干脆抽象出一套自己的代码结构规范也就是 t3code 的前身。它的核心信条非常简单把项目拆成三个职责明确的层次每一层只允许跟相邻层对话不允许跳层访问。这三个层次分别是类型域、服务域和接口域对应到代码里就是 types 目录、core 目录和 handlers 目录。听起来像是老生常谈的分层架构但 t3code 跟传统三层架构有个关键区别我把类型单独拎出来作为一个独立的一等模块。大多数项目里类型定义都是边写边加散落在各个文件里最后变成一锅粥。t3code 要求所有跨层共享的类型必须提前定义、统一管理这不是为了形式主义而是为了让编译器和编辑器帮我们把大部分低级错误挡在门外。我在重构过程中体会特别深一旦类型收敛到一处项目里大量隐藏的接口契约问题直接显形很多 bug 在跑起来之前就被类型检查掐死了。1.2 这套东西适合谁不适合谁先说适合谁。如果你正处在一个挺尴尬的阶段业务功能写得出但项目一复杂就乱或者你带的团队里每个人写代码风格都不一样急需一套统一约束那 t3code 很适合你。它不挑技术栈不依赖某个特定框架核心就依赖 TypeScript 配一个轻量运行时你完全可以把它嫁接到现有的 Express、Fastify 或者纯 Node 项目里。但如果你要的是一个开箱即用的重型脚手架希望 npm create 一条命令装好数据库 ORM、权限框架、消息队列、容器编排那 t3code 可能不是你要的东西。它的设计哲学恰恰是反其道而行能用语言原生能力解决的就不引入依赖能用一个 function 解决的就不包装成 Class能靠静态类型约束住的就不写运行时的防御判断。这种少即是多的取向适合那些愿意为长期的清爽维护性支付一点前期设计成本的人。我自己给它定的定位是一套可以用一辈子的代码底层模板你可以基于它长出任何形态的上层业务。下面我就把它的核心设计思路和具体落地步骤一步步展开。2. 架构设计与取舍思路2.1 为什么基石必须是 TypeScript而不是 JavaScriptt3code 这个项目在技术选型时第一件没有犹豫的事就是语言层必须用 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 可以引用 corecore 可以引用 typestypes 谁都不引用独立在最底层。反过来是不允许的core 绝不允许直接操作 HTTP 请求对象types 里绝不允许出现任何业务逻辑代码。types 目录存放所有跨层共享的数据描述实体类型、值对象类型、可辨识联合的事件类型、函数签名等。它是全项目的宪法改这里需要非常谨慎。core 目录是业务逻辑的本体包括服务类、领域函数和与之配套的纯函数工具。它只知道自己操作的数据长什么样不知道数据从哪儿来、到哪儿去。这样设计最大的好处是核心逻辑可以脱离 HTTP 环境被单独测试我甚至可以直接在 REPL 环境里调用它调试。handlers 目录负责与外部世界打交道解析请求、验证输入、把 core 的计算结果序列化返回。它要做的事情尽量薄只做翻译和适配不掺杂业务判断。这套分层做下来项目里改数据模型导致全盘崩坏的情况基本绝迹了。因为改动会先在 types 层暴露所有受影响的签名编译错误列表就是一份完整的改动影响报表。3. 核心细节配置一段能直接抄走的 t3code 模板3.1 初始化二叉树一般的 tsconfigt3code 对 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 ResultT, E Error | { ok: true; value: T } | { ok: false; error: E }; export function okT(value: T): ResultT, never { return { ok: true, value }; } export function failE extends Error(error: E): Resultnever, 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): PromiseUser | null; insert(user: User): PromiseUser; } export async function createUser( repo: UserRepository, input: CreateUserInput, ): PromiseResultUser, 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): PromiseResponse { 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: ab.com, nickname: a, createdAt: new Date(), status: active }), insert: async (user) user, }; const result await createUser(repo, { email: AB.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 没有什么神秘魔法它就是逼着你在写代码的每个岔路口多看一眼这样设计以后改起来会不会痛然后用类型系统和分层边界把大部分痛提前挡住。这也是我做完这个项目之后最大的一个体会好的代码结构不一定让你写第一版时更爽但它一定让你在第七次改需求时依然睡得着觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑