)
1. 为什么 3000 行 Express 项目迁移 TypeScript 会卡住一个跑了两年多的 Node.js Express 项目代码量大概 3000 行纯 JavaScript目录结构还算清晰app.js入口、config.js配置、routes/路由、controllers/控制器、services/业务逻辑、models/数据模型、middleware/中间件、utils/工具函数。这种项目想迁移到 TypeScript最典型的卡点不是「不会写 TS」而是不知道从哪一刀切下去。我见过太多团队的做法是装个typescript把tsconfig.json一开strict: true然后整个项目瞬间爆出几百个红色波浪线改到一半发现项目跑不起来了最后回滚。问题出在迁移顺序和配置策略上——TypeScript 迁移不是「一次性重写」而是「渐进式共存」。这篇要交付的东西很具体一套可复制的tsconfig.json支持 JS/TS 共存、package.json脚本、Express 类型声明骨架以及用 Claude Code 辅助迁移的完整流程。目标很明确——迁移完成后tsc --noEmit零报错且运行时不改变原有行为。适合谁看手里有存量 Express JS 项目、想上 TypeScript 但被类型报错劝退的后端同学或者想用 AI 辅助做大规模代码重构、但不知道怎么给 AI 下指令的开发者。整个流程我会按「先配环境 → 再定类型 → 从底层往上迁 → 最后验证」的顺序拆开每一步都有可复制的命令和配置。2. 迁移前的环境准备与 TaoToken 接入Claude Code 这类 AI 编码工具在迁移场景里最大的价值是它能读懂整个项目的上下文然后按你给的约束批量改写文件。但前提是你得有一个稳定的模型调用入口。我这边用的是 TaoToken 的 API 服务它兼容 Anthropic 的接口格式Claude Code 可以直接对接。先说清楚它是什么TaoToken 是一个大模型 API 聚合服务提供 Claude、GPT 等模型的统一调用入口支持按量计费适合需要长期跑编码任务的场景。官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。接入 Claude Code 的步骤不复杂核心是拿到 API Key 后配置环境变量。你可以先到控制台创建密钥控制台入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite拿到 Key 之后在项目根目录或者 shell 配置里设置export ANTHROPIC_BASE_URLhttps://taotoken.net/api export ANTHROPIC_API_KEY你的_TaoToken_Key如果你用的是 Claude Code 的 CLI它会自动读取这两个环境变量。想先验证模型能不能正常对话可以到模型对话页面试一下https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 。确认能正常返回内容后再进入项目迁移环节。注意环境变量里的ANTHROPIC_BASE_URL不要带末尾斜杠否则部分客户端会拼接出双斜杠导致 404。3. 可复制的 tsconfig 与 package.json 配置迁移的第一步不是改代码而是把 TypeScript 编译环境配好并且让 JS 和 TS 能共存。这样你迁移一个文件、验证一个文件项目始终处于可运行状态。3.1 tsconfig.json 渐进式配置{ compilerOptions: { target: ES2020, module: commonjs, lib: [ES2020], outDir: ./dist, rootDir: ./src, declaration: true, sourceMap: true, strict: true, noImplicitAny: true, strictNullChecks: true, moduleResolution: node, esModuleInterop: true, allowSyntheticDefaultImports: true, resolveJsonModule: true, baseUrl: ., paths: { /*: [src/*] }, allowJs: true, checkJs: false, forceConsistentCasingInFileNames: true, skipLibCheck: true }, include: [src/**/*], exclude: [node_modules, dist, tests] }这里有两个关键点。allowJs: true让编译器接受.js文件checkJs: false表示暂时不对 JS 文件做类型检查——这就是渐进式迁移的核心开关。等你把所有文件都改成.ts之后再把checkJs打开做最终校验。strict: true我建议一开始就开因为迁移过程中 AI 会帮你补类型如果先关掉后面再开等于要重新过一遍。skipLibCheck: true能跳过第三方库声明文件的检查避免被node_modules里的类型问题拖住。3.2 package.json 脚本{ scripts: { dev: tsx watch src/app.ts, build: tsc, start: node dist/app.js, typecheck: tsc --noEmit, migrate:check: tsc --noEmit 21 | head -50 } }tsx比ts-node快而且对 ESM/CJS 混用的兼容性更好。typecheck是迁移期间你最常跑的命令--noEmit表示只做类型检查不产出文件。migrate:check加了head -50因为迁移初期报错可能很多先看前 50 条定位主要问题。3.3 安装依赖npm install -D typescript tsx types/node types/express npm install -D types/jsonwebtoken types/bcryptjstypes/node和types/express是必须的JWT 和 bcrypt 的类型包按你项目实际用到的库来装。装完之后跑一下npx tsc --version确认版本在 5.x 以上。4. 用 Claude Code 从底层往上迁移迁移顺序很重要从依赖最少的模块开始往上走。具体是config → utils → models → services → middleware → controllers → routes → app。这样每迁一层上层的类型依赖都已经就绪。4.1 先定义全局类型在写任何业务代码之前先建src/types/目录把核心接口定义出来。给 Claude Code 的提示词可以这样写请分析当前项目所有 JS 文件中的数据结构和函数签名 在 src/types/ 下创建完整的类型定义 1. user.ts — User、CreateUserInput、UpdateUserInput、LoginInput、JwtPayload 2. common.ts — ApiResponse、PaginatedResult、PaginationQuery、AppConfig 3. express.d.ts — 扩展 Express 的 Request添加 user 属性 4. index.ts — 统一导出 要求不使用 any所有字段类型明确可空字段用 | null。Claude Code 会扫描你的models/userModel.js、services/userService.js等文件推断出数据结构。比如它从 SQL 查询里能看出phone、nickname、avatar这些字段可能为 null从jwt.sign的 payload 里能推断出JwtPayload的结构。Express 的类型扩展是很多人卡住的地方src/types/express.d.ts长这样import { JwtPayload } from ./user; declare global { namespace Express { interface Request { user?: JwtPayload; } } } export {};这个文件让req.user在中间件和控制器里都有类型提示不用再写(req as any).user。4.2 迁移 config 和 utils这两个模块没有内部依赖是最安全的起点。提示词将 config.js 迁移到 src/config/index.ts 将 utils/response.js 和 utils/logger.js 迁移到 src/utils/ 下。 要求 - 使用 src/types 里已定义的类型 - 函数参数和返回值都标注类型 - 不使用 any - 保持功能与 JS 版本完全一致response.ts里的泛型响应函数是重点import { Response } from express; import { ApiResponse, PaginatedResult } from ../types; export function successT( res: Response, data: T, message: string 操作成功, statusCode: number 200 ): void { const response: ApiResponseT { code: 0, message, data }; res.status(statusCode).json(response); } export function paginatedT( res: Response, data: PaginatedResultT ): void { res.json({ code: 0, data: { list: data.list, pagination: { total: data.total, page: data.page, size: data.size, totalPages: Math.ceil(data.total / data.size), }, }, }); }泛型T让success能适配任意返回数据结构同时保持类型安全。4.3 迁移 models 层mysql2 类型处理这是整个迁移里最麻烦的一层因为mysql2的查询结果默认是RowDataPacket[]需要显式声明行类型。提示词将 models/userModel.js 迁移到 src/models/userModel.ts。 要求 1. 所有查询方法的返回值都要有明确类型 2. 使用 mysql2 的 RowDataPacket 和 ResultSetHeader 3. 处理查询结果的类型推导 4. 保持 SQL 参数化不受影响关键代码import mysql, { Pool, RowDataPacket, ResultSetHeader } from mysql2/promise; import { User, UserListItem, UserQueryParams, PaginatedResult } from ../types; interface UserRow extends RowDataPacket, User {} interface CountRow extends RowDataPacket { total: number; } const pool: Pool mysql.createPool({ /* ... */ }); async function findById(id: number): PromiseUserListItem | null { const [rows] await pool.queryUserListItem[]( SELECT id, username, email FROM users WHERE id ? AND deleted_at IS NULL, [id] ); return rows[0] || null; } async function create(userData: CreateUserInput { password: string }): Promisenumber { const [result] await pool.queryResultSetHeader( INSERT INTO users (username, password, email) VALUES (?, ?, ?), [userData.username, userData.password, userData.email] ); return result.insertId; }pool.queryT里的泛型参数是关键它告诉 TypeScript 查询结果是什么形状。ResultSetHeader用于 INSERT/UPDATE/DELETE能拿到insertId和affectedRows。4.4 迁移 services 和 middleware业务逻辑层建议引入自定义错误类替代原来throw { status, message }的写法export class AppError extends Error { public readonly statusCode: number; public readonly code: string; constructor(message: string, statusCode: number 500, code: string INTERNAL_ERROR) { super(message); this.statusCode statusCode; this.code code; Object.setPrototypeOf(this, AppError.prototype); } static badRequest(message: string): AppError { return new AppError(message, 400, BAD_REQUEST); } static notFound(message: string 资源不存在): AppError { return new AppError(message, 404, NOT_FOUND); } }中间件用 Express 的RequestHandler类型import { Request, Response, NextFunction, RequestHandler } from express; import jwt from jsonwebtoken; import { JwtPayload } from ../types; export const authenticate: RequestHandler ( req: Request, _res: Response, next: NextFunction ): void { const authHeader req.headers.authorization; if (!authHeader) { next(new AppError(未提供认证令牌, 401)); return; } const token authHeader.startsWith(Bearer ) ? authHeader.slice(7) : authHeader; try { req.user jwt.verify(token, config.jwt.secret) as JwtPayload; next(); } catch { next(new AppError(Token 无效或已过期, 401)); } };4.5 迁移 controllers、routes 和 app控制器层建议加一个asyncHandler包装器避免每个函数都写 try/catchtype AsyncRequestHandler ( req: Request, res: Response, next: NextFunction ) Promisevoid; export function asyncHandler(fn: AsyncRequestHandler): RequestHandler { return (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; }路由里这样用router.get(/users, authenticate, asyncHandler(userController.getList)); router.delete( /users/:id, authenticate, authorize([admin]), asyncHandler(userController.deleteUser) );app.ts里把全局错误处理中间件放到最后app.use(errorHandler); app.listen(config.app.port, () { logger.info(服务器启动成功, { port: config.app.port }); });5. 验证迁移结果tsc --noEmit 零报错所有文件迁移完成后跑完整类型检查npx tsc --noEmit如果输出为空说明零报错。如果还有报错按这个顺序排查先看报错文件路径判断是哪一层的问题。如果是models层的RowDataPacket相关报错检查是否漏了extends RowDataPacket。如果是req.user报「属性不存在」检查express.d.ts是否被tsconfig.json的include覆盖到。再跑一次运行时验证npm run dev curl http://localhost:3000/health确认服务能正常启动、健康检查返回正常。然后测一个需要认证的接口确认 JWT 中间件和类型扩展都工作正常。我实测下来3000 行左右的项目迁移后大概在 800 行 TS 代码含类型定义any使用次数为 0。迁移前后文件对照大致是这样原文件JS新文件TS行数变化config.jssrc/config/index.ts30 → 25models/userModel.jssrc/models/userModel.ts120 → 140services/userService.jssrc/services/userService.ts95 → 110middleware/auth.jssrc/middleware/auth.ts40 → 50controllers/userController.jssrc/controllers/userController.ts65 → 55app.jssrc/app.ts40 → 50—src/types/*.ts0 → 2106. 迁移过程中的常见报错排查6.1 TS7016找不到模块的声明文件报错形如Could not find a declaration file for module xxx。说明这个第三方库没有自带类型。解决办法是装对应的types/xxx如果社区没有就在src/types/下建一个xxx.d.ts手动声明declare module some-untyped-lib { export function doSomething(input: string): number; }6.2 TS2339Property user does not exist on type Request这是express.d.ts没生效。检查两点一是文件里必须有export {}否则declare global不生效二是tsconfig.json的include要包含src/**/*确保.d.ts被扫描到。6.3 TS2345mysql2 查询结果类型不匹配pool.query返回的rows默认是RowDataPacket[]如果你直接当业务类型用会报错。正确做法是给query传泛型参数pool.queryUserRow[](...)并且UserRow要extends RowDataPacket。6.4 TS18046err is of type unknowncatch (err)里的err在 strict 模式下是unknown。要么用err instanceof AppError做类型收窄要么在errorHandler里统一处理if (err instanceof AppError) { res.status(err.statusCode).json({ code: 1, message: err.message }); return; }6.5 迁移后运行时行为变化最常见的是parseInt没传进制参数。JS 里parseInt(08)在某些环境下会当八进制处理TS 不会自动帮你改。统一写成parseInt(value, 10)。另外require改成import后注意esModuleInterop是否开启否则import express from express会报错。7. 继续用 AI 做代码审查与长期编码迁移完成只是第一步。tsc --noEmit零报错不代表代码质量没问题——类型安全了但逻辑漏洞、性能问题、安全风险还在。这时候可以让 Claude Code 继续做代码审查从规范、安全、性能、最佳实践四个维度过一遍。如果你打算长期用 AI 辅助编码比如持续做重构、写新模块、跑 Agent 任务可以考虑 TaoToken 的 Coding Plan按周期计费比按量更划算https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Claude Code、Cursor 等客户端的配置示例。整个迁移流程走下来我的体会是AI 辅助迁移的关键不在于 AI 多强而在于你给的约束够不够明确。「不要用 any」「从底层往上迁」「保持功能一致」这几条指令直接决定了输出质量。另外allowJs: true这个开关一定要在迁移初期就打开它让你能一个文件一个文件地验证而不是赌一把全量重写。