我们是一支 12 人的前端与全栈工程团队,维护着一套用了七年、从 jQuery 时代一路堆叠到今天的庞大 JavaScript 业务代码。这套代码先天没有类型:一个函数到底收什么参数、返回什么结构,全靠你去翻调用处和实现、或者祈祷注释还没过期,改一个字段名编译器一声不吭、要等到线上某个页面白屏才知道闯了祸;代码里 any 和隐式 any 满天飞,类型检查形同虚设,IDE 的智能提示几乎瞎了;后端接口返回什么结构前端只能靠手写一份会过期的 interface 去"猜"、字段对不上就在运行时炸;数据进来从不校验,一个后端少返回的字段、一个 null 就能让整个渲染链路崩掉;模块还是 CommonJS 的 require、和现代 ESM 生态格格不入、tree-shaking 也做不了;构建用的是老掉牙的 webpack 配上 babel,改一行代码热更新要等十几秒、全量构建要好几分钟;枚举值全是散落各处的魔法字符串、拼错一个字母毫无察觉。这套代码能跑,但每一次重构都像在雷区里走路、每一次改字段都提心吊胆、每一个深夜的线上事故事后看都是"一个本该被类型系统拦住的低级错误"。
我们花了 87 天,把这套粗放的弱类型 JavaScript 代码,系统性地重构成了 2026 年的现代 TypeScript 工程体系。这不是简单地把 .js 后缀改成 .ts,而是一次从"运行时才暴露错误、靠人脑记忆数据结构、靠运气不出事"到"编译期就拦住错误、靠类型系统强制约束、靠机制结构性兜底"的开发范式跃迁。下面这张表,是我们这次 TypeScript 现代化战役里十个关键战场的"重构前 → 重构后"全景对比。
| 维度 | 重构前(粗放 JavaScript) | 重构后(现代 TypeScript) |
|---|---|---|
| 类型检查 | 弱类型无检查,改个字段名编译器不吭声,运行时才白屏 | 静态类型,编译期就把类型错误全标红拦住 |
| any 治理 | any 与隐式 any 满天飞,类型检查形同虚设 | strict 严格模式 + noImplicitAny,危险处用 unknown 收口 |
| 类型复用 | 手写重复 interface、到处手动类型守卫判断 | 泛型 + 类型推断 + 工具类型,一处定义处处复用 |
| 数据校验 | 外部数据从不校验,少个字段或 null 就崩 | zod 在边界对数据做 schema 校验 + 类型推断 |
| 前后端契约 | 手写一份会过期的 interface 猜后端返回结构 | tRPC / OpenAPI 生成,前后端类型端到端打通 |
| 模块系统 | CommonJS require,和现代生态格格不入无法 tree-shake | ESM import/export,标准模块化可摇树优化 |
| 构建工具 | webpack + babel,热更新十几秒全量构建几分钟 | Vite + esbuild/swc,热更新毫秒级构建秒级 |
| 枚举常量 | 魔法字符串散落各处,拼错一个字母毫无察觉 | 联合字面量类型 / const 对象,拼错编译期报错 |
| 空值处理 | undefined/null 到处裸奔,运行时 cannot read property | strictNullChecks + 可选链 + 空合并,编译期拦空 |
| 迁移方式 | —(从未类型化) | allowJs 渐进迁移,逐文件 .js→.ts 双轨并行 |
这套体系不是一蹴而就的,而是 12 个人在 87 天里、在一套天天还在迭代上线的业务代码上,一个文件一个文件地补类型、一个边界一个边界地加校验、一块一块地把构建迁到 Vite 上,啃下来的。最终我们沉淀了 47 套工程修法、7 个 P0 事故复盘和 6 条工程哲学。下面从十个战场逐一复盘。
一、静态类型:从弱类型运行时炸到编译期就拦住
第一仗,也是整场战役的地基,就是给代码装上类型这套"编译期的安全网"。古早时代我们写的是纯 JavaScript,一个函数到底接收什么形状的参数、返回什么结构的数据,语言层面完全不约束——你传个字符串它收、传个对象它也收、传个 undefined 进去它照样不报错地往下跑,直到某一行试图 .toFixed() 一个其实是字符串的值、或者 .map() 一个其实是 undefined 的东西,才在运行时轰然崩掉;最要命的是改字段名:把数据结构里的 userName 改成 username,所有用到旧名字的地方编译器一个字都不会提醒你,只能靠全文搜索加肉眼排查,总有那么几个漏网的角落,在线上某个不常走的分支里冷不丁地白屏。现代做法是引入 TypeScript:给每个函数的参数和返回值、每个数据结构都标注上类型,让类型检查器在编译期(其实是在你敲代码的当下,IDE 实时检查)就把所有类型不匹配的地方标红、把改字段后所有受影响的引用点一个不漏地揪出来。下面是静态类型的对比:
// 重构前:纯 JS,函数收什么返回什么全靠猜,改字段名编译器不吭声,运行时才炸
// function getDiscount(order) {
// return order.amount * order.user.vipLevel * 0.1; // amount 是字符串?user 是 undefined?
// } // 谁知道,跑到这行才 cannot read property
// getDiscount({ amount: "100" }); // 漏传 user,运行时白屏,编译期毫无察觉
// 重构后:TypeScript 标注类型,参数/返回结构编译期约束,改字段全引用点立刻标红
interface User { id: number; vipLevel: number; }
interface Order { amount: number; user: User; } // 数据结构显式建模
function getDiscount(order: Order): number { // 参数和返回类型都标注
return order.amount * order.user.vipLevel * 0.1; // amount 必是 number,user 必存在
}
// getDiscount({ amount: "100" }); // ✗ 编译期直接报错:缺 user、amount 类型错
getDiscount({ amount: 100, user: { id: 1, vipLevel: 3 } }); // ✓ 类型对上才能通过编译
静态类型让我们从"弱类型无任何检查、函数收什么返回什么全靠翻代码或祈祷注释、改个字段名编译器毫不知情、类型不匹配要等运行时才白屏崩溃"进化到了"显式标注类型、编译期就约束参数和返回结构、改字段所有受影响引用点立刻标红、类型不匹配编码当下就报错":过去我们写纯 JavaScript,语言对类型完全不设防,一个函数能接收任何东西、返回任何东西,调用方根本不知道该传什么、会拿到什么,只能靠去翻函数实现、去看其它调用处、或者去读一份可能早已过期的注释来"猜",而一旦猜错——少传个参数、传错个类型、访问了个不存在的属性——代码不会在出错的那一刻提醒你,而是继续不动声色地往下执行,直到某一行真的对一个 undefined 或类型不符的值做了操作才在运行时崩溃,且崩溃的位置往往离真正的错误源头十万八千里;尤其改字段名这种最常见的重构,旧名字的所有引用点编译器一个都不会帮你找,只能全文搜索加肉眼比对,总会漏掉几处藏在冷门分支里、最终在线上爆雷;现在我们用 TypeScript 给一切都标注了类型,函数的参数和返回值、每一个数据结构的形状都被显式地建模出来,类型检查器在我们敲代码的当下就实时地检查每一处类型是否匹配、把不匹配的地方红线标出,改一个字段名时所有用到它的地方会立刻报错、想漏都漏不掉。我们的纪律是"一切新代码用 TypeScript 编写、一切函数标注参数和返回类型、一切数据结构显式建模为 interface 或 type、严禁用注释代替类型"。静态类型的本质认知是:JavaScript 把"一个值是什么类型、一个函数要什么给什么"这些关键信息完全留在了程序员的脑子里和运行时,语言层面对此既不记录也不检查,这等于把海量本可以被机器自动发现的错误,全部推迟到运行时由用户用白屏和报错来替你发现;TypeScript 的智慧是把这些类型信息显式地编码进代码、让一个独立的类型检查器在编译期就对整个程序做一遍严格的类型推演和校验,把"类型用错"这一最庞大、最常见的错误门类,从"运行时崩溃"整体前移到了"编码时标红",这是用一次性的类型标注成本、换取持续的编译期安全保障的根本性工程升级。
二、严格模式:从 any 满天飞到 strict 与 unknown 收口
第二仗,是把类型检查的"严格度"拉满。光是把代码改成 .ts 后缀、加上类型还不够——如果到处用 any 这个"万能逃生舱口",类型系统就形同虚设。古早时代我们刚接触 TypeScript 时,遇到搞不定的类型就随手标个 any 了事,或者干脆开着宽松配置让编译器对没标类型的地方默默推断成隐式 any,结果就是大量代码顶着 TypeScript 的外壳、却完全没有类型保护,any 会像病毒一样传染——一个 any 的值赋给别的变量、传进别的函数,类型安全就在那条链路上彻底失守。现代做法是开启 strict 严格模式(noImplicitAny、strictNullChecks 等一整套严格选项),禁掉隐式 any、强制处理 null,并且约定:确实无法预知类型的外部数据(如 JSON.parse 的结果)一律用 unknown 而非 any 来接,unknown 强制你在使用前先做类型收窄检查,从而堵住 any 这个逃生舱口。下面是 strict 与 unknown 的对比:
// 重构前:any 满天飞,类型检查形同虚设,any 像病毒一样传染,该报的错全不报
// function handle(data: any) { // any:彻底放弃类型检查
// return data.user.name.toUpperCase(); // 链路上任何属性不存在都不报错,运行时才炸
// }
// const raw = JSON.parse(s); // 隐式 any,后续怎么用都不检查
// 重构后:strict 严格模式 + 用 unknown 接外部数据,强制收窄后才能用,堵死逃生舱口
function handle(data: unknown): string { // unknown:未知类型,使用前必须先收窄
// return data.user.name; // ✗ 编译期报错:unknown 不能直接访问属性
if (
typeof data === "object" && data !== null &&
"user" in data && typeof (data as { user: unknown }).user === "object"
) {
const d = data as { user: { name: string } }; // 收窄后才能安全断言
return d.user.name.toUpperCase();
}
throw new Error("invalid shape"); // 形状不对就显式报错,而非裸奔到运行时
}
严格模式让我们从"遇到搞不定的类型就随手标 any、开着宽松配置放任隐式 any、any 像病毒一样传染整条链路、类型检查名存实亡"进化到了"开启 strict 全套严格选项禁掉隐式 any 强制处理 null、外部未知数据一律用 unknown 接、unknown 强制先收窄类型再使用":过去我们虽然用了 TypeScript,却因为滥用 any 而几乎丢掉了它的全部价值——一遇到不好标注的复杂类型、一遇到外部来的数据,就图省事标个 any 把类型检查彻底关掉,或者干脆开着宽松的编译配置、让那些忘了标类型的参数和变量被默默当成隐式 any 处理,这样一来大量代码空有 TypeScript 的外壳、内里却毫无类型保护,而 any 最阴险的地方在于它会传染:一个 any 类型的值一旦赋给其它变量、传进其它函数、参与其它运算,它所污染的整条数据链路就都退化回了无检查的弱类型状态,该报的错一个都不会报;现在我们在 tsconfig 里开启了 strict 这一整套最严格的检查选项,noImplicitAny 逼着我们给所有东西都显式标注类型、再不允许隐式 any 蒙混过关,strictNullChecks 逼着我们认真处理每一处可能为 null 或 undefined 的情况,同时我们立下铁律:凡是类型确实无法预知的外部数据——比如 JSON.parse 的返回、第三方 SDK 给的回调参数——一律用 unknown 而不是 any 来接,因为 unknown 和 any 看似都表示"未知",行为却天差地别:unknown 类型的值你不做任何类型收窄检查就根本不能用它做任何操作、编译器会强制你先用 typeof、in、类型守卫把它收窄成一个确定的类型之后才放行,这就把 any 那个能让类型安全凭空蒸发的逃生舱口给彻底焊死了。我们的纪律是"tsconfig 必须开 strict、严禁裸用 any(确需逃生用带注释的 unknown 加显式收窄)、外部数据入口一律 unknown 接收并校验、any 出现必须 code review 拦截并说明理由"。严格模式的本质认知是:TypeScript 的类型安全不是一个开关、而是一个连续的光谱,而 any 是这个光谱上的一个黑洞——它不是"暂时没标类型",而是"主动声明这里放弃一切类型检查",并且会把这种放弃顺着数据流传染出去,一个团队如果对 any 不加节制,最终得到的就是一套"看起来有类型、实际上没保护"的最坏组合,既付出了标注成本又没换来安全;strict 模式和 unknown 的智慧,是从配置层面强制拉满检查严格度、从习惯层面用 unknown 这个"必须先证明类型才能使用"的安全替代品来对待一切未知数据,确保类型系统的防线是真实、连续、不可被随手绕过的,这是让 TypeScript 从"形式上的类型"变成"实质上的安全"的关键一仗。
这十个战场不是孤立的,它们彼此咬合、层层递进,共同构成了从弱类型 JS 到现代 TS 的完整跃迁。下面这张图,勾勒出我们这套 TypeScript 工程体系里数据从外部流入、经过校验、在类型系统保护下流转、最终被类型安全地消费的全景脉络:
三、泛型与类型推断:从手写重复 interface 到一处定义处处复用
第三仗,是消灭类型代码里的重复与僵化。给代码加类型之后,新的问题冒了出来:很多逻辑其实是通用的——一个"分页响应"无论装的是订单、用户还是商品,结构都是 { list: T[], total: number },一个"取数组第一个元素"的函数无论数组里是什么都该返回那个元素的类型——但如果不用泛型,我们就只能为订单写一个 OrderPageResp、为用户写一个 UserPageResp、为商品再写一个,几乎一模一样的结构抄了一遍又一遍,而且取首元素这种函数要么标成 any[] 丢掉类型、要么为每种数组重载一遍。同时我们还在到处手写类型守卫去判断一个值到底是什么类型。现代做法是用泛型(Generics)把类型参数化、用类型推断让编译器自动算出类型、用内置工具类型(Partial、Pick、Omit、Record 等)在已有类型上派生新类型,做到一处定义、处处复用。下面是泛型与类型推断的对比:
// 重构前:每种数据都抄一份几乎一样的分页结构,取首元素只能 any 丢类型
// interface OrderPageResp { list: Order[]; total: number; } // 抄一遍
// interface UserPageResp { list: User[]; total: number; } // 又抄一遍
// interface GoodsPageResp { list: Goods[]; total: number; } // 还抄一遍
// function first(arr: any[]): any { return arr[0]; } // any:返回值丢了类型
// 重构后:泛型把类型参数化,一处定义处处复用,类型推断自动算出结果类型
interface PageResp { list: T[]; total: number; } // 一个泛型搞定所有分页
function first(arr: T[]): T | undefined { return arr[0]; } // 泛型保留元素类型
const o = first([{ id: 1 }, { id: 2 }]); // o 被推断为 { id: number } | undefined
// 工具类型在已有类型上派生,无需重写
type OrderDraft = Partial; // 所有字段变可选(草稿态)
type OrderKey = Pick; // 只取 id 字段
type OrderNoUser = Omit; // 去掉 user 字段
const cache: Record = {}; // id→Order 的强类型字典
泛型与类型推断让我们从"通用结构为每种数据各抄一份几乎相同的 interface、通用函数要么标 any 丢类型要么逐类型重载、到处手写类型守卫"进化到了"泛型把类型参数化一处定义处处复用、类型推断让编译器自动算出结果类型、内置工具类型在已有类型上派生新类型":过去我们给代码加上类型之后很快发现类型代码里充斥着重复——一个分页响应的结构无非是一个数据数组加一个总数,可因为不用泛型,我们不得不为订单、用户、商品分别写出 OrderPageResp、UserPageResp、GoodsPageResp 这些除了数组元素类型不同、其余一模一样的接口,改个分页结构要同步改一大堆地方,而像"取数组首元素"这种与具体类型无关的通用函数,不用泛型就只能把参数标成 any[]、返回值也只能是 any、白白把调用方本来明确的元素类型给丢掉了;现在我们用泛型把"变化的那部分类型"提取成类型参数,一个 PageResp<T> 就覆盖了所有的分页响应、一个 first<T>(arr: T[]): T 就让取首元素的函数在任何数组上都精确保留元素类型,而且大多数时候我们根本不用手动写出 T 是什么、编译器会根据传入的实参自动推断出来,再配合 Partial、Pick、Omit、Record 这些内置的工具类型,我们能从一个已有的类型出发、派生出"所有字段可选的草稿类型""只挑几个字段的子类型""去掉某些字段的类型""某种键值映射的字典类型",全程不必重复书写。我们的纪律是"通用结构和函数一律用泛型参数化严禁为每种类型抄一份、优先依赖类型推断少写显式类型标注、派生类型一律用工具类型而非重写、泛型约束(extends)要用足以保证类型安全"。泛型的本质认知是:类型如果不能被抽象和复用,就会像没有函数的代码一样陷入大规模的复制粘贴,每一处重复都是一个未来改漏的隐患,而把每种数据的类型都写死又会让通用逻辑被迫丢弃类型信息退回 any;泛型的智慧是把"类型"本身也变成可以接收参数、可以被推断、可以被组合派生的一等公民,让我们能像复用函数一样复用类型逻辑、像编译器自动算值一样让它自动算类型,从而在不牺牲类型精确性的前提下彻底消除类型代码的重复与僵化,这是让类型系统既严格又不啰嗦、既安全又可维护的关键能力。
四、运行时校验:从外部数据裸进到 zod schema 边界把关
第四仗,是堵住类型系统的一个根本盲区——TypeScript 的类型只在编译期存在、运行时会被完全擦除,这意味着对于编译期无法看到的外部数据(后端接口返回、用户表单输入、本地存储读出、第三方回调),你写的 interface 只是一个"一厢情愿的承诺",运行时根本没人替你保证数据真的长那样。古早时代我们就是这么干的:写一份 interface ApiResp 然后把 fetch 回来的 JSON 直接 as ApiResp 强行断言成那个类型,从此 TypeScript 就真的以为数据是那个形状了——可后端某天少返回一个字段、把数字返回成了字符串、某个值给了 null,类型系统全然不知、IDE 也照样给你智能提示,直到运行时访问那个根本不存在的字段才崩溃。现代做法是引入 zod 这样的运行时校验库:在数据进入系统的边界处,用 zod 定义一份 schema、对实际收到的数据做一次真刀真枪的运行时校验,校验通过才放行、不通过就显式报错,而且 zod 能从 schema 自动推断出 TypeScript 类型,做到"校验逻辑和类型定义"single source of truth。下面是运行时校验的对比:
// 重构前:fetch 回来的 JSON 直接 as 断言,运行时没人保证,后端变结构就裸崩
// interface ApiUser { id: number; name: string; email: string; }
// const user = (await res.json()) as ApiUser; // as:骗编译器,运行时根本没校验
// user.email.toLowerCase(); // 后端没返 email?运行时 cannot read property
// 重构后:zod 在边界处真刀真枪运行时校验,通过才放行,类型从 schema 自动推断
import { z } from "zod";
const ApiUser = z.object({ // schema 既是校验规则也是类型来源
id: z.number(),
name: z.string(),
email: z.string().email(), // 连格式都校验
vipLevel: z.number().default(0), // 缺失给默认值
});
type ApiUser = z.infer; // 类型从 schema 自动推断,单一事实源
const json = await res.json(); // unknown,绝不直接信任
const user = ApiUser.parse(json); // 运行时校验:不符合就当场抛错
user.email.toLowerCase(); // 到这里 user 必定合法,放心用
运行时校验让我们从"外部数据直接 as 强断言成 interface、编译期一厢情愿运行时无人保证、后端少字段或类型变了类型系统全然不知、访问不存在的字段才在运行时崩"进化到了"在数据入口用 zod 定义 schema 做真实的运行时校验、通过才放行不通过就显式报错、类型从 schema 自动推断做到校验与类型单一事实源":过去我们严重忽视了 TypeScript 一个根本性的局限——它的类型完全是编译期的、打包后会被彻底擦除、运行时一行类型检查代码都不剩,所以对于那些编译期根本看不到内容的外部数据,我们写的 interface 不过是一句"我希望它长这样"的空头承诺,而我们偏偏还用 as 断言把 fetch 回来的 JSON 直接强行"盖章"成那个 interface、从此让 TypeScript 对它深信不疑、给出完整的智能提示和编译通过,可现实是后端接口随时可能调整——少返回一个字段、把本该是数字的值返回成字符串、给某个字段塞个 null——这些变化类型系统一概察觉不到,代码会带着这份虚假的类型自信一路裸奔,直到运行时真的去访问那个不存在或类型不符的字段才崩溃,而且因为大家都以为"有类型了"反而更不设防;现在我们在每一个外部数据进入系统的边界处都架起了 zod 这道运行时关卡,用 zod 的 API 声明出数据应有的 schema——每个字段是什么类型、是否必填、缺失时给什么默认值、字符串要不要符合邮箱等格式,然后对实际收到的数据调用 parse 做一次真实的运行时校验,数据完全符合 schema 才放行、有任何不符就当场抛出带详细信息的错误而非带病往下跑,更妙的是 zod 能用 z.infer 从 schema 直接推断出对应的 TypeScript 类型、让"运行时怎么校验"和"编译期是什么类型"出自同一份 schema 定义、彻底消除二者不一致的可能。我们的纪律是"一切外部数据(接口/表单/存储/回调)入口必须经 zod 校验严禁裸 as 断言、类型一律由 zod schema 用 infer 推断而非手写 interface、校验失败必须显式处理(报错或兜底)不得吞掉、schema 集中管理作为前后端共享契约"。运行时校验的本质认知是:TypeScript 的类型安全有一条清晰的边界——它能保证"系统内部按类型流转的数据"是安全的,但对"从系统外部流入的数据"无能为力,因为运行时类型已被擦除,用 as 断言去"假装"校验过外部数据,是在类型系统最脆弱的边界处自欺欺人地拆掉了唯一可能拦住坏数据的关卡;zod 的智慧是在编译期类型擦除之后、用真实存在于运行时的校验代码,在系统的每一个数据入口重建一道"既校验数据形状又同时产出类型"的关卡,让外部数据必须先证明自己确实符合约定的形状才被允许进入类型系统的保护范围,从而把 TypeScript 的安全边界从"系统内部"一直延伸到"与外部世界交互的最前线",这是让类型安全在真实世界的脏数据面前不破功的必要补全。
五、端到端类型安全:从手写过期 interface 到 tRPC 前后端类型打通
第五仗,是把前后端之间那道最容易腐烂的类型鸿沟彻底填平。古早时代前后端之间的接口契约全靠"口头加文档加手写":后端定义了一个接口、前端这边照着文档手写一份 interface 去描述它的入参和返回,这份手写的 interface 从写下的那一刻起就开始和真实的后端实现各自演化、慢慢腐烂——后端加了个字段前端的 interface 不知道、后端把某个字段改了名前端还在用旧名、后端把返回结构调整了前端浑然不觉,前后端类型对不上的问题永远要等到联调甚至上线才暴露,而且每次接口变动都要前后端两边手动同步修改、极易遗漏。现代做法(在前后端同为 TypeScript 的全栈场景下)是用 tRPC:后端用 TypeScript 定义出带类型的路由(procedure),前端直接 import 后端导出的路由类型、像调用本地函数一样调用远程接口,入参和返回值的类型由后端这一处定义自动流到前端、完全不需要手写也不需要代码生成,后端改了接口前端立刻在编译期红线报错。(若前后端异构,则用 OpenAPI/Protobuf 生成类型达到类似效果。)下面是端到端类型安全的对比:
// 重构前:前端照着文档手写一份 interface 猜后端,从写下就开始腐烂,对不上要联调才发现
// interface GetOrderResp { id: number; amount: number; } // 手写,后端一变就过期
// const res = await fetch(`/api/order/${id}`);
// const data = (await res.json()) as GetOrderResp; // 又是骗编译器的 as 断言
// 重构后:tRPC 后端定义带类型路由,前端 import 路由类型,类型从后端一处自动流到前端
// ---- 后端:定义带类型的 procedure ----
export const appRouter = router({
getOrder: publicProcedure
.input(z.object({ id: z.number() })) // 入参 schema(也是类型)
.query(({ input }) => orderService.get(input.id)), // 返回类型自动推断
});
export type AppRouter = typeof appRouter; // 仅导出"类型",不带实现
// ---- 前端:import 类型,像调本地函数一样调远程,全程类型安全 ----
import type { AppRouter } from "../server/router";
const trpc = createTRPCClient({ /* ... */ });
const order = await trpc.getOrder.query({ id: 1 }); // 入参/返回类型来自后端,改接口前端立刻报错
端到端类型安全让我们从"前端照后端文档手写 interface 去猜接口、这份 interface 从写下就开始腐烂、后端加改字段前端浑然不觉、前后端类型对不上要联调上线才暴露、每次变动两边手动同步极易遗漏"进化到了"tRPC 后端定义带类型路由前端直接 import 路由类型、入参返回类型从后端一处定义自动流到前端无需手写无需生成、后端一改接口前端编译期立刻红线报错":过去前后端之间的契约维系在一种极其脆弱的方式上——后端实现了接口、写一份文档,前端开发者照着文档在自己这边手敲一份 interface 来描述这个接口的请求参数和响应结构,可这份手写的类型定义和后端的真实实现是两套各自独立演化的东西,从敲下的第一秒起就开始分道扬镳:后端悄悄加了个字段,前端的 interface 还是老样子;后端把一个字段改了名或换了类型,前端还在按旧定义访问;后端调整了嵌套结构,前端的类型早已名不副实,而这一切类型系统都无从知晓,因为前端的类型是"自己编的"、和后端没有任何真实连接,结果前后端类型不一致的 bug 永远潜伏到联调阶段、甚至直接溜到线上才以各种诡异的 undefined 形式炸出来,且每次接口一变就得前后端两头人工同步、漏改一处就是一个隐患;现在我们在前后端同为 TypeScript 的全栈项目里用上了 tRPC,后端用 TypeScript 把每个接口定义成带类型的 procedure、入参用 zod schema 约束、返回值类型由实现自动推断,然后仅仅把这个路由的"类型"(typeof appRouter)导出,前端这边直接 import 这个纯类型、用一个类型化的客户端像调用本地函数一样去调用远程接口,请求参数该传什么、响应回来是什么结构,全部由后端那唯一的一处定义自动地、实时地流动到前端,完全不需要前端手写任何 interface、也不需要任何代码生成步骤,而一旦后端改动了接口的入参或返回,前端所有调用处会在编译期立刻亮起红线、想漏都漏不掉;在前后端语言不一致的异构场景里,我们则用 OpenAPI 或 Protobuf 作为契约、通过代码生成在前端产出类型,达到类似的端到端效果。我们的纪律是"全栈 TS 项目优先 tRPC 让类型从后端自动流到前端、异构项目用 OpenAPI/Protobuf 生成类型严禁前端手写接口 interface、接口契约以后端定义或 schema 为唯一事实源、契约变更靠编译期报错驱动前端同步而非靠人工通知"。端到端类型安全的本质认知是:前后端之间的类型契约之所以总是腐烂,根源在于双方各自维护着一份对同一接口的独立描述、而这两份描述之间没有任何机制保证它们始终一致——任何需要人工同步两处来保持一致的东西,最终都必然会因为某次遗漏而不一致;tRPC(以及契约代码生成)的智慧是消灭"两份描述"这件事本身、让接口的类型只有唯一一处定义(后端的路由或共享的 schema),前端的类型不是另写一份、而是从这唯一的源头自动派生流出,这样前后端的类型在物理上就是同一个东西、根本不存在"对不上"的可能,接口的任何变动都会顺着类型连接立刻传导到所有调用方并触发编译期报错,把"前后端契约不一致"这一全栈开发里最顽固、最高频的错误门类从机制上连根拔除,这是类型安全从单个项目内部扩展到跨端协作全链路的最后一块拼图。
六、模块系统:从 CommonJS require 到标准 ESM 可摇树
第六仗,是把代码的组织方式从老旧的 CommonJS 迁移到标准的 ESM(ECMAScript Modules)。古早时代我们的代码全是 CommonJS——用 require 引入、用 module.exports 导出,这是 Node 早年的模块方案,可它有几个先天的局限:它是运行时同步加载的,模块之间的依赖关系要到代码真正执行 require 那一行时才确定,打包工具无法在编译期静态地分析出"哪些导出真正被用到了",于是没法做 tree-shaking(摇树优化)——哪怕你只用了一个工具库里的一个函数,整个库都会被打进最终的包里,白白增大体积;而且 CommonJS 和如今几乎整个前端生态都在标准化采用的 ESM 格格不入,越来越多的现代库只发 ESM 格式,我们的 CommonJS 代码引入它们时常常磕磕绊绊、要靠各种 interop 兼容层来回折腾。现代做法是全面转向 ESM:用 import/export 这一 JavaScript 语言标准的模块语法,它是静态可分析的——模块的导入导出关系在编译期就完全确定,打包工具因此能精确地知道每个导出有没有被用到、把没用到的死代码摇掉,同时 ESM 是整个现代生态的统一标准、和所有现代库、和浏览器原生模块、和 Vite 这样基于 ESM 的新一代构建工具天然契合。模块系统让我们从"CommonJS 的 require/module.exports、运行时同步加载、依赖关系要执行到才确定、打包工具无法静态分析所以做不了 tree-shaking 用一个函数也打进整个库、和现代 ESM 生态格格不入要靠 interop 兼容层"进化到了"标准 ESM 的 import/export、静态可分析、编译期就确定导入导出关系、打包工具能精确摇掉未用到的死代码、和现代库浏览器及 Vite 等新构建工具天然契合":过去我们的整个代码库都建立在 CommonJS 这套 Node 早期的模块机制上,模块用 require 函数同步地引入、用给 module.exports 赋值的方式导出,这种方式的根本问题在于它是动态的、运行时的——你完全可以在一个 if 分支里、在一个函数内部去 require 一个模块,模块间真正的依赖关系要等到代码执行到那一行才揭晓,这就让打包工具在编译期根本无法静态地、完整地分析出整个依赖图和每个导出的实际使用情况,自然也就无法安全地把那些导出了却从没被任何地方用到的死代码摇掉,后果就是我们哪怕只从一个庞大的工具库里用了区区一个函数、最终的打包产物里也塞进了那个库的全部代码,包体积被白白撑大、首屏加载被白白拖慢,更别提随着整个前端生态都在向 ESM 标准靠拢、越来越多的现代库干脆只发布 ESM 格式,我们这套 CommonJS 代码去消费它们时总是状况不断、要靠一层层的互操作兼容垫片来回适配、踩不完的坑;现在我们全面转向了 ESM,用 import 和 export 这一写进了 JavaScript 语言标准的模块语法,它最关键的特性是静态可分析——所有的 import 和 export 都必须写在模块顶层、模块之间的依赖关系在代码还没运行的编译期就被完全确定下来,打包工具因此能够精确地构建出完整的依赖图、清楚地判断出每一个导出到底有没有被用到、然后放心地把所有没被用到的死代码从最终产物里摇掉,包体积大幅瘦身,而且 ESM 是当下整个现代前端生态共同遵循的统一标准,和所有现代库、和浏览器的原生模块加载、和 Vite 这种本身就构建在 ESM 之上的新一代工具都天然契合、再没有那些恼人的兼容层折腾。我们的纪律是"一切代码用 ESM 的 import/export 严禁新写 CommonJS、package.json 标注 type module、导入路径规范化、避免动态 require 破坏静态分析、依赖优先选提供 ESM 格式的版本"。模块系统的本质认知是:模块化方案的差异表面上只是导入导出的语法不同,深层却决定了"工具能否在编译期看清整个程序的结构"这一根本能力——CommonJS 的动态运行时加载把依赖关系藏在了执行流里、对编译期的工具是不透明的,所以它从机制上就堵死了 tree-shaking 这类需要静态分析才能做的优化;ESM 的智慧是把模块的依赖关系约束成静态的、声明式的、编译期完全可知的,从而为打包工具打开了精确分析和深度优化的大门、也让整个生态有了一个可以共同依赖的统一标准,这是现代前端能够做到按需打包、极致瘦身、生态互通的底层基石。
七、构建工具:从 webpack 慢如蜗牛到 Vite 毫秒级热更新
第七仗,是把开发和构建的体验从难以忍受的缓慢中解救出来。古早时代我们用的是 webpack 配上 babel 的经典组合,它功能强大、生态完整,但有一个随着项目变大越来越致命的问题——慢:webpack 在启动开发服务器时要先把整个应用的依赖图全部打包一遍才能让你开始开发,项目大了之后冷启动动辄一两分钟,改一行代码触发的热更新也要等十几秒才能在浏览器里看到效果,这十几秒里思路被打断、心态被磨损,一天下来光是等构建就耗掉大把时间;全量生产构建更是要好几分钟、CI 流水线被严重拖慢。这种缓慢的根源在于 webpack 是基于打包(bundle)的——它必须先把所有模块打包成 bundle 才能提供服务,而打包本身是用 JavaScript 写的、又要处理整个依赖图,自然快不起来。现代做法是换用 Vite:开发期它利用浏览器原生支持 ESM 的能力、不预先打包整个应用、而是按浏览器实际请求按需地、即时地编译单个模块,所以冷启动几乎是瞬时的、热更新也是毫秒级的;生产构建则用 Rollup,而 TypeScript 和 JSX 的转译交给用 Go 写的 esbuild 或用 Rust 写的 swc 这类原生语言实现的超高速工具,比 babel 快一两个数量级。构建工具让我们从"webpack 加 babel、基于打包必须先把整个应用打成 bundle 才能开发、项目大了冷启动一两分钟、改一行热更新等十几秒、全量构建好几分钟拖慢 CI"进化到了"Vite 开发期利用浏览器原生 ESM 按需即时编译单模块、冷启动近乎瞬时、热更新毫秒级、转译交给 Go 写的 esbuild 或 Rust 写的 swc 比 babel 快一两个数量级":过去我们的开发体验被 webpack 加 babel 这套组合的缓慢严重拖累,webpack 的工作模式是基于打包的——它在启动开发服务器之前,必须先把整个应用从入口开始、顺着依赖图把所有模块统统打包成一个个 bundle,这个过程在项目还小的时候尚可忍受,可一旦项目膨胀到一定规模,冷启动一次开发服务器就要干等一两分钟、改动一行代码触发的热更新也要等上十几秒钟才能在浏览器里看到结果,而开发是一个高度依赖即时反馈的活动、这十几秒的等待足以打断思路、积累起来更是每天白白蒸发掉大量时间,全量的生产构建则要耗费好几分钟、把 CI 流水线拖得又臭又长,这一切缓慢的病根就在于 webpack"必须先打包完才能用"的范式、加上 babel 这个用 JavaScript 写的转译器在处理海量文件时的力不从心;现在我们换上了 Vite,它的开发期范式完全不同——它充分利用现代浏览器已经原生支持 ESM 这一事实、根本不预先打包整个应用,而是直接把源码以原生模块的形式交给浏览器、由浏览器按页面实际需要去请求一个个模块、Vite 再针对被请求到的那个模块即时地、单独地做一次编译返回,于是冷启动几乎是瞬间完成的(因为不用预打包)、改动一个文件的热更新也快到以毫秒计(因为只需重新编译那一个变动的模块),而生产构建则交给成熟的 Rollup 来做、TypeScript 和 JSX 这些需要转译的工作则交给 esbuild(用 Go 写的)或 swc(用 Rust 写的)这类用系统级语言实现、充分利用多核的超高速转译器,速度比 babel 快上一两个数量级。我们的纪律是"开发与构建一律 Vite、转译用 esbuild/swc 不再用 babel、类型检查与转译解耦(转译只剥离类型、类型检查交给 tsc 或编辑器单独跑)、依赖预构建用足、构建产物体积纳入 CI 监控"。构建工具的本质认知是:开发反馈的速度直接决定了开发者的心流和产出效率,而构建慢的根源往往在于范式与实现语言两层——webpack"先打包全部再服务"的范式让启动成本随项目规模线性膨胀、babel 用 JavaScript 做 CPU 密集的转译又先天受限于单线程和语言性能;Vite 的智慧是从范式上抛弃"先全量打包"、转而拥抱浏览器原生 ESM 做到按需即时编译让启动和热更新与项目规模几乎解耦,同时从实现上把转译这种 CPU 密集的重活交给 Go、Rust 这类原生语言写的工具去压榨硬件性能,两者叠加把开发体验从"泡杯咖啡等构建"拉回到了"所见即所得"的即时反馈,这是现代前端工程在工具链层面对开发者生产力的一次彻底解放。
八、渐进迁移:从断崖重写到 allowJs 逐文件双轨并行
第八仗,是怎么把这样一套跑了七年、几十万行、天天还在上线的纯 JavaScript 代码,实际地迁移成 TypeScript——这一仗的策略选择,直接决定了整场战役是稳稳落地还是半途崩盘。把整个项目停下来、一次性全部重写成 TypeScript 再某天切换,是最省心想象却最不切实际的做法:工程量大到周期遥遥无期、重写期间业务还在天天加需求、新写的永远追不上在动的、最后极可能演变成一场看不到头又切不过去的豪赌。现代做法是渐进迁移:TypeScript 编译器本身就支持 allowJs 选项、允许 .ts 和 .js 文件在同一个项目里混编共存、彼此自由引用,这就让我们可以让新旧代码双轨并行——新文件一律用 TypeScript 写,老的 .js 文件则一个一个地、按优先级(先迁最核心最易错的模块)逐步转成 .ts 并补齐类型,转一个、类型检查通过一个、上线验证一个,整个过程平滑无中断,期间还可以先用 JSDoc 注释给暂时来不及转的 .js 文件加上一部分类型信息作为过渡。渐进迁移的智慧在于把"几十万行 JS 迁成 TS"这件高风险的大事,从"整个项目停下来一次性全部重写再某天断崖式切换"的豪赌,变成了"用 allowJs 让 ts 和 js 同项目混编共存双轨并行 + 新文件一律 TS + 老文件按优先级逐个转换逐个验证 + JSDoc 过渡"的渐进迁移":断崖式重写一套跑了七年、承载着全部业务、还在每天迭代上线的几十万行代码,几乎注定是灾难——工程量大到周期看不到头、重写期间业务需求还在源源不断地涌入老代码、新代码永远在追一个移动的靶子、好不容易快写完了又发现和老系统行为对不齐、约定好的切换日一到处处暴雷只能连夜回滚,这是无数大型前端重写项目折戟的同一个剧本;我们走的是渐进迁移的稳健路子,关键就在于 TypeScript 编译器的 allowJs 选项——它允许 .ts 文件和 .js 文件在同一个项目里和平共处、相互引用,TypeScript 文件能 import JavaScript 文件、反之亦然,这就意味着我们完全不需要一次性地、原子地把所有文件都变成 TS,而是可以让两种文件长期双轨并行地跑在同一套代码库里:从此刻起所有新增的文件一律用 TypeScript 编写、存量的 .js 文件则按照一个理性的优先级队列(优先迁移那些最核心、改动最频繁、出过事最多、类型最容易出错的模块)一个一个地转换成 .ts 并补齐类型标注,每转换一个文件就让它通过类型检查、跟着正常的发布节奏上线验证,整个迁移过程被拆成了成百上千个独立的、小而可控、随时可暂停可回退的小步子、对线上业务零中断,对于那些一时还顾不上正式转换的 .js 文件,我们还可以先用 JSDoc 注释的形式给它的函数参数和返回值标上类型、让 TypeScript 也能据此提供一部分类型检查作为平滑过渡。我们的纪律是"严禁断崖式整体重写、开 allowJs 让新旧文件混编共存、新文件强制 TS、老文件按核心度和出错率排优先级逐个迁移、每个文件迁移后类型检查通过并上线验证、暂不能转的用 JSDoc 过渡、迁移进度可量化追踪"。渐进迁移的本质认知是:大型遗留系统的技术栈升级,真正的风险从来不在"新栈好不好",而在"如何在业务一刻不停的前提下安全地把庞大的存量代码搬过去"——断崖式重写之所以屡屡失败,是因为它要求你让整个团队停下来、去追赶一个永远在移动的庞然大物、并把全部赌注押在某个切换的瞬间;渐进迁移的智慧是借助 allowJs 这种"新旧共存"的机制把一次不可逆的大豪赌,拆解成一连串小而独立、可验证可回退的小迁移,让每一步的风险都被限制在单个文件的范围内、让迁移的价值随每个文件的转换而持续兑现、让整个庞大的工程在业务不停摆的情况下被温水煮青蛙般地平滑搬迁完成,这是大型 JS 项目 TS 化唯一靠谱的活法。
九、7 个 P0 事故复盘
7 事故:(1) 一处外部接口数据未经 zod 校验直接 as 断言使用,后端某次发版少返了一个字段导致核心列表页全量白屏,全面排查在所有数据入口补 zod 校验、禁绝裸 as;(2) 一个隐式 any 顺着数据流传染到金额计算把字符串当数字相乘算出 NaN 显示给用户,开启 strict 与 noImplicitAny 全量整改;(3) strictNullChecks 未开时一处 undefined 解引用把整个结算流程打挂,全项目开启 strictNullChecks 加可选链兜底;(4) 枚举用魔法字符串、一处把 "paid" 拼成 "payed" 导致订单状态判断恒假,改用联合字面量类型让拼错编译期即报;(5) CommonJS 与 ESM 混用 interop 出错导致一个第三方库在生产环境加载为 undefined 调用即崩,统一迁 ESM 理顺模块格式;(6) 一处把后端可能为 null 的字段在类型上标成了非空、运行时拿到 null 崩溃,修正类型如实标注可空并强制处理;(7) 迁移中一个 .js 文件转 .ts 时类型标注与实际运行行为不符引入回归,补充迁移后必跑的类型检查加单测加灰度。每个 P0 都做 5-Why 复盘,固化成边界校验门禁、strict 配置基线或类型标注规范,确保同类问题不再复发。
十、TypeScript 工程师的 6 条工程哲学
6 哲学:(1) 让错误在编译期爆炸而非运行时——类型的全部价值就是把 bug 从用户的白屏前移到你的编辑器红线;(2) any 是债不是解——每一个 any 都是一处主动放弃的检查、会传染会爆雷,能 unknown 就别 any;(3) 类型边界即信任边界——系统内部信任类型、系统外部一律 zod 校验,擦除后的类型保护不了外部数据;(4) 单一事实源——类型从 schema 推断、契约从后端流出,任何需要人工同步两处的东西最终都会不一致;(5) 类型要复用不要复制——泛型和工具类型让一处定义处处通用,重复的类型和重复的代码一样是隐患;(6) 迁移要渐进不要重写——allowJs 双轨并行逐文件转,断崖式重写大型项目几乎必死。这 6 条哲学,是我们用 7 个 P0 事故和 87 天攻坚换来的集体共识。它们共同指向一个认知:TypeScript 现代化的价值不在于"加了多少类型标注"这个动作本身,而在于把"前端应用的健壮、可维护、可重构"从依赖程序员的记忆力和运气、前移成了由类型系统和编译器结构性保障——会用 TypeScript 的团队,是在用类型系统的机制把一整类"类型用错、空指针、数据形状不符、契约不一致、拼错常量"的问题从源头上消除在编码阶段,而不是在线上事故里一次次救火。
十一、重构收益的量化:7 个关键数字
7 数字:(1) 线上类型类事故:字段对不上空指针频繁白屏 → 类型系统加 zod 校验后这类事故大幅归零;(2) 重构信心:改字段名要全文搜索肉眼排查提心吊胆 → 改字段所有引用点编译期标红可放心大重构;(3) IDE 智能提示:几乎瞎了全靠记忆 → 类型完备后自动补全和跳转覆盖全代码库;(4) 外部数据健壮性:接口一变就裸崩 → zod 边界校验后坏数据被挡在门外;(5) 前后端联调:类型对不上联调上线才发现 → tRPC 端到端打通后契约不一致编译期即报;(6) 热更新速度:webpack 改一行等十几秒 → Vite 毫秒级热更新近乎瞬时;(7) 包体积:用一个函数打进整个库 → ESM 加 tree-shaking 后产物大幅瘦身。这些数字背后,是 87 天里 12 个人一个文件一个文件地补类型、一个边界一个边界地加校验、一块一块地迁构建,但每一个都实打实地转化成了稳定性、可维护性、开发体验和性能的提升。当我们把这份数据汇报给管理层时,最有说服力的不是任何 TypeScript 名词,而是"线上白屏类事故大幅减少、大型重构敢做了也快了、新人上手代码库靠类型提示就能读懂"这三条。
十二、留给后来者的最后一句话
87 天的 TypeScript 现代化战役,我们走过的不只是一条从弱类型 JS 到静态类型、从 any 满天飞到 strict 收口、从手写重复 interface 到泛型复用、从外部数据裸进到 zod 把关、从手写过期契约到 tRPC 端到端、从 CommonJS 到 ESM、从 webpack 到 Vite、从断崖重写到渐进迁移的技术升级路,更是一次从"靠程序员记得住数据结构、靠运气不出事"到"靠类型系统强制约束、靠编译期检查结构性兜底"的开发范式跃迁。当改一个核心字段名所有受影响的地方瞬间在编辑器里标红让大重构第一次变得敢做、当每一处外部数据都经 zod 校验后坏数据再也进不了系统、当后端改个接口前端调用处立刻编译报错让前后端联调的扯皮成为历史、当 Vite 把热更新从十几秒压到毫秒让开发重新进入心流、当一个新人靠着完备的类型提示就能读懂一块七年前的老代码的那一刻,真正点燃我们的,不是加了多少类型标注本身,而是"前端应用的健壮、可维护和可重构,终于从依赖人的记忆力和手感的运气,变成了由类型系统和编译器强制保障"的踏实与笃定。TypeScript 现代化没有银弹,关键是理解静态类型、strict、泛型、zod、tRPC、ESM、Vite 各自解决什么问题、又各自带来什么代价,然后从开启 strict 和给核心模块补类型的地基起步、用 allowJs 加逐文件迁移可回退地落地——尤其要克制"图省事标个 any、图省事 as 断言一下外部数据、图省事手写一份接口 interface、图省事不开 strict、图省事断崖式重写"的旧习惯,因为每一个随手的 any、每一次裸 as 断言、每一份手写的契约、每一个被关掉的严格选项,都是在亲手埋下未来某次白屏、空指针或前后端对不上账的事故。愿每一位还在和弱类型、空指针、数据形状错乱、前后端契约腐烂搏斗的同行,都能早日让自己的前端代码被类型系统和编译器稳稳地守护。共勉,后会有期。
—— 别看了 · 2026