说实话,我见过太多项目“被 TypeScript 折磨”的经历。一开始配置错了,后来报错改不动,最后干脆全删了 TypeScript 回退到 JS。但如果你愿意花点时间把基础打牢,TypeScript 带来的回报是巨大的——尤其是当你接手一个几千行的 Node.js 后端时,那种“变量到底叫什么名字”、“这个函数到底返回什么”的焦虑会完全消失。
今天我不跟你扯什么“为什么需要 TypeScript”,我们直接进入正题:怎么把一个现有的 Node.js 项目优雅地接入 TypeScript,并且能在生产环境跑得稳稳当当。
一、先搞清楚:你的项目现在是什么状态?
在动手之前,先问自己几个问题:
- 项目是用 CommonJS(
require/module.exports)还是 ESM(import/export)? - 有没有现有的
package.json?有没有测试? - 用的是 Express、Koa、NestJS,还是自己搭的框架?
- 团队里有没有人已经写过 TypeScript?
这些问题的答案,会直接影响你的迁移策略。
最稳妥的方式是:新建一个 TypeScript 项目,然后逐步把代码迁移过来。 而不是直接在现有 JS 项目里强行加 .ts 文件——那会导致工具链混乱,TypeScript 编译器报错满天飞,你根本不知道哪些是真实的错误,哪些是配置问题。
二、初始化 TypeScript 环境:tsconfig.json 是核心
2.1 新建一个干净的 Node.js + TypeScript 项目
mkdir my-typescript-node-app
cd my-typescript-node-app
npm init -y
2.2 安装 TypeScript 和类型定义
npm install --save-dev typescript @types/node
@types/node 是 Node.js 的全局类型定义,没有它,你用的 process、console、path 等都会变成 any 类型,TypeScript 就失去意义了。
2.3 生成并配置 tsconfig.json
npx tsc --init
这会生成一个默认的 tsconfig.json,但默认配置对生产环境来说太宽松了。我们来改一改:
{
"compilerOptions": {
/* 基础配置 */
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
/* 严格类型检查 */
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
/* 模块解析 */
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* 类型检查 */
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
/* 高级 */
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
2.4 逐条解释为什么这么配
| 配置项 | 作用 | 为什么重要 |
|---|---|---|
target: "ES2020" |
编译后的 JS 目标版本 | Node.js 14+ 完全支持 ES2020,可以享用现代语法 |
module: "commonjs" |
使用 CommonJS 模块系统 | 与现有 Node.js 生态兼容,如果你用 ESM 可以改成 "module": "NodeNext" |
strict: true |
开启所有严格检查 | 这是 TypeScript 发挥作用的底线,关掉严格检查等于没用 TS |
noImplicitAny: true |
禁止隐式 any | 防止 let x = something 这种不声明类型的写法 |
strictNullChecks: true |
严格的 null/undefined 检查 | 防止 null 和 undefined 被当作正常值使用 |
declaration: true |
生成 .d.ts 类型声明文件 |
方便其他项目引用你的库,也方便 IDE 补全 |
sourceMap: true |
生成 source map | 生产环境报错时,能定位到源代码的行号,而不是编译后的代码 |
skipLibCheck: true |
跳过第三方库的类型检查 | 加速编译,避免因为某个库的类型定义错误而阻断编译 |
resolveJsonModule: true |
支持导入 JSON 文件 | 很多 Node.js 项目会直接 require('./config.json') |
三、项目目录结构:别搞得太复杂,也别太随意
一个健康的 Node.js + TypeScript 项目结构应该长这样:
my-typescript-node-app/
├── src/
│ ├── controllers/ # 控制器层
│ ├── services/ # 业务逻辑层
│ ├── middleware/ # 中间件
│ ├── routes/ # 路由定义
│ ├── types/ # 类型定义
│ ├── utils/ # 工具函数
│ ├── app.ts # 应用入口
│ └── index.ts # 服务器启动入口
├── dist/ # 编译输出目录
├── tests/ # 测试文件
├── .env # 环境变量
├── package.json
├── tsconfig.json
└── tsconfig.build.json # 生产环境专用配置
为什么要有 tsconfig.build.json?因为开发环境和生产环境的编译策略有时不一样。比如开发时你可能想要更快的热更新,生产时可能要优化打包体积。我们后面会讲到。
四、编写第一个 TypeScript 模块:从 Hello World 开始
4.1 定义类型
先不要写逻辑,先定义类型。这是 TypeScript 最强大的地方——先想清楚数据结构,再写代码。
// src/types/user.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
export type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdateUserInput = Partial<CreateUserInput>;
这里用 Omit 和 Partial 这两个工具类型,避免了重复定义。CreateUserInput 是创建用户时需要的字段(排除 id 和时间戳),UpdateUserInput 是更新用户时可能的字段(全部可选)。
4.2 编写 Service
// src/services/userService.ts
import { User, CreateUserInput, UpdateUserInput } from '../types/user';
class UserService {
private users: Map<string, User> = new Map();
async create(input: CreateUserInput): Promise<User> {
const id = this.generateId();
const now = new Date();
const user: User = {
id,
...input,
createdAt: now,
updatedAt: now,
};
this.users.set(id, user);
return user;
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async update(id: string, input: UpdateUserInput): Promise<User | null> {
const existing = this.users.get(id);
if (!existing) {
return null;
}
const updated: User = {
...existing,
...input,
updatedAt: new Date(),
};
this.users.set(id, updated);
return updated;
}
private generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
}
export const userService = new UserService();
注意几个细节:
User | null:明确标注返回可能是 null,调用方必须处理这种情况。?? null:使用空值合并运算符,而不是||,避免0、''等 falsy 值被错误处理。private方法:generateId是内部实现细节,不需要暴露出去。
4.3 编写 Controller
// src/controllers/userController.ts
import { Request, Response } from 'express';
import { userService } from '../services/userService';
export class UserController {
async create(req: Request, res: Response): Promise<void> {
try {
const user = await userService.create(req.body);
res.status(201).json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to create user' });
}
}
async findById(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const user = await userService.findById(id);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
}
async update(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const user = await userService.update(id, req.body);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
}
}
export const userController = new UserController();
这里用了 Express 的 Request 和 Response 类型,前提是安装了 @types/express:
npm install --save express
npm install --save-dev @types/express
4.4 编写路由
// src/routes/userRoutes.ts
import { Router } from 'express';
import { userController } from '../controllers/userController';
const router = Router();
router.post('/users', userController.create.bind(userController));
router.get('/users/:id', userController.findById.bind(userController));
router.patch('/users/:id', userController.update.bind(userController));
export default router;
注意 bind(userController):TypeScript 对方法调用有严格的 this 类型检查,直接传 userController.create 可能会导致 this 类型错误,所以显式 bind。
4.5 编写应用入口
// src/app.ts
import express, { Application } from 'express';
import userRoutes from './routes/userRoutes';
export const app: Application = express();
app.use(express.json());
app.use('/api', userRoutes);
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
4.6 编写服务器启动文件
// src/index.ts
import { app } from './app';
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
这里用 process.env.PORT ?? 3000,而不是 process.env.PORT || 3000,因为环境变量可能是空字符串 '',用 || 会错误地回退到默认值。
五、package.json 配置:让开发和生产流程顺畅
5.1 添加脚本
{
"name": "my-typescript-node-app",
"version": "1.0.0",
"description": "A Node.js project with TypeScript",
"main": "dist/index.js",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"type-check": "tsc --noEmit",
"lint": "eslint src --ext .ts",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"eslint": "^8.55.0",
"@typescript-eslint/parser": "^6.13.0",
"@typescript-eslint/eslint-plugin": "^6.13.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
}
}
5.2 脚本说明
| 脚本 | 作用 |
|---|---|
dev |
使用 ts-node-dev 启动开发服务器,支持热重载和类型推断 |
build |
编译 TypeScript 到 dist 目录 |
start |
启动生产环境服务器 |
type-check |
只做类型检查,不输出文件,用于 CI/CD 前置检查 |
lint |
使用 ESLint 检查代码风格 |
六、开发环境 vs 生产环境:分开配置
6.1 开发环境配置(tsconfig.json)
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
注意 baseUrl 和 paths:这允许你使用路径别名,比如 import { userService } from '@/services/userService',而不是 import { userService } from '../../services/userService'。这在项目变大时非常有用。
6.2 生产环境配置(tsconfig.build.json)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"declarationMap": false,
"removeComments": true
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}
生产环境不需要 source map 和 declaration 文件,去掉它们可以加快编译速度,减小输出体积。
6.3 构建命令区分
{
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:dev": "tsc -p tsconfig.json"
}
}
七、类型安全:如何写出真正有用的 TypeScript 代码
7.1 不要用 any
这是最大的陷阱。一旦你用 any,TypeScript 就退化成带语法糖的 JavaScript。
// ❌ 错误:使用 any
function getUser(id: any) {
return users.find(u => u.id === id);
}
// ✅ 正确:明确类型
function getUser(id: string): User | undefined {
return users.find(u => u.id === id);
}
7.2 使用 interface 还是 type?
这是一个经典问题。我的建议是:
- API 返回的数据结构:用
interface,因为接口可以合并(declaration merging),方便扩展。 - 复杂类型推导:用
type,因为类型别名支持联合类型、交叉类型、映射类型等高级特性。
// 用 interface 定义数据结构
interface User {
id: string;
name: string;
email: string;
}
// 用 type 定义复杂类型
type UserWithRole = User & { role: 'admin' | 'user' };
type MaybeUser = User | null;
type UserId = string;
7.3 处理可选属性和 null
// ❌ 错误:忽略可选属性
interface Config {
port?: number;
host?: string;
}
function connect(config: Config) {
// 这里 config.port 可能是 undefined
const port = config.port; // TypeScript 会报错
}
// ✅ 正确:使用默认值或空值合并
function connect(config: Config) {
const port = config.port ?? 3000;
const host = config.host ?? 'localhost';
}
7.4 使用泛型让代码更灵活
”`typescript
// 通用的响应类型
interface ApiResponse
// 使用泛型 function
