跳到主要内容

Node.js + TypeScript 开发实践

TypeScript 与 Node.js 结合是构建服务端应用的优秀选择。本章将介绍如何在 Node.js 项目中使用 TypeScript,包括项目配置、模块解析、常用工具和实践技巧。

为什么在 Node.js 中使用 TypeScript

Node.js 原生使用 JavaScript,但 TypeScript 能显著提升开发体验:

类型安全:在编译时捕获错误,而不是运行时

// JavaScript - 运行时错误
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.pice, 0); // 拼写错误:price 写成了 pice
}

// TypeScript - 编译时错误
interface Item {
name: string;
price: number;
}

function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.pice, 0);
// 错误:属性 'pice' 不存在。你的意思是 'price' 吗?
}

智能提示:IDE 能提供精确的代码补全

import fs from 'fs';

// 输入 fs. 后,IDE 会列出所有可用的方法和属性
fs.readFile('data.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});

代码导航:快速跳转到定义、查找引用

重构支持:安全地重命名变量、函数、类等

项目初始化

创建项目

# 创建项目目录
mkdir my-nodejs-project
cd my-nodejs-project

# 初始化 package.json
npm init -y

# 安装 TypeScript
npm install -D typescript

# 安装 Node.js 类型定义
npm install -D @types/node

初始化 TypeScript 配置

# 生成 tsconfig.json
npx tsc --init

这会生成一个带有默认配置的 tsconfig.json 文件。根据 Node.js 版本,你需要调整一些配置。

推荐的 tsconfig.json 配置

针对 Node.js 项目,以下是一个推荐的配置:

{
"compilerOptions": {
// 目标版本:根据你的 Node.js 版本选择
"target": "ES2022",

// 模块系统:Node.js 使用 CommonJS 或 ESM
"module": "NodeNext",

// 模块解析策略
"moduleResolution": "NodeNext",

// 输出目录
"outDir": "./dist",

// 源码目录
"rootDir": "./src",

// 严格模式(强烈推荐)
"strict": true,

// 启用所有严格类型检查选项
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,

// 额外检查
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,

// 模块互操作
"esModuleInterop": true,

// 跳过库检查
"skipLibCheck": true,

// 强制文件名大小写一致
"forceConsistentCasingInFileNames": true,

// 生成声明文件(库项目需要)
"declaration": true,

// 生成 source map
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

配置详解

  • target:编译后的 JavaScript 版本。Node.js 18+ 支持 ES2022,建议根据运行环境选择。
  • module:模块系统。NodeNext 是 TypeScript 4.7+ 引入的,会根据 package.json 中的 type 字段自动选择 CommonJS 或 ESM。
  • moduleResolution:模块解析策略。NodeNextmodule: NodeNext 配合使用,正确处理 Node.js 的模块解析规则。
  • strict:开启所有严格类型检查选项,是 TypeScript 提供的最强类型检查。

配置 package.json

package.json 中添加以下字段:

{
"name": "my-nodejs-project",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=18.0.0"
}
}

字段说明

  • main:指定项目入口文件,指向编译后的 JavaScript 文件。
  • types:指定类型声明文件入口,用于库项目。
  • scripts.dev:使用 tsx 工具进行开发时的热重载。

CommonJS 与 ESM

Node.js 支持两种模块系统:CommonJS 和 ES Modules。理解它们的区别对于配置 TypeScript 至关重要。

CommonJS 模式

package.json 中不设置 type 字段或设置为 "commonjs"

{
"name": "my-project",
"type": "commonjs"
}

tsconfig.json

{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node10"
}
}

代码风格

// src/index.ts
import fs from 'fs';
import path from 'path';

// 导出
module.exports = { hello };
exports.world = 'world';

ES Modules 模式

package.json 中设置 type"module"

{
"name": "my-project",
"type": "module"
}

tsconfig.json

{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}

代码风格

// src/index.ts
import fs from 'fs';
import path from 'path';

// 导出
export function hello(): void {
console.log('Hello, World!');
}

export const world = 'world';

重要区别

在 ESM 模式下,本地导入必须包含文件扩展名:

// ESM 模式下
import { helper } from './utils/helper.js'; // 必须写 .js 扩展名
import { config } from './config.js';

// CommonJS 模式下
import { helper } from './utils/helper'; // 可以省略扩展名
import { config } from './config';

NodeNext 模式

NodeNext 是 TypeScript 4.7+ 引入的智能模式,它会根据 package.json 中的 type 字段自动选择正确的模块系统:

{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}

解释:使用 NodeNext 是现代 Node.js + TypeScript 项目的推荐做法,因为它能正确处理各种模块导入场景。

开发工具

tsx:快速开发运行

tsx 是一个快速、零配置的 TypeScript 运行器,适合开发环境:

# 安装
npm install -D tsx

# 运行单个文件
npx tsx src/index.ts

# 监听模式(文件变化自动重启)
npx tsx watch src/index.ts

tsx 支持:

  • 直接运行 TypeScript 文件,无需预编译
  • 文件监听和热重载
  • ESM 和 CommonJS 模块
  • TypeScript 路径映射

ts-node:传统选择

ts-node 是一个 TypeScript 执行引擎:

# 安装
npm install -D ts-node

# 运行
npx ts-node src/index.ts

# ESM 模式需要额外配置
npx ts-node --esm src/index.ts

nodemon:自动重启

配合 nodemon 实现文件变化自动重启:

# 安装
npm install -D nodemon

# 运行
npx nodemon --exec tsx src/index.ts

配置文件 nodemon.json

{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "tsx src/index.ts"
}

构建和部署

构建脚本

{
"scripts": {
"build": "tsc",
"build:prod": "npm run clean && tsc",
"clean": "rm -rf dist",
"start": "node dist/index.js",
"start:prod": "NODE_ENV=production node dist/index.js"
}
}

使用 tsc-watch 监听构建

npm install -D tsc-watch
{
"scripts": {
"dev": "tsc-watch --onSuccess \"node dist/index.js\""
}
}

项目结构

基础项目结构

my-project/
├── src/
│ ├── index.ts # 入口文件
│ ├── app.ts # 应用主逻辑
│ ├── config/ # 配置
│ │ └── index.ts
│ ├── controllers/ # 控制器
│ │ └── userController.ts
│ ├── services/ # 业务逻辑
│ │ └── userService.ts
│ ├── models/ # 数据模型
│ │ └── user.ts
│ ├── routes/ # 路由
│ │ └── userRoutes.ts
│ ├── middleware/ # 中间件
│ │ └── auth.ts
│ └── utils/ # 工具函数
│ └── logger.ts
├── tests/ # 测试文件
│ └── user.test.ts
├── dist/ # 编译输出(自动生成)
├── node_modules/
├── package.json
├── tsconfig.json
└── README.md

路径映射

使用 baseUrlpaths 配置绝对路径导入:

{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@controllers/*": ["src/controllers/*"],
"@services/*": ["src/services/*"],
"@models/*": ["src/models/*"],
"@utils/*": ["src/utils/*"]
}
}
}

使用示例

// 不使用路径映射
import { UserService } from '../../../services/userService';
import { User } from '../../../models/user';
import { logger } from '../../../utils/logger';

// 使用路径映射
import { UserService } from '@/services/userService';
import { User } from '@/models/user';
import { logger } from '@/utils/logger';

运行时支持:路径映射只在编译时有效,运行时需要使用 tsc-aliasmodule-alias 等工具转换路径。

安装 tsc-alias

npm install -D tsc-alias

更新构建脚本:

{
"scripts": {
"build": "tsc && tsc-alias"
}
}

Express 集成

Express 是 Node.js 最流行的 Web 框架,与 TypeScript 结合效果更佳。

安装依赖

npm install express
npm install -D @types/express

基础应用

// src/index.ts
import express, { Request, Response, NextFunction, Application } from 'express';

const app: Application = express();
const PORT = process.env.PORT || 3000;

// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 路由
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Hello, TypeScript!' });
});

// 健康检查
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 错误处理中间件
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});

// 启动服务器
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

类型化请求和响应

定义请求和响应的类型:

// src/types/express.ts
import { Request } from 'express';

// 扩展 Request 类型
export interface AuthRequest extends Request {
user?: {
id: string;
email: string;
role: 'admin' | 'user';
};
}

// 泛型请求体类型
export interface RequestWithBody<T> extends Request {
body: T;
}
// src/middleware/auth.ts
import { Response, NextFunction } from 'express';
import { AuthRequest } from '@/types/express';

export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
const token = req.headers.authorization?.split(' ')[1];

if (!token) {
res.status(401).json({ error: 'No token provided' });
return;
}

try {
// 验证 token(示例)
const user = verifyToken(token);
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}

类型化路由

// src/types/api.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}

export interface CreateUserDTO {
name: string;
email: string;
password: string;
}

export interface UpdateUserDTO {
name?: string;
email?: string;
}
// src/controllers/userController.ts
import { Response, NextFunction } from 'express';
import { AuthRequest } from '@/types/express';
import { User, CreateUserDTO, UpdateUserDTO } from '@/types/api';
import { UserService } from '@/services/userService';

export class UserController {
constructor(private userService: UserService) {}

// 获取所有用户
async getAll(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const users: User[] = await this.userService.findAll();
res.json(users);
} catch (error) {
next(error);
}
}

// 根据 ID 获取用户
async getById(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const user: User | null = await this.userService.findById(id);

if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}

res.json(user);
} catch (error) {
next(error);
}
}

// 创建用户
async create(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const data: CreateUserDTO = req.body;
const user: User = await this.userService.create(data);
res.status(201).json(user);
} catch (error) {
next(error);
}
}

// 更新用户
async update(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const data: UpdateUserDTO = req.body;
const user: User = await this.userService.update(id, data);
res.json(user);
} catch (error) {
next(error);
}
}

// 删除用户
async delete(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
await this.userService.delete(id);
res.status(204).send();
} catch (error) {
next(error);
}
}
}

错误处理

定义自定义错误类:

// src/utils/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

export class NotFoundError extends AppError {
constructor(message: string = 'Resource not found') {
super(message, 404);
}
}

export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}

export class UnauthorizedError extends AppError {
constructor(message: string = 'Unauthorized') {
super(message, 401);
}
}

export class ForbiddenError extends AppError {
constructor(message: string = 'Forbidden') {
super(message, 403);
}
}

全局错误处理中间件:

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '@/utils/errors';

export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error('Error:', err);

if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
status: 'error'
});
return;
}

// 处理其他类型的错误
res.status(500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
status: 'error'
});
}

数据库集成

使用 Prisma ORM

Prisma 是一个现代的数据库 ORM,原生支持 TypeScript:

# 安装 Prisma
npm install prisma
npm install -D @prisma/client

# 初始化
npx prisma init

schema.prisma

generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id String @id @default(uuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Post {
id String @id @default(uuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

使用 Prisma Client

// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default prisma;
// src/services/userService.ts
import prisma from '@/lib/prisma';
import { User, CreateUserDTO, UpdateUserDTO } from '@/types/api';

export class UserService {
async findAll(): Promise<User[]> {
return prisma.user.findMany({
include: { posts: true }
});
}

async findById(id: string): Promise<User | null> {
return prisma.user.findUnique({
where: { id },
include: { posts: true }
});
}

async create(data: CreateUserDTO): Promise<User> {
return prisma.user.create({
data
});
}

async update(id: string, data: UpdateUserDTO): Promise<User> {
return prisma.user.update({
where: { id },
data
});
}

async delete(id: string): Promise<void> {
await prisma.user.delete({
where: { id }
});
}
}

环境变量

使用 dotenv

npm install dotenv
// src/config/index.ts
import dotenv from 'dotenv';
import path from 'path';

// 加载环境变量
dotenv.config({ path: path.resolve(__dirname, '../../.env') });

interface Config {
nodeEnv: string;
port: number;
database: {
host: string;
port: number;
name: string;
user: string;
password: string;
};
jwt: {
secret: string;
expiresIn: string;
};
}

function getEnvVar(key: string, defaultValue?: string): string {
const value = process.env[key] ?? defaultValue;
if (value === undefined) {
throw new Error(`Environment variable ${key} is required`);
}
return value;
}

function getEnvNumber(key: string, defaultValue?: number): number {
const stringValue = getEnvVar(key, defaultValue?.toString());
const value = parseInt(stringValue, 10);
if (isNaN(value)) {
throw new Error(`Environment variable ${key} must be a number`);
}
return value;
}

export const config: Config = {
nodeEnv: getEnvVar('NODE_ENV', 'development'),
port: getEnvNumber('PORT', 3000),
database: {
host: getEnvVar('DB_HOST', 'localhost'),
port: getEnvNumber('DB_PORT', 5432),
name: getEnvVar('DB_NAME'),
user: getEnvVar('DB_USER'),
password: getEnvVar('DB_PASSWORD')
},
jwt: {
secret: getEnvVar('JWT_SECRET'),
expiresIn: getEnvVar('JWT_EXPIRES_IN', '7d')
}
};

类型化环境变量

使用 @typegeist/env 或自定义类型守卫:

// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
PORT?: string;
DATABASE_URL: string;
JWT_SECRET: string;
JWT_EXPIRES_IN?: string;
}
}
}

export {};

日志系统

使用 winstonpino 构建日志系统:

npm install winston
// src/utils/logger.ts
import winston from 'winston';

const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.printf(({ level, message, timestamp, stack }) => {
if (stack) {
return `${timestamp} [${level}]: ${message}\n${stack}`;
}
return `${timestamp} [${level}]: ${message}`;
})
);

const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: logFormat,
transports: [
// 控制台输出
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
logFormat
)
}),
// 错误日志文件
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
// 所有日志文件
new winston.transports.File({
filename: 'logs/combined.log'
})
]
});

export default logger;

测试

使用 Jest

npm install -D jest ts-jest @types/jest

jest.config.js

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts'
],
coverageDirectory: 'coverage'
};

测试示例

// tests/userService.test.ts
import { UserService } from '@/services/userService';
import prisma from '@/lib/prisma';

// Mock Prisma
jest.mock('@/lib/prisma');

describe('UserService', () => {
let userService: UserService;

beforeEach(() => {
userService = new UserService();
jest.clearAllMocks();
});

describe('findById', () => {
it('should return user when found', async () => {
const mockUser = {
id: '1',
email: '[email protected]',
name: 'Test User',
createdAt: new Date(),
updatedAt: new Date()
};

(prisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser);

const result = await userService.findById('1');

expect(result).toEqual(mockUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
include: { posts: true }
});
});

it('should return null when user not found', async () => {
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);

const result = await userService.findById('999');

expect(result).toBeNull();
});
});

describe('create', () => {
it('should create and return new user', async () => {
const createData = {
email: '[email protected]',
name: 'New User',
password: 'password123'
};

const mockCreatedUser = {
id: '1',
...createData,
createdAt: new Date(),
updatedAt: new Date()
};

(prisma.user.create as jest.Mock).mockResolvedValue(mockCreatedUser);

const result = await userService.create(createData);

expect(result).toEqual(mockCreatedUser);
});
});
});

使用 Vitest

Vitest 是一个更快的测试框架,与 Vite 共享配置:

npm install -D vitest

vitest.config.ts

import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'tests/']
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});

最佳实践

项目结构原则

  1. 按功能模块组织:将相关文件放在一起
  2. 单一职责:每个文件只做一件事
  3. 依赖注入:便于测试和解耦
  4. 配置外置:使用环境变量管理配置

错误处理策略

// 使用 Result 模式
type Result<T, E = Error> = Success<T> | Failure<E>;

interface Success<T> {
ok: true;
value: T;
}

interface Failure<E> {
ok: false;
error: E;
}

function success<T>(value: T): Success<T> {
return { ok: true, value };
}

function failure<E>(error: E): Failure<E> {
return { ok: false, error };
}

// 使用示例
async function findUser(id: string): Promise<Result<User, 'NOT_FOUND' | 'DATABASE_ERROR'>> {
try {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return failure('NOT_FOUND');
}
return success(user);
} catch (error) {
return failure('DATABASE_ERROR');
}
}

// 调用方
const result = await findUser('1');
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}

代码组织示例

// src/services/userService.ts
import prisma from '@/lib/prisma';
import { User, CreateUserDTO, UpdateUserDTO } from '@/types/api';
import { NotFoundError, ValidationError } from '@/utils/errors';
import logger from '@/utils/logger';

export class UserService {
/**
* 获取所有用户
*/
async findAll(): Promise<User[]> {
logger.debug('Fetching all users');
const users = await prisma.user.findMany({
include: { posts: true }
});
logger.info(`Found ${users.length} users`);
return users;
}

/**
* 根据 ID 获取用户
* @param id 用户 ID
* @throws NotFoundError 如果用户不存在
*/
async findById(id: string): Promise<User> {
const user = await prisma.user.findUnique({
where: { id },
include: { posts: true }
});

if (!user) {
throw new NotFoundError(`User with id ${id} not found`);
}

return user;
}

/**
* 创建新用户
* @param data 用户数据
* @throws ValidationError 如果邮箱已存在
*/
async create(data: CreateUserDTO): Promise<User> {
// 检查邮箱是否已存在
const existing = await prisma.user.findUnique({
where: { email: data.email }
});

if (existing) {
throw new ValidationError('Email already exists');
}

const user = await prisma.user.create({
data: {
...data,
password: await this.hashPassword(data.password)
}
});

logger.info(`Created user: ${user.id}`);
return user;
}

private async hashPassword(password: string): Promise<string> {
const bcrypt = await import('bcrypt');
return bcrypt.hash(password, 10);
}
}

小结

本章介绍了在 Node.js 项目中使用 TypeScript 的核心知识:

  • 项目配置:tsconfig.json 的关键配置选项
  • 模块系统:CommonJS 和 ES Modules 的区别与选择
  • 开发工具:tsx、ts-node、nodemon 等工具的使用
  • Express 集成:类型安全的 Web 应用开发
  • 数据库集成:使用 Prisma ORM
  • 环境变量:类型化的配置管理
  • 测试:Jest 和 Vitest 测试框架的使用
  • 最佳实践:项目结构、错误处理、代码组织

TypeScript 能显著提升 Node.js 项目的开发效率和代码质量,是现代 Node.js 开发的推荐选择。

参考资料