某电商Node项目转型TypeScript实战 编译错误如何逼你写出更安全的代码
我最近接手了一个挺有意思的项目——帮一家做生鲜电商的团队,把他们跑了两年多的Node.js后端从纯JavaScript迁移到TypeScript。这个项目挺有代表性,日活峰值几十万,订单、库存、支付、物流好几个核心模块,代码量大概在15万行左右。说实话,刚开始我也没想明白,明明JS跑得好好的,为什么要折腾?但真正干完这一趟回来,我的认知彻底变了。
为什么非要转型,JS到底出了什么问题
先说背景。这家公司的业务线比较杂,早期两个前端组分别用JS写订单服务和库存服务,后来业务合并到一起,慢慢就变成了一锅粥。
我翻了一下代码,有几个典型问题:
第一个问题是”类型黑洞”。
订单模块有个函数叫 calculatePrice,接收三个参数,返回最终价格。代码长这样:
// 这是一个真实的"祖传代码"
function calculatePrice(items, userId, couponCode) {
let total = 0;
items.forEach(function(item) {
total += item.price * item.quantity;
});
if (couponCode) {
// 这里 couponCode 可能是 null、undefined、空字符串、数字、对象...
total = total * couponCode.discount;
}
// 如果 userId 是字符串而不是数字,后面的逻辑全乱了
if (userId < 1000) {
total = total * 0.9; // 新用户优惠
}
return total;
}
你看这个代码,运行时它可能跑得好好的,但如果某个调用方传了个奇怪的值进去——比如 couponCode 是个数字 5,而不是一个带 discount 属性的对象——那这个 total 就变成了 NaN,然后整个订单金额就变成了 NaN,这笔订单就废了。
这种 bug 在开发阶段看不出来,只有在真实流量打到生产环境的时候,客服才会开始接电话。
第二个问题是文档全靠嘴。
他们的 API 接口文档是 Excel 表格维护的,字段描述是中文写的,但代码里的变量名是拼音缩写——djsj、shdj、zfbf。你猜怎么着?有段时间他们连自己团队内部的人都搞不清楚 djsj 到底是”当前时间”还是”订单时间”。
第三个问题是测试覆盖率极低。
核心订单逻辑的测试覆盖率大概30%左右,而且很多测试是 mock 掉了实际逻辑的”空跑测试”。这就意味着大部分代码从来没被真正验证过。
这些问题单独看都不致命,但叠加在一起,每次发版都像在赌博。
转型计划:怎么动刀才不翻车
我们定了几个原则:
- 不是重写,是渐进式迁移——先把核心模块迁移,外围服务保持 JS,两边互相调用
- 类型定义先行——先定义好接口和数据类型,再改实现
- 编译错误是朋友,不是敌人——每一个报错都是系统在帮你找出一个潜在 bug
选型方面,用的是 TypeScript 5.x,ES2022 目标,配合 tsup 做打包。配置上开启了最严格的检查:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"noEmitOnError": true
}
}
特别是 noUncheckedIndexedAccess 和 exactOptionalPropertyTypes 这两个选项,后面会让你疼一段时间,但绝对值得。
第一个硬仗:订单服务迁移
我们从订单服务开始,因为这是最核心、问题最多的模块。
错误1:undefined 不是数字,但你的代码在处理它
迁移第一个函数时,TypeScript 直接报错了:
Type 'number | undefined' is not assignable to type 'number'
对应的代码是这样的:
// 原来的 JS 代码,用户可能没有价格字段
interface OrderItem {
productId: string;
quantity: number;
price?: number; // 可选字段
}
function calculatePrice(items: OrderItem[]): number {
let total = 0;
items.forEach(item => {
total += item.price * item.quantity; // 💥 报错!price 可能是 undefined
});
return total;
}
表面上看,这个报错很”烦人”——明明业务上价格字段应该总是有的。但你想想,如果某个历史数据或者上游服务传过来的订单,price 字段缺失了,那 undefined * 5 就是 NaN,整个订单就坏了。
我们的修复方式是:
function calculatePrice(items: OrderItem[]): number {
let total = 0;
items.forEach(item => {
// 明确处理 price 不存在的情况
if (item.price === undefined) {
throw new Error(`商品 ${item.productId} 缺少价格字段`);
}
total += item.price * item.quantity;
});
return total;
}
你看,编译器不是在为难你,它是在帮你发现:这段代码有一个隐式的假设——”price 一定存在”。而现实中,这个假设被打破过,只是你没发现而已。
错误2:可选属性写进去了,但 API 文档没标记
interface Coupon {
code: string;
discount: number;
expiredAt?: Date; // 这个字段是可选的
}
// 调用方
function applyCoupon(order: Order, coupon: Coupon): Order {
if (coupon.expiredAt) {
if (coupon.expiredAt < new Date()) {
throw new Error('优惠券已过期');
}
}
// ...
}
开启 exactOptionalPropertyTypes: true 之后,如果你把 expiredAt 设为 undefined,TypeScript 会报错——因为它认为你要么有这个字段,要么没有,不能显式赋值为 undefined。
这个规则看起来很较真,但它帮你避免了一个真实的问题:有些调用方会这样写:
const coupon: Coupon = {
code: 'SUMMER2024',
discount: 0.8,
expiredAt: undefined // 这不是"没有过期时间",这是"忘记设置了"
};
这两者的语义完全不同,但 JS 里它们看起来一样。TypeScript 强制你区分这两种情况。
错误3:数组下标访问的隐形炸弹
function getTopSeller(products: Product[]): Product {
// 按销量排序后返回第一个
const sorted = products.sort((a, b) => b.sales - a.sales);
return sorted[0]; // 💥 报错!sorted[0] 可能是 undefined
}
开启 noUncheckedIndexedAccess 之后,所有数组下标访问都会返回 T | undefined。这意味着你得显式处理”数组为空”的情况:
function getTopSeller(products: Product[]): Product | null {
if (products.length === 0) return null;
const sorted = [...products].sort((a, b) => b.sales - a.sales);
return sorted[0]!; // 这里用非空断言,因为我们已经检查过 length
}
或者更优雅地:
function getTopSeller(products: Product[]): Product | null {
if (products.length === 0) return null;
return products.reduce((best, current) =>
current.sales > best.sales ? current : best
, products[0]);
}
这个错误特别有意思——你的代码可能在99%的情况下是正常的,但那1%的边界情况(空数组)在生产环境里可能触发过,只是没人注意到。
编译错误教会我的三件事
1. 那些”我以为不会发生”的事情,真的会发生
迁移过程中,我们统计了所有编译错误,发现大约60%的错误来自”隐式假设”——开发者在心里假设某个值一定存在、一定非空、一定符合某个类型,但代码里没有任何地方验证过这个假设。
这些假设在测试环境里不会暴露,因为测试数据是精心构造的。只有在生产环境,面对真实用户传来的各种脏数据时,才会炸。
2. 类型系统是写给自己看的文档
迁移完订单服务之后,我们回头看了一眼代码,发现一个有趣的现象:新写的 TypeScript 代码,比原来的 JS 代码好读了很多。
因为当你定义了一个类型:
interface Order {
orderId: string;
userId: number;
status: 'pending' | 'paid' | 'shipped' | 'cancelled' | 'refunded';
items: OrderItem[];
totalAmount: number;
paidAt?: Date;
shippedAt?: Date;
refundedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
任何接手这个代码的人,一眼就能看出订单有哪些状态、哪些时间字段、哪些是必填的。而不需要去翻 API 文档——因为文档可能已经过时了,但类型不会。
3. 编译错误在”逼”你思考边界条件
有一次迁移支付模块时,TypeScript 报了一个错误:
interface PaymentResult {
success: boolean;
transactionId?: string;
errorMessage?: string;
}
function handlePayment(result: PaymentResult): void {
if (result.success) {
console.log('支付成功,交易号:', result.transactionId);
// 💥 报错!transactionId 可能是 undefined
}
}
这个错误逼着我问了一个问题:支付成功了,但交易号为空,这种情况可能吗?
调查之后发现,确实存在一种情况——第三方支付平台返回成功状态,但因为网络问题没有带回交易号。我们的原始代码直接就把 undefined 写进了日志,然后数据库里存了一条”成功但无交易号”的异常记录,导致后续的对账逻辑完全乱了。
如果当初在开发阶段就注意到这个问题,早就修复了,而不是等到财务对账发现差异才排查。
迁移过程中的痛点和应对
说实话,迁移过程并不轻松。有几个阶段特别痛苦:
第一阶段:满屏红色波浪线。
项目一开始跑 tsc --noEmit,报错数量超过2000条。很多老代码用了各种隐式 any,类型完全丢失。我们的策略是先关掉严格检查,让项目能编译通过,再逐步开启严格检查。
// 先用宽松的,再收紧
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"strictNullChecks": false
}
}
然后每隔几天就开启一个严格选项,逐步收紧。这样错误数量可控,团队也容易接受。
第二阶段:第三方库没有类型定义。
项目用了一些比较小众的内部 SDK,没有提供 TypeScript 类型定义。我们有两个选择:要么自己写 .d.ts 文件,要么用 @ts-ignore 暂时绕过去。
我们选择自己写类型定义,因为这其实是一个梳理接口的好机会。写类型定义的过程中,我们发现很多 SDK 的接口设计本身就有问题——比如某个方法既接受字符串又接受数字,但实际语义上应该是枚举。
第三阶段:业务逻辑和类型定义的博弈。
有些业务逻辑很复杂,用 TypeScript 表达出来会很繁琐。比如订单的状态机,有十几种状态和几十种状态转移,如果用 switch 语句写,代码又臭又长。
我们最后用了一种基于类型的状态机模式:
// 定义状态类型
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled' | 'refunded';
// 定义合法的状态转移
type TransitionMap = {
'pending': ['paid', 'cancelled'];
'paid': ['shipped', 'refunded'];
'shipped': ['refunded'];
'cancelled': [];
'refunded': [];
};
// 类型安全的状态转移函数
function transitionOrder(
order: Order,
nextStatus: TransitionMap[Order['status']] extends infer T ? T : never
): Order {
const allowedTransitions = {
'pending': ['paid', 'cancelled'],
'paid': ['shipped', 'refunded'],
'shipped': ['refunded'],
'cancelled': [],
'refunded': []
};
const current = order.status;
const allowed = allowedTransitions[current];
if (!allowed.includes(nextStatus)) {
throw new Error(`订单状态 ${current} 不能转移到 ${nextStatus}`);
}
return { ...order, status: nextStatus, updatedAt: new Date() };
}
这样写的好处是,如果有人尝试做一个非法的状态转移,TypeScript 会在编译期直接报错,而不是在运行时才出问题。
转型后的实际收益
迁移完成后,我们做了一些量化对比:
Bug 数量下降。 上线一个月后,订单模块的线上 bug 数量比迁移前下降了约70%。最多的三类 bug 分别是:金额计算错误(从12个降到1个)、用户信息异常(从8个降到0个)、状态机非法转移(从5个降到0个)。
代码可读性提升。 新入职的工程师反馈,看 TypeScript 代码比看原来的 JS 代码快了很多——因为类型本身就是文档,不需要去翻注释或者问同事。
重构更有信心。 以前改订单逻辑总是担心”会不会影响其他地方”,现在有了类型检查的护城河,改代码时心里有底多了。有一次我们重构了订单查询的接口,TypeScript 帮我们 catch 了3个调用方的兼容性问题,这些要是放到运行时才发现,那就麻烦大了。
给想转型的团队几个建议
不要追求一次性全部迁移。 渐进式迁移是最好的策略,先把核心模块迁移,外围服务保持 JS,两边可以共存。TypeScript 和 JavaScript 是互操作的,
.js文件可以直接 import.ts文件。编译错误是礼物,不是敌人。 每一个报错都在帮你发现一个潜在问题。不要急着
@ts-ignore,先想想这个报错背后是不是藏着一个真实 bug。类型定义要先于业务逻辑。 先花时间定义好接口和数据类型,再写实现代码。这样业务逻辑会清晰很多,而且类型定义本身就是 API 文档。
严格检查要逐步开启。 不要一次性开启所有严格选项,否则报错数量会吓死人。建议分三个阶段:先关闭严格检查让项目编译通过,然后开启
strictNullChecks,最后开启其他严格选项。把类型检查加入 CI/CD 流程。 确保每次提交都经过类型检查,不要让有问题的代码进入主分支。
最后说几句
这次转型最让我感触的是:TypeScript 的编译错误不是在限制你,而是在帮你。那些让你头疼的报错,其实都是编译器在说——”这里有个地方你不确定,我觉得你可能需要考虑一下”。
电商系统的核心是钱和订单,容错率极低。一个价格计算错误可能导致公司损失几万元,一个状态转移错误可能导致用户投诉。TypeScript 用编译时的检查,帮我们在问题发生之前就把它们消灭掉。
转型的过程确实痛苦,团队的前两周每天都在和报错搏斗。但当我们看到生产环境的 bug 曲线断崖式下降的那一刻,所有人都觉得值得了。
如果你的团队也在考虑从 JS 转型 TS,我的建议是:别怕报错,报错越多,说明你发现的隐藏问题越多。每一次和编译器的”对话”,都在让你的代码变得更安全。
