
从零搭建 t3code一套基于“三个 T”思想的轻量级全栈开发工作流最近在整理自己常用的一套项目脚手架时朋友问我“你每次新起项目都很快到底是用了什么魔法”其实没有什么魔法就是按一套固定的方法论来拆解需求、定数据结构、写测试、收敛工程配置这套方法论我私下里叫它 t3code。t3code 不是一个开源框架也不是某个现成的 CLI 工具它是我对“第三代前端/全栈工程实践”的一种个人理解和总结。所谓三个 T分别对应 Type类型系统、Test测试驱动、Tooling工程工具链。整条思路的核心就一句话用强类型约束数据、用测试锁定行为、用工具链消除琐碎决策。如果你经常从零起项目或者在团队里被“每人一套写法”折磨过那这篇文章应该能给你一些可落地的参考。我会从命名逻辑讲起到类型设计、测试策略、工具链收敛最后给一个最小可运行的项目示例把整套流程完整串一遍。文中有不少代码和配置都是可以直接拷贝改用的不玩虚的。1. 为什么叫“三个 T”t3code 的命名逻辑与选型取舍先解释一下这个标题不然容易误读。t3code 里的 t3直观理解是 third generation 的意思也就是第三代。第一代是前后端完全分离、各自为政的“页面仔”时代第二代是以 Node.js 为桥梁、全栈语言统一的同构时代到第三代我认为核心特征是类型、测试、工具链三者深度绑定成为项目的事实标准和基础设施。很多团队其实已经摸到第三代的门槛但落地时容易走偏。最常见的现象是TypeScript 装了但写起来还是 any 满天飞Vitest/Jest 配了但只在赶工的时候补两个冒烟测试ESLint/Prettier 装了但格式问题和 PR 评论纠缠不清。这些工具单个拎出来都很好缺的是把它们组装成一套有内在逻辑的工作流而不是一堆各自为战的依赖。我自己设计 t3code 这套实践时定了几条硬性约定类型是唯一的事实来源Single Source of TruthAPI 请求、数据库字段、UI 组件的 props全部由同一套类型推导出来。测试不是“有时间再补”的任务而是开发循环的一部分写完一个模块的主体逻辑后第一件事是写它的行为测试。工具链要“少而精”能用一个工具解决的事情绝对不装三个插件每多一个配置文件都是在给后人留坑。这三条约定听起来简单但实际做下来会发现它们会反推你的项目架构。比如因为类型是唯一事实来源你会被迫在写业务代码之前先把数据模型定义清楚因为测试要跟着开发走你会自然而然写出依赖注入、纯函数、副作用隔离的代码因为工具链要精简你会反复质问自己“这个依赖真的有必要吗”。从结果看用 t3code 这套思路搭出来的项目大概长这样前后端共享同一套核心类型包用 monorepo 的 packages/shared 承载。每个业务模块至少有一个对应的测试文件测试文件里覆盖主流程和两个边界分支。工程配置收敛到少数几个文件tsconfig、eslint.config、vitest.config外加一个极简的 package.json scripts。有人可能会问这和 T3 StackT3 App是什么关系T3 Stack 是 Next.js 生态里的一套模板强调 TypeScript、Tailwind、tRPC 三件套。t3code 更偏工程方法和团队规范层面不绑定具体框架。你完全可以在 Vue、Nuxt、Express、NestJS 里使用这套思路它只关心“你怎么组织代码”不关心“你选了哪个渲染框架”。2. 类型优先把数据结构当一等公民来设计t3code 方法论里面第三个字母虽然是 Tooling但真正的地基是 Type。没有一层扎实的类型层后面所有测试和工具链都会变成空中楼阁。2.1 前置建模先画数据形状再写业务逻辑我在实际项目里踩过最大的坑就是“边写边建模”。一开始觉得某个字段以后可能会变于是先用 any 顶着结果半个月后整个函数签名都变成了 any 的传播链——一个地方松了周围所有地方全跟着塌。后来我换了一套做法任何模块开发前第一件事是定义它的数据形状也就是输入、输出、中间状态分别长什么样。这个动作在 t3code 里叫“前置建模”Model First。举个例子假设我们要开发一个订单列表页不要急着写组件先定义这些// packages/shared/src/order.ts export type OrderStatus pending | paid | shipped | completed | cancelled; export interface OrderItem { sku: string; name: string; unitPrice: number; quantity: number; // 小计直接由服务端计算返回避免前后端各自算一遍对不上 subtotal: number; } export interface Order { id: string; orderNo: string; status: OrderStatus; items: OrderItem[]; totalAmount: number; createdAt: string; // ISO 8601 updatedAt: string; } // 列表查询的参数和返回值 export interface OrderListParams { page: number; pageSize: number; status?: OrderStatus; } export interface PaginatedT { list: T[]; total: number; page: number; pageSize: number; }这段代码看起来平平无奇但注意几个细节OrderStatus用字面量联合类型而不是字符串枚举这样在你处理状态流转时TypeScript 可以帮你做穷尽检查exhaustive check。如果你在 switch 里漏掉一个 case编译器会直接报错。createdAt用string ISO 8601 格式约定而不是Date对象。原因很简单类型系统不关心时区用字符串可以避免序列化时 Date 被转成 UTC 字符串的坑。PaginatedT是个泛型后面所有分页接口都可以复用。这时候有人会说TypeScript 类型只是编译期的约束运行时数据还是可能不合法比如后端返回了缺失totalAmount的订单。这就引出了 t3code 的下一条实践类型必须和运行时校验配对使用。2.2 运行时校验用 Zod 把“信任边界”守住类型系统管编译期Zod 之类的 schema 校验库管运行时。t3code 的约定是写在.shared.ts里的类型定义必须带一份对应的 Zod Schema两者放在同一个文件里。// packages/shared/src/order.ts import { z } from zod; export const OrderSchema z.object({ id: z.string(), orderNo: z.string(), status: z.enum([pending, paid, shipped, completed, cancelled]), items: z.array( z.object({ sku: z.string(), name: z.string(), unitPrice: z.number().nonnegative(), quantity: z.number().int().positive(), subtotal: z.number().nonnegative(), }) ), totalAmount: z.number().nonnegative(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type Order z.infertypeof OrderSchema;然后类型不再手写直接用z.infer推导这就消除了“类型定义和校验规则不一致”的可能性。API 边界处直接这样用// apps/api/src/routes/orders.ts import { OrderSchema } from shared/order; import type { OrderListParams } from shared/order; export async function getOrderList(params: OrderListParams) { const response await fetchOrders(params); // 运行时校验不确定的数据一律就地拦截 const parsed z.array(OrderSchema).safeParse(response.data); if (!parsed.success) { // 记录解析失败详情不把脏数据放进应用状态 logger.error(订单数据格式异常, parsed.error.issues); throw new ApiError(INVALID_RESPONSE); } return parsed.data; }说实话很多前端项目里 Zod 是被误用的。有人把它当“表单验证库”只用来校验用户输入其实这浪费了它最核心的能力外部边界数据的可信化。在 t3code 里Zod 的定位是所有“不信任数据”的守门员——后端返回的数据、localStorage 里读出来的数据、URL query 参数、iframe postMessage 传过来的数据全部过一遍 schema。做过支付、做过后台系统的朋友应该懂这一步能挡掉非常多线上事故。2.3 类型组合与领域建模的边界类型设计容易走极端。我见过有人把每个接口的响应类型都定义得极其细致导致类型文件比业务代码还长也见过有人走到另一个极端90% 的类型都是Recordstring, any。t3code 的建议是保持“三明治”结构底层是不可拆分的原子类型SKU、金额、时间戳、状态枚举。中间层是领域实体类型Order、User、Product这些由原子类型组合而成。上层才是接口 DTO、组件 Props、状态切片这些尽量从中间层推导或挑选不重复定义。举个例子订单的totalAmount不应该在 DTO 里重新定义一遍而是PickOrder, totalAmount或者OmitOrder, items这样派生。一旦底层字段改名所有派生类型都会连锁更新不会出现“改了实体忘了改 DTO”的情况。这套做法带来的直接好处是开发时重命名一个字段跑完 TypeScript 编译检查所有受影响的地方一目了然比翻文档高效多了。3. 测试先行让测试成为开发主循环的一部分第二件事是 Test。我经常看到一种论调“小项目不需要测试跑得快就行。”但 t3code 的立场是测试不是工作量而是开发速度的一部分。关键在于你怎么设计测试的粒度和时机。3.1 测试策略不是所有代码都要同样的覆盖密度在 t3code 里我把测试分成三类按优先级排列测试类型覆盖对象优先级典型工具纯逻辑测试工具函数、状态计算、金额计算、状态流转、排序过滤最高必须写Vitest边界契约测试API 响应结构、数据库返回、外部服务交互高重点覆盖Vitest MSW交互测试页面点击、表单提交、UI 状态切换按需核心流程写Testing Library Playwright这个顺序不是随便排的背后逻辑是“投资回报率”。纯逻辑测试运行最快、环境依赖最少、执行结果最稳定写一个 80% 业务逻辑的纯函数测试可能只需要两分钟但它能锁定整个业务规则。交互测试写起来最慢、最容易维护垮掉所以只覆盖核心用户路径。所以实际上t3code 的测试实践是从纯函数和 reducer 开始的不是从 UI 开始的。这样开发时也能最快获得反馈。3.2 一个真实模块的测试编写过程以订单状态流转为例。假设我们有一个纯函数负责执行订单状态变更// apps/web/src/features/order/order-status.ts import type { OrderStatus } from shared/order; const TRANSITIONS: RecordOrderStatus, OrderStatus[] { pending: [paid, cancelled], paid: [shipped, cancelled], shipped: [completed, cancelled], completed: [], cancelled: [], }; export function canTransition(from: OrderStatus, to: OrderStatus): boolean { return TRANSITIONS[from]?.includes(to) ?? false; } export function transition(from: OrderStatus, to: OrderStatus): OrderStatus { if (!canTransition(from, to)) { throw new Error(非法状态流转: ${from} - ${to}); } return to; }对应测试文件// apps/web/src/features/order/order-status.test.ts import { describe, expect, it } from vitest; import { canTransition, transition } from ./order-status; describe(order status transition, () { it(允许 pending 流转到 paid, () { expect(canTransition(pending, paid)).toBe(true); }); it(禁止 ships 之前直接完成, () { expect(canTransition(pending, completed)).toBe(false); expect(() transition(pending, completed)).toThrow(); }); it(已取消的订单不能再次流转, () { expect(canTransition(cancelled, paid)).toBe(false); }); it(终态不能继续流转, () { expect(canTransition(completed, cancelled)).toBe(false); }); });这份测试的用词要提一下我用的是业务语言而不是实现语言比如“禁止 ships 之前直接完成”而不是“调用 transition 函数时 pending completed 会抛错”。前者才是团队成员能读懂的描述后者只是复述了一遍代码。让测试描述成为需求的行为记录这是 t3code 的一条隐形规范。这个例子虽然简单但背后有一个很深的点把业务规则放进纯函数测试成本极低如果把同样的规则写在组件内部的点击事件里那测试就需要渲染组件、模拟点击、等待异步更新成本高出一个量级。所以 t3code 特别强调“逻辑下沉”能抽到纯函数里的逻辑绝不写在组件副作用里。3.3 用 Vitest 和 MSW 守住 API 边界逻辑写完之后第二块要测的是 API 边界。这里最顺手的组合是 Vitest MSWMock Service Worker。MSW 的核心思想是在测试环境里启动一个 Service Worker 层拦截实际的 HTTP 请求返回你定义的 mock 数据。这样做的好处是测试环境跑的是完整请求链路包括序列化、headers 拼接、错误处理而不是直接把某个 mock 函数塞进模块里。// apps/web/src/features/order/orders.test.ts import { setupServer } from msw/node; import { http, HttpResponse } from msw; import { fetchOrderList } from ./api; const server setupServer( http.get(/api/orders, ({ request }) { const url new URL(request.url); const page url.searchParams.get(page) ?? 1; return HttpResponse.json({ list: [], total: 0, page: Number(page), pageSize: 10, }); }) ); beforeAll(() server.listen()); afterEach(() server.resetHandlers()); afterAll(() server.close()); describe(fetchOrderList, () { it(解析并返回分页数据, async () { const result await fetchOrderList({ page: 1, pageSize: 10 }); expect(result.total).toBe(0); expect(result.list).toHaveLength(0); }); it(异常数据结构直接抛出, async () { server.use( http.get(/api/orders, () { // 故意返回缺字段的数据 return HttpResponse.json({ list: oops }); }) ); await expect(fetchOrderList({ page: 1, pageSize: 10 })).rejects.toThrow(); }); });可能有人会问直接测这么细是不是过度了我的回答是API 返回结构是整个项目最脆弱的边界。服务端一个字段改名、一个 null 变成空字符串UI 层可能根本感知不到直到线上数据渲染错误。用 MSW 在开发阶段就模拟出各种异常情况比上线之后出事故好处理太多了。但也要注意测试覆盖是有成本的所以 t3code 的建议是“核心链路全测边角分支按风险选测”比如上面两个用例一个验证正常解析一个验证异常拦截就够了。3.4 把测试命令嵌入开发循环很多人对测试的反感来自“写完代码还要单独跑一下测试”的割裂感。t3code 解决的办fa 是把测试嵌进开发命令里。我的 package.json scripts 一般是这样{ scripts: { dev: vitest run --watch vite, test: vitest run, test:watch: vitest, check: tsc --noEmit eslint . vitest run } }本地开发时vitest 开着 watch 模式你改一个纯函数相关测试立即重跑两秒钟内告诉你刚才改坏了什么。这种即时反馈让测试变成了开发的“后台心跳”而不是一个需要专门抽出时间完成的仪式。我在团队里推行这套之后最大的变化是测试数量不再需要行政命令去逼因为写了测试的开发体验确实更好——改代码之前有数据支撑改完代码立刻知道有没有搞坏东西。4. 工具链收敛少即是多的工程配置哲学第三个 T 是 Tooling。这是 t3code 里最“反堆砌”的部分。最近几年工程配置有越来越复杂的趋势但 t3code 的理念是工具链的目标是消除决策而不是制造新的决策。4.1 最小编程工具清单一个 Node TypeScript 项目我会把工具收敛到以下几项语言编译TypeScript 5.x测试/断言Vitest代码检查ESLint typescript-eslint格式化Prettier提交前检查Husky lint-staged运行时校验ZodAPI MockMSW然后没有了。没有 Babel、没有 SWC除非构建性能无法接受、没有额外的 lint 插件全家桶、没有独立的类型检查工具更不需要为“格式化、排序 import、检查拼写”分别安装三个插件。每个工具的加入都要回答一个问题它能否和现有工具直接协作而不是引入另一套配置语言4.2 一个足够精简但有效果的 ESLint 配置我见过不少团队把 ESLint 做成“规则收费站”几百条规则开着跑一次 lint 能刷屏三页警告但真正常用的还是那几个。t3code 的 ESLint 配置只保留这些核心能力// eslint.config.js import tseslint from typescript-eslint; import prettier from eslint-config-prettier; export default tseslint.config( ...tseslint.configs.recommended, prettier, { rules: { typescript-eslint/no-explicit-any: warn, typescript-eslint/no-unused-vars: [error, { argsIgnorePattern: ^_ }], typescript-eslint/consistent-type-imports: [error, { prefer: type-imports }] } }, { ignores: [dist, node_modules, coverage] } );这里有几个小心思用typescript-eslint的 flat config 直接继承 recommended不必手动列一堆规则。末尾接eslint-config-prettier让 Prettier 管理格式ESLint 只管理逻辑问题两者各司其职。开启consistent-type-imports强制import type和import分开配合打包工具的 tree-shaking能避免运行时加载纯类型文件的问题。关闭所有和格式相关的 rules因为它们只会在团队里制造无意义的 diff。Prettier 配置就一行{ semi: true, singleQuote: false }剩下的默认值够用。我给团队定的规矩是Prettier 不讨论、不覆盖默认值就是标准特殊情况单独提 PR大家统一改不要有人在 local 偷偷改格式化配置。4.3 提交前检查为什么值得留很多人对 Husky 加 lint-staged 这类工具有抵触觉得“我本地代码没问题提交前再检查一遍很耽误时间”。我的体验正好相反提交前检查其实是为了避免“事后打补丁”。{ lint-staged: { *.{ts,tsx,js,jsx}: [eslint --fix, prettier --write], *.{json,md,yml}: [prettier --write] } }执行流程是git commit 时lint-staged 只对暂存区里改过的文件跑检查而不是全量扫描。这样每次提交基本在三五秒内完成。通过了代码风格就是一致的不通过当场改完再提交。这比等到 PR 时机器人评论说“有 14 个文件格式有问题”要省心得多。有一个细节值得说lint-staged 只处理暂存区文件偶尔会遇到“仓库里老文件没跑过格式化新提交的时候 lint 一起报错”的情况。解决办法是把全量格式化拆成一次单独的 chore commit之后就再也不会因为历史欠账干扰新提交了。4.4 TSConfig 的边界既要严格又能干活最后一个配置是 tsconfig。t3code 的默认配置抓住了几个关键开关{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true, resolveJsonModule: true, isolatedModules: true, skipLibCheck: true, types: [vitest/globals] } }两个最容易引发讨论的开关是noUncheckedIndexedAccess和exactOptionalPropertyTypes。noUncheckedIndexedAccess开启后arr[i]返回的类型是T | undefined而不是T。说白了就是逼你处理数组越界的可能性。刚开始写的时候会觉得烦但做实际业务时这条规则帮我抓过很多“undefined 不是函数”的线上错误。忍受一周的编译报错换来的是后续更多类型安全感。exactOptionalPropertyTypes要求“声明了可选属性就不能显式传 undefined”比如props: { name?: string }不允许传{ name: undefined }。这个规则在写组件和接口时能强制你更精确地表达“没有这个字段”和“这个字段的值是 undefined”的区别。5. 落地示例从零搭一个最小的 t3code 风格项目和目录结构说了这么多原则不展示一个可运行的项目骨架是耍流氓。下面用一个“极简订单管理”的例子把 t3code 的完整工作流串起来。5.1 项目结构t3code-demo/ ├── package.json ├── tsconfig.base.json ├── vitest.config.ts ├── eslint.config.js ├── .prettierrc.json ├── packages/ │ ├── shared/ │ │ ├── package.json │ │ └── src/ │ │ ├── index.ts │ │ └── order.ts │ └── web/ │ ├── package.json │ ├── tsconfig.json │ └── src/ │ ├── features/ │ │ └── order/ │ │ ├── api.ts │ │ ├── order-status.ts │ │ ├── order-status.test.ts │ │ ├── orders.test.ts │ │ └── OrderList.tsx │ └── main.ts这个结构遵循 monorepo 的共享类型模式packages/shared里放所有跨端复用的类型定义和 Zod Schemapackages/web只做渲染和交互API 服务端可以采用同样的模式在packages/server里引入 shared 来保证类型同步。5.2 包管理脚本// 根 package.json { scripts: { dev:web: npm -w t3code/web run dev, test: npm -w t3code/shared run test npm -w t3code/web run test, check: npm -w t3code/shared run check npm -w t3code/web run check } }每个包都自带check脚本里面统一执行tsc --noEmit eslint . vitest run这个组合一次跑完类型、检查和全部测试。CI 上再挂一个npm run check就能实现“本地能过CI 必过”。5.3 共享类型包的最小实现// packages/shared/src/order.ts import { z } from zod; export const OrderStatusSchema z.enum([pending, paid, shipped, completed, cancelled]); export type OrderStatus z.infertypeof OrderStatusSchema; export const OrderItemSchema z.object({ sku: z.string(), name: z.string(), unitPrice: z.number().nonnegative(), quantity: z.number().int().positive(), subtotal: z.number().nonnegative(), }); export type OrderItem z.infertypeof OrderItemSchema; export const OrderSchema z.object({ id: z.string(), orderNo: z.string(), status: OrderStatusSchema, items: z.array(OrderItemSchema), totalAmount: z.number().nonnegative(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); export type Order z.infertypeof OrderSchema; // 分页结果泛型 export const createPaginatedSchema T extends z.ZodTypeAny(itemSchema: T) z.object({ list: z.array(itemSchema), total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive(), }); export const OrderListSchema createPaginatedSchema(OrderSchema); export type OrderList z.infertypeof OrderListSchema;这里用z.ZodTypeAny作为泛型约束提供了一个通用的分页 schema 工厂。以后任何分页接口都能直接复用不需要重写一遍分页校验。5.4 Web 端的 API 模块// packages/web/src/features/order/api.ts import { OrderListSchema, OrderSchema } from t3code/shared; import type { Order, OrderStatus } from t3code/shared; export interface OrderListParams { page: number; pageSize: number; status?: OrderStatus; } export async function fetchOrderList(params: OrderListParams): PromiseOrder[] { const search new URLSearchParams({ page: String(params.page), pageSize: String(params.pageSize), }); if (params.status) { search.set(status, params.status); } const response await fetch(/api/orders?${search.toString()}); if (!response.ok) { throw new ApiError(订单列表请求失败: ${response.status}); } const result OrderListSchema.safeParse(await response.json()); if (!result.success) { throw new ApiError(订单列表数据格式异常); } return result.data.list; } export async function updateOrderStatus(orderId: string, nextStatus: OrderStatus): PromiseOrder { const response await fetch(/api/orders/${orderId}/status, { method: PATCH, headers: { Content-Type: application/json }, body: JSON.stringify({ status: nextStatus }), }); if (!response.ok) { throw new ApiError(订单状态更新失败: ${response.status}); } const result OrderSchema.safeParse(await response.json()); if (!result.success) { throw new ApiError(订单数据格式异常); } return result.data; }5.5 组件层面如何使用// packages/web/src/features/order/OrderList.tsx import { useEffect, useState } from react; import { fetchOrderList } from ./api; import type { Order } from t3code/shared; export function OrderList({ page 1, pageSize 10 }: { page?: number; pageSize?: number }) { const [orders, setOrders] useStateOrder[]([]); const [error, setError] useStatestring | null(null); const [loading, setLoading] useState(true); useEffect(() { let cancelled false; setLoading(true); fetchOrderList({ page, pageSize }) .then((data) { if (!cancelled) setOrders(data); }) .catch((err) { if (!cancelled) setError(err.message); }) .finally(() { if (!cancelled) setLoading(false); }); return () { cancelled true; }; }, [page, pageSize]); if (loading) return div加载中.../div; if (error) return div加载失败: {error}/div; return ( ul {orders.map((order) ( li key{order.id} {order.orderNo} - {order.status} - ¥{order.totalAmount.toFixed(2)} /li ))} /ul ); }这个组件本身不承担任何数据校验和业务规则它只负责渲染Order[]而数据从哪来、格式对不对全部由api.ts和 shared 层保证。这也是 t3code 强调的分层UI 尽可能薄逻辑尽可能厚。当运行npm run check时这个项目会依次完成tsc --noEmit类型检查eslint .静态检查vitest run跑所有测试。整个流程大概半分钟适合每天执行很多次。6. 实践中的权衡与典型坑把 t3code 应用到团队时会遇到的问题最后讲讲真刀真枪搬 t3code 时会遇到的摩擦。这些坑多数来自“理想状态”和“团队现实”的碰撞不提前打预防针的话推行到一半很容易回到老路上。6.1 强类型的“心理账户”最大的阻力往往是团队成员觉得写类型太慢。尤其是从 JS 转 TS 的同事会更偏爱用any快速蒙混过关。我遇到过最典型的场景是新人交上来的代码十个函数里有八个参数都是any跑完编译后全绿但一运行就各种报错。应对方案不是制定“禁止 any”的宪法而是建立一个no-explicit-any警告规则同时提供一个更顺手的选择。比如让它用unknown加守卫或者用z.infer生成的类型。我在代码评审时会看一个指标新增代码里any出现的密度。一个模块如果频繁出现any说明这个模块的数据边界没有设计清楚应该回头补 shared 层。只要把一个又一个模块的类型补扎实密度的下降是能被感知到的。6.2 测试用例写成“代码复读机”另一个容易踩的坑是测试写了但全是“复读机”式测试。比如一个加法函数测试就写expect(add(1, 2)).toBe(3)一个组件测试测的就是把组件渲染出来没有抛错。这种测试对行为约束没有任何增量只会增加维护成本。判断测试是否有价值的唯一标准是假如有人把实现改错这个测试能不能及时变红。不能变红的测试不如不写。所以我在团队里鼓励“行为优先”的用例描述测试名和代码里体现的是业务约束不是实现步骤。举一个正面案例测试逻辑是“订单状态流转表不能出现非法跳转”而不是“transition 函数对 pending 的输入返回 paid”。前者描述的是业务规则后者只是复述实现。如果预期行为发生变化前者需要改动测试同步需求文档后者只会让你反复猜“我这一行是不是多余了”。6.3 lint-staged 的“历史包袱”问题前面说过 lint-staged 只扫描暂存区文件但和历史代码还是有冲突的。团队里如果存在一个旧仓库大量文件未经过新规则格式化每次改一个文件都会连带一堆格式报错。处理这个问题我建议分两步走。第一步创建一个格式化全量代码的 chore 提交明文记录在 git 历史里之后所有人从同一个基线出发。第二步把 eslint 和 prettier 的检查范围收窄到 src 下的业务代码dist、generated、vendor 目录全部 ignore。这样既能把规则施行到实际代码上又不会让工具链价值被无关文件稀释。还有一个小坑Husky 在 Windows 环境下偶发无法触发 hook。遇到这种情况先确认是否以 git bash 作为脚本解释器再检查.husky/pre-commit是否有可执行权限。多数是环境问题不是配置问题。6.4 什么时候不要用 t3code 这套方式说句公道话不是所有项目都适合完整的 t3code 实践。如果是两三天就能搞定的一次性原型、内部演示页面、或者数据结构和交互完全不确定的探索型项目强推类型优先 测试驱动会让进度明显变慢。这种情况我一般只保留 TypeScript把 Zod 和测试都弱化成“关键边界才加”。判断标准是项目的生命周期代码被维护三个月以上、或者同时多个人改同一个项目才有必要把完整流程铺开。如果团队规模很小一个人维护全部代码工具链还可以更精简比如把 ESLint 收窄到只开 recommended测试只保留对最核心业务逻辑的覆盖。所有方法论都是在“护栏”和“速度”之间取平衡不要为了工程仪式感牺牲开发节奏。6.5 从现有项目迁移到 t3code 的路径如果手上已经有一个老项目想逐步引入这套实践而不是推倒重来我的经验是分四步走先把表面的“硬伤”清掉导入tsc --noEmit全量检查把阻断性错误清零跑一遍eslint --fix清掉基础格式问题。把 shared 层抽出来主要是跨端复用、最容易出边界问题的类型包和 schema不必所有代码都移动。给新增模块立规矩定一个约定新代码必须带类型定义和至少一个有效测试老代码暂不作为改造目标。逐步替换高危模块优先处理支付、权限、订单这类出错代价高的部分用 Zod 把数据边界封起来用测试锁定状态流转。每完成一步项目都会获得一个稳定可验证的中间态不会出现“改造了一周项目还不能跑”的窘境。我在几个团队迁移过凡是按这个节奏走的一个月左右看不完整套足迹但代码 review 的体验会有明显变化。t3code 这个方法学说到底就是给自己立一条规矩在写业务逻辑之前先用类型把数据说清楚用测试把行为锁住用工具把琐碎事自动化。我第一次完整跑完这套流程时最直观的感受是长期记忆负担变低了——以前要记住每个模块哪些字段可空、哪些状态允许跳转、哪些接口会返回什么格式现在编译器、测试和 schema 替我记住了。这套思路值得你拿一个周末的项目试试一旦适应了这种“边写边有反馈”的节奏就会明白为什么大家都在往类型和测试上投入了。