某电商后台 Node.js 项目引入 TypeScript 的完整过程从类型定义到业务逻辑实现的真实经验分享与常见问题解答
开篇:我们为什么要折腾这件事
说实话,2023 年初那个冬天,我们团队的 Node.js 电商后台项目已经跑了一年多。业务量从日均几千单涨到了几万单,代码库也膨胀到了二十几个模块。那时候我们用的是纯 JavaScript,ES6+ 语法用得还算熟练,但问题开始出现——
一个订单状态字段,有的地方叫 orderStatus,有的地方叫 status,还有的地方直接传个数字 1 代表”已支付”,传字符串 "paid" 也代表”已支付”。某天上线后,后台订单列表直接炸了,前端报了个 undefined 的错,排查了整整两个晚上。
我们老大当时在群里说了一句:”再这样下去,我们要给代码做遗体认领了。”
于是,引入 TypeScript 这件事,就被提上了议程。
第一步:冷静评估,别一上来就改
很多团队引入 TypeScript 最大的坑,就是冲动。周五晚上热血沸腾,周六开始迁移,周日就发现线上全崩了。
我们当时的策略是“不折腾、不推倒、渐进式”。
1.1 先看看项目现状
# 我们的项目结构大致是这样
ecommerce-backend/
├── src/
│ ├── controllers/ # 控制器层
│ ├── services/ # 业务逻辑层
│ ├── models/ # 数据模型
│ ├── routes/ # 路由定义
│ ├── middleware/ # 中间件
│ ├── utils/ # 工具函数
│ ├── types/ # ← 新加的,放类型定义
│ └── app.js # 入口文件
├── package.json
├── .babelrc
└── .env
1.2 决定迁移策略
我们选的是 TypeScript with ts-node 并行运行 的方式,而不是直接把所有 .js 文件改 .ts。这样做的好处是:
- 旧代码完全不动,先跑着
- 新模块直接用 TypeScript 写
- 旧代码可以逐步迁移,想哪天迁哪天
# 安装必要的包
npm install --save-dev typescript @types/node @types/express @types/axios @types/morgan
npm install --save-dev ts-node typescript-eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
安装完之后,在根目录新建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"types": ["node", "express"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
关键点解释一下:
strict: true—— 一开始别开,我们团队很多人对 TypeScript 不熟,先开宽松模式,等适应后再逐步开启。但后面我们第二周就把 strict 开了,因为noImplicitAny这种规则真的能救命。esModuleInterop: true—— 必须开,不然导入 Node.js 模块会一堆报错。skipLibCheck: true—— 跳过.d.ts文件的类型检查,加快编译速度。
第二步:类型定义——这是整个迁移的地基
2.1 先定义项目里反复出现的”硬编码字符串”
我们项目里有一个大麻烦,就是各种状态用字符串硬编码。比如订单状态:
// src/types/enums.ts
/** 订单状态枚举 */
export enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
SHIPPED = 'shipped',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
REFUNDING = 'refunding',
REFUNDED = 'refunded',
}
/** 支付渠道 */
export enum PaymentChannel {
ALIPAY = 'alipay',
WECHAT = 'wechat',
UNIONPAY = 'unionpay',
BALANCE = 'balance',
}
/** 角色类型 */
export enum Role {
ADMIN = 'admin',
SELLER = 'seller',
USER = 'user',
OPERATOR = 'operator',
}
定义好枚举之后,原来散落在各处的魔法字符串就都有了统一来源:
// 迁移前(JavaScript)
if (order.status === 'paid') { ... }
if (order.status === 'PENDING') { ... } // 有人用大写,有人用小写,乱成一锅粥
// 迁移后(TypeScript)
if (order.status === OrderStatus.PAID) { ... }
// 或者用 as const 约束
const status: OrderStatus = OrderStatus.PENDING;
2.2 核心业务实体类型
电商后台最核心的就是订单和商品。我们把它们的类型定义清楚:
// src/types/entities/order.ts
import { OrderStatus, PaymentChannel } from '../enums';
/** 订单明细 */
export interface OrderItem {
id: string;
productId: string;
productName: string;
productSku: string;
price: number; // 单价,单位:分
quantity: number;
total: number; // 小计,单位:分
imageUrl: string;
}
/** 收货地址 */
export interface ShippingAddress {
province: string;
city: string;
district: string;
detailAddress: string;
receiverName: string;
receiverPhone: string;
postalCode?: string;
}
/** 支付信息 */
export interface PaymentInfo {
channel: PaymentChannel;
transactionId: string | null; // 第三方支付交易号
paidAt: Date | null;
amount: number; // 支付金额,单位:分
fee: number; // 支付手续费,单位:分
}
/** 订单主实体 */
export interface Order {
id: string; // 业务订单号,如 ORD2023120100001
userId: string; // 用户ID
status: OrderStatus; // 订单状态
items: OrderItem[]; // 商品明细
totalAmount: number; // 订单总金额,单位:分
discountAmount: number; // 优惠金额,单位:分
shippingFee: number; // 运费,单位:分
payAmount: number; // 实付金额,单位:分
shippingAddress: ShippingAddress; // 收货地址快照
payment?: PaymentInfo; // 支付信息(未支付时为空)
remark?: string; // 买家备注
sellerRemark?: string; // 卖家备注
createdAt: Date;
updatedAt: Date;
paidAt?: Date; // 支付时间
shippedAt?: Date; // 发货时间
completedAt?: Date; // 完成时间
}
/** 创建订单的入参(前端传来) */
export interface CreateOrderRequest {
userId: string;
items: Array<{
productId: string;
sku: string;
quantity: number;
}>;
addressId: string;
remark?: string;
couponCode?: string; // 优惠码
}
/** 更新订单状态的入参 */
export interface UpdateOrderStatusRequest {
orderId: string;
status: OrderStatus;
operatorId: string;
remark?: string;
}
// src/types/entities/product.ts
/** 商品 SKU */
export interface ProductSku {
skuId: string;
skuCode: string;
price: number; // 销售价,单位:分
costPrice: number; // 成本价,单位:分
stock: number; // 库存
lockedStock: number; // 锁定库存(下单未支付时占用)
specs: Record<string, string>; // 规格,如 { "颜色": "红色", "尺寸": "L" }
}
/** 商品分类 */
export interface Category {
id: string;
name: string;
parentId: string | null;
level: number;
sort: number;
}
/** 商品主实体 */
export interface Product {
id: string;
title: string;
subtitle?: string;
mainImage: string;
images: string[];
description: string; // HTML 内容
categoryId: string;
brandId?: string;
skus: ProductSku[];
status: 'active' | 'inactive' | 'deleted';
salesCount: number;
viewCount: number;
createdAt: Date;
updatedAt: Date;
}
/** 商品列表查询参数 */
export interface ProductListQuery {
keyword?: string;
categoryId?: string;
brandId?: string;
status?: Product['status'];
minPrice?: number;
maxPrice?: number;
sortBy?: 'sales' | 'price_asc' | 'price_desc' | 'newest';
page: number;
pageSize: number;
}
2.3 分页和通用响应类型
这类类型在所有 API 里都会用到,单独拎出来:
// src/types/common.ts
/** 分页参数 */
export interface PaginationQuery {
page: number;
pageSize: number;
}
/** 分页结果 */
export interface PaginatedResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** 通用 API 响应 */
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
timestamp: number;
}
/** 分页列表响应 */
export interface PageResponse<T> extends ApiResponse<PaginatedResult<T>> {}
/** 错误响应 */
export interface ErrorResponse extends ApiResponse<null> {
code: number;
message: string;
data: null;
stack?: string;
}
/** 文件上传结果 */
export interface UploadResult {
url: string;
filename: string;
size: number;
mimeType: string;
}
有了这些通用类型之后,其他地方直接复用,再也不用每次重新定义了。
第三步:业务逻辑层的渐进式改造
3.1 先从最独立的模块开始
我们没有一上来就改订单服务,而是先改了工具函数和中间件这类最独立的代码。
工具函数改造示例:
// src/utils/string.ts(迁移前是 .js 文件)
/**
* 生成订单号
* 格式:ORD + yyyyMMdd + 6位序列号
*/
export function generateOrderNo(): string {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const seq = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
return `ORD${year}${month}${day}${seq}`;
}
/**
* 金额从"分"转换为"元"
* @param cents 分
* @param decimals 小数位数,默认2
*/
export function centsToYuan(cents: number, decimals: number = 2): string {
if (!Number.isFinite(cents)) return '0.00';
return (cents / 100).toFixed(decimals);
}
/**
* 金额从"元"转换为"分"
* @param yuan 元
*/
export function yuanToCents(yuan: string | number): number {
const num = typeof yuan === 'string' ? parseFloat(yuan) : yuan;
return Math.round(num * 100);
}
/**
* 校验手机号
*/
export function isValidPhone(phone: string): boolean {
return /^1[3-9]\d{9}$/.test(phone);
}
/**
* 脱敏手机号
*/
export function maskPhone(phone: string): string {
if (!phone || phone.length !== 11) return '***********';
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
这些函数改完 .ts 后缀之后,TypeScript 直接就能用,不需要额外配置。
3.2 服务层的改造——这是核心
以订单服务为例,看看是怎么一步一步改的:
// src/services/order.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { Order, OrderStatus, CreateOrderRequest, UpdateOrderStatusRequest } from '../types/entities/order';
import { Product, ProductSku } from '../types/entities/product';
import { PaginatedResult } from '../types/common';
import { generateOrderNo, yuanToCents } from '../utils/string';
import { orderRepository } from '../repositories/order.repository';
import { productRepository } from '../repositories/product.repository';
import { inventoryService } from './inventory.service';
import { couponService } from './coupon.service';
import { Logger } from '../utils/logger';
const logger = new Logger('OrderService');
@Injectable()
export class OrderService {
/**
* 创建订单
* 关键步骤:校验商品 → 锁定库存 → 计算金额 → 创建订单
*/
async createOrder(dto: CreateOrderRequest): Promise<Order> {
logger.info(`用户 ${dto.userId} 创建订单,商品数: ${dto.items.length}`);
// 1. 批量查询商品信息
const productIds = dto.items.map(item => item.productId);
const products = await productRepository.findByIds(productIds);
if (products.length !== productIds.length) {
const foundIds = new Set(products.map(p => p.id));
const missingIds = productIds.filter(id => !foundIds.has(id));
throw new Error(`商品不存在: ${missingIds.join(', ')}`);
}
// 2. 校验商品状态和库存,并构建订单明细
const orderItems: Order['items'] = [];
let totalAmount = 0;
for (const item of dto.items) {
const product = products.find(p => p.id === item.productId);
if (!product) {
throw new Error(`商品 ${item.productId} 不存在`);
}
// 查找匹配的 SKU
const sku = product.skus.find(s => s.skuCode === item.sku);
if (!sku) {
throw new Error(`商品 ${product.title} 无此规格: ${item.sku}`);
}
if (product.status !== 'active') {
throw new Error(`商品 ${product.title} 已下架`);
}
// 检查库存(注意:这里要减去已锁定的库存)
const availableStock = sku.stock - sku.lockedStock;
if (availableStock < item.quantity) {
throw new Error(`商品 ${product.title} 库存不足,剩余 ${availableStock}`);
}
const itemTotal = sku.price * item.quantity;
totalAmount += itemTotal;
orderItems.push({
id: crypto.randomUUID(),
productId: product.id,
productName: product.title,
productSku: item.sku,
price: sku.price,
quantity: item.quantity,
total: itemTotal,
imageUrl: product.mainImage,
});
}
// 3. 处理优惠券
let discountAmount = 0;
if (dto.couponCode) {
discountAmount = await couponService.calculateDiscount(
dto.userId,
dto.couponCode,
totalAmount,
);
}
// 4. 计算实付金额(运费暂定为固定 0,实际应该根据地址计算)
const shippingFee = 0;
const payAmount = Math.max(0, totalAmount - discountAmount + shippingFee);
// 5. 锁定库存
await inventoryService.lockStock(
dto.items.map(item => ({
productId: item.productId,
sku: item.sku,
quantity: item.quantity,
})),
);
// 6. 创建订单
const order: Order = {
id: generateOrderNo(),
userId: dto.userId,
status: OrderStatus.PENDING,
items: orderItems,
totalAmount,
discountAmount,
shippingFee,
payAmount,
shippingAddress: {
// 这里应该从 addressId 查询地址,简化处理
province: '',
city: '',
district: '',
detailAddress: '',
receiverName: '',
receiverPhone: '',
},
createdAt: new Date(),
updatedAt: new Date(),
};
const createdOrder = await orderRepository.create(order);
logger.info(`订单创建成功: ${createdOrder.id}, 金额: ${createdOrder.payAmount}分`);
return createdOrder;
}
/**
* 支付订单
* 实际项目中会调用第三方支付 API
*/
async payOrder(orderId: string, paymentChannel: string, amount: number): Promise<Order> {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new Error(`订单不存在: ${orderId}`);
}
if (order.status !== OrderStatus.PENDING) {
throw new Error(`订单状态异常,当前状态: ${order.status}`);
}
// 金额校验(防止篡改)
if (amount !== order.payAmount) {
logger.warn(`订单 ${orderId} 支付金额不匹配,期望: ${order.payAmount}分, 实际: ${amount}分`);
throw new Error('支付金额不匹配');
}
// 模拟支付回调处理
const transactionId = `TXN${Date.now()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const updatedOrder = await orderRepository.update(orderId, {
status: OrderStatus.PAID,
payment: {
channel: paymentChannel as any, // 实际项目中应该用枚举
transactionId,
paidAt: new Date(),
amount,
fee: Math.round(amount * 0.006), // 假设计算 0.6% 手续费
},
paidAt: new Date(),
});
// 解锁库存(支付成功后,锁定变为实际扣减)
await inventoryService.confirmLock(order.items.map(item => ({
productId: item.productId,
sku: item.productSku,
quantity: item.quantity,
})));
logger.info(`订单 ${orderId} 支付成功,交易号: ${transactionId}`);
return updatedOrder;
}
/**
* 分页查询订单
*/
async listOrders(query: {
userId?: string;
status?: OrderStatus;
startDate?: Date;
endDate?: Date;
page: number;
pageSize: number;
}): Promise<PaginatedResult<Order>> {
const { userId, status, startDate, endDate, page, pageSize } = query;
const filter: any = {};
if (userId) filter.userId = userId;
if (status) filter.status = status;
if (startDate || endDate) {
filter.createdAt = {};
if (startDate) filter.createdAt.$gte = startDate;
if (endDate) filter.createdAt.$lte = endDate;
}
const [list, total] = await Promise.all([
orderRepository.findPaginated(filter, page, pageSize),
orderRepository.count(filter),
]);
return {
list,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* 更新订单状态(供后台运营使用)
*/
async updateOrderStatus(dto: UpdateOrderStatusRequest): Promise<Order> {
const order = await orderRepository.findById(dto.orderId);
if (!order) {
throw new Error(`订单不存在: ${dto.orderId}`);
}
// 状态机校验:不是所有状态都能直接切换
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
[OrderStatus.PENDING]: [OrderStatus.PAID, OrderStatus.CANCELLED],
[OrderStatus.PAID]: [OrderStatus.SHIPPED, OrderStatus.REFUNDING],
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED],
[OrderStatus.COMPLETED]: [],
[OrderStatus.CANCELLED]: [],
[OrderStatus.REFUNDING]: [OrderStatus.REFUNDED],
[OrderStatus.REFUNDED]: [],
};
const allowedNext = validTransitions[order.status];
if (!allowedNext.includes(dto.status)) {
throw new Error(
`订单当前状态 ${order.status} 不允许转移到 ${dto.status}`
);
}
return orderRepository.update(dto.orderId, {
status: dto.status,
...(dto.remark ? { sellerRemark: dto.remark } : {}),
});
}
}
3.3 控制器层的改造
// src/controllers/order.controller.ts
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
import { OrderService } from '../services/order.service';
import { Order, CreateOrderRequest, UpdateOrderStatusRequest } from '../types/entities/order';
import { PageResponse } from '../types/common';
@Controller('api/v1/orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post()
async createOrder(@Body() dto: CreateOrderRequest): Promise<Order> {
return this.orderService.createOrder(dto);
}
@Get(':id')
async getOrder(@Param('id') orderId: string): Promise<Order> {
return this.orderService.findById(orderId);
}
@Get()
async listOrders(
@Query() query: {
userId?: string;
status?: string;
startDate?: string;
endDate?: string;
page?: string;
pageSize?: string;
}
): Promise<PageResponse<Order>> {
const page = parseInt(query.page || '1', 10);
const pageSize = parseInt(query.pageSize || '20', 10);
return this.orderService.listOrders({
userId: query.userId,
status: query.status as any,
startDate: query.startDate ? new Date(query.startDate) : undefined,
endDate: query.endDate ? new Date(query.endDate) : undefined,
page,
pageSize,
});
}
@Post(':id/pay')
async payOrder(
@Param('id') orderId: string,
@Body() body: { channel: string; amount: number }
): Promise<Order> {
return this.orderService.payOrder(orderId, body.channel, body.amount);
}
@Patch(':id/status')
async updateOrderStatus(
@Param('id') orderId: string,
@Body() body: { status: string; operatorId: string; remark?: string }
): Promise<Order> {
return this.orderService.updateOrderStatus({
orderId,
status: body.status as any,
operatorId: body.operatorId,
remark: body.remark,
});
}
}
可以看到,控制器层变得非常薄,主要职责就是接收请求、参数转换、调用服务。所有的业务逻辑都在 service 层。
第四步:遇到的真实坑和解决方案
4.1 坑一:as any 满天飞,感觉白改了
刚开始迁移的时候,我们很多人图省事,遇到类型不匹配就直接 as any:
// 这样写虽然能跑,但完全失去了 TypeScript 的意义
const result = someFunction(input as any);
解决方案:强制 ESLint 规则
我们在 eslint.config.js 里加了这些规则:
// .eslintrc.js 或 eslint.config.js
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
// 禁止使用 any,除非有充分理由
'@typescript-eslint/no-explicit-any': 'warn',
// 禁止不必要的类型断言
'@typescript-eslint/no-non-null-assertion': 'error',
// 禁止隐式 any
'@typescript-eslint/no-inferrable-types': 'off',
// 要求所有函数有返回类型
'@typescript-eslint/explicit-function-return-type': 'warn',
// 要求所有参数有类型
'@typescript-eslint/explicit-parameter-types': 'off', // 这个太严格了,先不开
},
};
后来我们又加了一个更狠的:"@typescript-eslint/no-explicit-any": "error",直接报错,不让过。这样团队成员就必须认真思考类型该怎么写。
4.2 坑二:第三方库没有类型定义
我们用了 ali-oss(阿里云 OSS)、node-cron 这些库,它们的类型定义要么没有,要么很旧。
解决方案:用 @types/ 包或自己写声明文件
// 对于有 @types 的库,直接装
npm install --save-dev @types/node-cron
// 对于没有 @types 的库,自己写 .d.ts 文件
// src/types/ali-oss.d.ts
declare module 'ali-oss' {
interface ClientOptions {
region: string;
accessKeyId: string;
accessKeySecret: string;
bucket: string;
}
interface PutResult {
url: string;
name: string;
size: number;
}
class Client {
constructor(options: ClientOptions);
put(key: string, file: Buffer | string): Promise<PutResult>;
get(key: string): Promise<any>;
delete(key: string): Promise<void>;
listObjects(prefix?: string, options?: any): Promise<any>;
}
export = Client;
}
4.3 坑三:数据库查询结果的类型不好定义
我们用 MongoDB,查询结果和 TypeScript 类型之间总有出入。
解决方案:用 Mongoose 的 HydratedDocument 或者自己定义接口
// 如果用 Mongoose
import { HydratedDocument } from 'mongoose';
import { Order } from '../types/entities/order';
// 定义 Mongoose Schema
import { Schema, model } from 'mongoose';
const orderSchema = new Schema<Order>({
id: { type: String, required: true, unique: true },
userId: { type: String, required: true },
status: { type: String, enum: Object.values(OrderStatus), required: true },
items: [{ type: Schema.Types.Mixed, required: true }],
totalAmount: { type: Number, required: true },
// ... 其他字段
}, {
timestamps: true,
});
export const OrderModel = model<Order>('Order', orderSchema);
这样查询出来的结果就自动带有类型了,不用到处 as Order。
4.4 坑四:枚举用字符串还是数字?
这是团队里争论最久的一个问题。
- 用数字枚举:序列化后是数字,数据库好存,但可读性差
- 用字符串枚举:可读性好,但序列化后体积大,数据库索引效率略低
- 用
as const对象:类型安全,但不够”枚举”的感觉
我们最后的方案是:业务状态用字符串枚举,不会变化的常量用数字枚举。
// 字符串枚举——适合会序列化到前端的状态值
export enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
SHIPPED = 'shipped',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
}
// 数字枚举——适合内部逻辑,不对外暴露
export enum ErrorCode {
UNKNOWN_ERROR = 0,
VALIDATION_ERROR = 1000,
NOT_FOUND = 1001,
UNAUTHORIZED = 1002,
INSUFFICIENT_STOCK = 2001,
PAYMENT_FAILED = 3001,
}
第五步:测试的改造
TypeScript 引入之后,测试代码也要跟着改。我们用 Jest,改动不大:
// __tests__/services/order.service.test.ts
import { OrderService } from '../../src/services/order.service';
import { OrderStatus } from '../../src/types/enums';
import { CreateOrderRequest } from '../../src/types/entities/order';
describe('OrderService', () => {
let service: OrderService;
beforeEach(() => {
service = new OrderService();
});
it('应该成功创建订单', async () => {
const mockDto: CreateOrderRequest = {
userId: 'user_001',
items: [
{ productId: 'prod_001', sku: 'RED-L', quantity: 2 },
{ productId: 'prod_002', sku: 'BLUE-M', quantity: 1 },
],
addressId: 'addr_001',
remark: '请尽快发货',
};
const order = await service.createOrder(mockDto);
expect(order).toBeDefined();
expect(order.id).toMatch(/^ORD/);
expect(order.status).toBe(OrderStatus.PENDING);
expect(order.items).toHaveLength(2);
expect(order.payAmount).toBeGreaterThan(0);
});
it('库存不足时应抛出错误', async () => {
const mockDto: CreateOrderRequest = {
userId: 'user_001',
items: [
{ productId: 'prod_003', sku: 'RED-XL', quantity: 9999 },
],
addressId: 'addr_001',
};
await expect(service.createOrder(mockDto))
.rejects
.toThrow('库存不足');
});
it('状态转移应遵循状态机', async () => {
const order = await service.createOrder({
userId: 'user_001',
items: [{ productId: 'prod_001', sku: 'RED-L', quantity: 1 }],
addressId: 'addr_001',
});
// 可以直接从待支付跳到已完成吗?不应该
await expect(
service.updateOrderStatus({
orderId: order.id,
status: OrderStatus.COMPLETED,
operatorId: 'admin_001',
})
).rejects.toThrow('不允许转移');
});
});
测试用 TypeScript 写的好处是,IDE 的自动补全会让写测试快很多,而且类型错误会在写测试的时候就暴露出来,不用跑的时候才报错。
第六步:构建和部署的配置
// package.json 的 scripts
{
"scripts": {
"dev": "ts-node src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
"start:prod": "NODE_ENV=production node dist/app.js",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix"
}
}
Dockerfile 也要改一下:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/app.js"]
第七步:迁移过程中的关键原则
7.1 别试图一次性迁移所有代码
我们当时如果一口气把所有 JS 改成 TS,估计得延期两个月。我们采取了“新代码用 TS,旧代码逐步迁”的策略:
- 新写的模块直接用 TypeScript
- 有 bug 要修的旧模块,顺便改成 TypeScript
- 纯新增功能才考虑复用旧 JS 模块
7.2 类型定义要前置,别边写边定义
有一个经验:先想清楚类型,再写业务逻辑。
比如做”退款服务”的时候,我们先把 RefundRequest、RefundResult、RefundRecord 这几个类型定义好,写进 types/refund.ts,然后才开始写 service 代码。这样写出来的代码结构清晰,不容易返工。
7.3 善用 unknown 而不是 any
// ❌ 错误示范
function handleData(data: any) {
console.log(data.message); // TypeScript 不报错,但运行时可能崩
}
// ✅ 正确示范
function handleData(data: unknown) {
if (typeof data === 'object' && data !== null && 'message' in data) {
console.log((data as { message: string }).message);
}
}
unknown 是类型安全的 any,你必须先做类型守卫才能使用它。这个习惯一旦养成,线上 undefined 的 bug 会少很多。
常见问题解答(FAQ)
Q1:TypeScript 会不会让开发变慢?
短期会,长期不会。
我们第一个月确实觉得变慢了,因为要不停地补类型、改类型。但从第二个月开始,IDE 的自动补全和类型提示让我们写代码的速度反而提升了。特别是重构的时候,TypeScript 能告诉你所有受影响的地方,这在 JavaScript 里是做不到的。
Q2:老项目迁移 TypeScript,会不会影响线上服务?
不会,只要你们用的是 ts-node 并行运行。
我们当时就是边写 TS 边跑 JS,两者互不干扰。线上服务完全没受影响。只有当我们把某个模块完全切换成 TypeScript 版本之后,才做了灰度发布验证。
Q3:TypeScript 类型定义太多,会不会影响性能?
不会。 TypeScript 的类型在编译后会被完全移除,不产生任何运行时代码。我们构建出来的 JS 文件和之前用 JavaScript 写的一模一样。
Q4:怎么说服团队接受 TypeScript?
我们老大做了几件事:
- 先让小团队试点:选了三个愿意尝试的同学,用两周时间把一个非核心模块用 TypeScript 重写,让大家看到效果。
- IDE 体验分享:组织了一次分享会,演示了 TypeScript 的自动补全、重构提示、错误预警等功能。
- 代码 Review 强制要求:规定所有新代码必须用 TypeScript 写,旧代码可以逐步迁移。
Q5:类型定义太多太复杂,怎么办?
善用类型别名和泛型来简化:
// 定义通用的响应包装器
type Result<T> = { success: true; data: T } | { success: false; error: string };
// 使用
async function getUser(id: string): Promise<Result<User>> {
const user = await userRepository.findById(id);
if (!user) {
return { success: false, error: '用户不存在' };
}
return { success: true, data: user };
}
这样比每次都写 { code: number; message: string; data: T } 简洁多了。
Q6:第三方库的类型定义不全怎么办?
除了前面说的自己写 .d.ts 之外,还可以:
// 方案一:用 @types/ 包
npm install --save-dev @types/库名
// 方案二:用 declare module 自己声明
declare module 'untyped-library' {
export function someFunction(input: string): number;
}
// 方案三:直接忽略类型,用 any(临时方案,不建议长期用)
const lib = require('untyped-library') as any;
Q7:团队协作中,类型定义由谁来维护?
我们制定了规范:类型定义统一放在 src/types/ 目录下,按业务模块分包。新增类型需要 Code Review,避免每个人定义自己的版本。
最后:迁移一年后的真实感受
回头看,引入 TypeScript 是我们这个项目做过的最正确的技术决策之一。
最大的好处:
- 重构有底气:改一个函数的签名,TypeScript 会告诉你所有调用方,不用自己一个一个找。
- 文档即代码:类型定义就是最好的文档,新来的同学看类型就知道这个字段是什么。
- Bug 大幅减少:类型错误在编译期就暴露了,不用等到线上出问题了才知道。
- IDE 体验极佳:自动补全、跳转定义、查找引用,这些功能在 JavaScript 里是没有的。
当然也有代价:
- 学习曲线,尤其是团队里有些同学之前没接触过 TypeScript。
- 初期开发速度确实会变慢,大概需要 2-4 周的适应期。
- 有些动态的场景(比如动态生成的配置)还是很难用 TypeScript 表达。
但总体来说,收益远大于成本。如果你也在考虑给 Node.js 项目引入 TypeScript,我的建议是:别犹豫,早点做。越早引入,越早享受 TypeScript 带来的红利。
希望这篇分享对你有帮助。如果你在实际迁移过程中遇到其他问题,欢迎随时交流。
