TypeScript + Node.js 后端工程化:Express / Nest.js 的类型安全实践

详解 TypeScript 在 Node.js 后端项目中的工程化实践:项目结构、tsconfig 配置、路径别名、nodemon + ts-node 开发、Prisma ORM 类型安全、Jest 测试配置、构建部署流程。

用 TypeScript 编写 Node.js 后端可以大幅提升可维护性和开发效率。静态类型能够在编译期捕获大量潜在错误,配合现代编辑器的光速级 IntelliSense,团队协作与代码重构的自信心显著增强。本文覆盖 TS + Node 的工程化全流程,从项目初始化到生产部署,力求提供一份可直接落地的实践指南。


一、项目初始化

推荐采用以下目录结构,将源码、测试、配置分离,方便后续扩展与维护:

my-api/
├── src/
│   ├── index.ts
│   ├── routes/
│   ├── controllers/
│   ├── services/
│   └── types/
├── tests/
├── dist/
├── prisma/
├── tsconfig.json
├── nodemon.json
└── package.json

使用以下命令初始化项目并安装核心依赖:

mkdir my-api && cd my-api
npm init -y
npm install -D typescript ts-node nodemon @types/node
npx tsc --init

tsconfig.json 是 TypeScript 项目的灵魂,建议采用如下配置,兼顾严格性与开发体验:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] },
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

其中 paths 与 baseUrl 配合使用,可实现 @/services/user 这般优雅的路径别名,彻底告别 ../../../ 地狱。strict: true 开启全部严格类型检查,declaration 选项则便于后续作为库发布时自动生成 .d.ts 声明文件。

二、Express + TS

Express 在 JavaScript 生态中拥有最为广泛的中间件资源,配合 TypeScript 后,路由与中间件的类型安全得以保障。

首先需要扩展 Express 的 Request 类型,以便在中间件中注入自定义属性(例如认证后的用户信息):

// src/types/express.d.ts
import { User } from './user';

declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}

接着构建类型安全的路由处理函数。对于异步路由,务必封装 asyncHandler 以避免未捕获的 Promise 异常导致进程崩溃:

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

export const asyncHandler = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};
// src/routes/user.route.ts
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';

interface User {
  id: number;
  name: string;
  email: string;
}

const router = Router();

router.get(
  '/users/:id',
  asyncHandler(async (req: Request<{ id: string }>, res: Response<User>) => {
    const user: User = {
      id: Number(req.params.id),
      name: 'Alice',
      email: 'alice@example.com',
    };
    res.json(user);
  })
);

export default router;

Request<{ id: string }> 为路由参数提供泛型约束,Response<User> 则约束了响应体的结构。当尝试返回不符合 User 接口的对象时,TypeScript 编译器会立即报错。

三、Nest.js(推荐企业级)

Nest.js 原生基于 TypeScript 设计,是 Node.js 的 Angular 式框架。它将应用拆分为模块(Module)、控制器(Controller)与服务(Service),依赖注入容器贯穿始终,天然适合大型团队与复杂业务。

初始化项目非常简单:

npm install -g @nestjs/cli
nest new my-project

Nest.js 的核心在于装饰器与依赖注入。以下示例展示了完整的用户模块,包括 DTO(数据传输对象)、控制器与服务三层分离:

// src/users/dto/create-user.dto.ts
export class CreateUserDto {
  readonly name: string;
  readonly email: string;
}

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

export interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable()
export class UsersService {
  private users: User[] = [];

  create(dto: CreateUserDto): User {
    const user = { id: Date.now(), ...dto };
    this.users.push(user);
    return user;
  }

  findById(id: string): User | undefined {
    return this.users.find((u) => u.id === Number(id));
  }
}

// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService, User } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() dto: CreateUserDto): Promise<User> {
    return this.usersService.create(dto);
  }

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<User | undefined> {
    return this.usersService.findById(id);
  }
}

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

@Injectable() 将 UsersService 注册到 Nest 的 IoC 容器,@Module 负责声明该模块的边界与导出。借助管道(Pipe)、守卫(Guard)和拦截器(Interceptor),还能在框架层面统一处理参数校验、权限校验与响应转换。

四、开发工具链

高效的开发工具链是工程化的基石。热重载方面,推荐使用 nodemon 配合 ts-node 的 transpileOnly 模式,以牺牲部分类型检查为代价换取更快的重启速度,类型问题交由 IDE 实时提示:

// nodemon.json
{
  "watch": ["src"],
  "ext": "ts",
  "ignore": ["src/**/*.spec.ts"],
  "exec": "ts-node --transpile-only src/index.ts"
}

在 package.json 中配置常用脚本:

{
  "scripts": {
    "dev": "nodemon",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

测试框架采用 Jest 搭配 ts-jest。为了让 Jest 识别路径别名,需要显式配置 moduleNameMapper:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
};

export default config;
// tests/user.service.spec.ts
import { UsersService } from '../src/users/users.service';

describe('UsersService', () => {
  let service: UsersService;

  beforeEach(() => {
    service = new UsersService();
  });

  it('should create a user', () => {
    const user = service.create({ name: 'Bob', email: 'bob@example.com' });
    expect(user.name).toBe('Bob');
  });
});

五、Prisma 类型安全 ORM

Prisma 是当前 Node.js 生态中类型安全体验最佳的 ORM。它通过声明式 Schema 定义数据模型,再自动生成包含完整 TypeScript 类型的客户端,彻底告别手写模型类型与 SQL 拼接。

首先在 prisma/schema.prisma 中定义模型:

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

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

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

model Post {
  id       Int     @id @default(autoincrement())
  title    String
  content  String?
  published Boolean @default(false)
  author   User    @relation(fields: [authorId], references: [id])
  authorId Int
}

执行迁移与客户端生成:

npx prisma migrate dev --name init
npx prisma generate

在业务代码中,PrismaClient 的所有查询方法均返回精确推断的类型:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
});

// 完全类型安全的查询
const userWithPosts = await prisma.user.findUnique({
  where: { id: 1 },
  include: { posts: true },
});
// userWithPosts 的类型自动推断为 User & { posts: Post[] }

// 类型安全的创建,字段缺失或类型不符将立即报错
const newUser = await prisma.user.create({
  data: {
    email: 'alice@example.com',
    name: 'Alice',
    posts: {
      create: [{ title: 'Hello Prisma', published: true }],
    },
  },
});

// 事务保证原子性
const [updatedUser, updatedPost] = await prisma.$transaction([
  prisma.user.update({ where: { id: 1 }, data: { name: 'Updated' } }),
  prisma.post.update({ where: { id: 1 }, data: { published: true } }),
]);

Prisma 的 include 与 select 选项会根据 Schema 关系自动扩展返回类型,这对于复杂的多表联查场景尤为宝贵,开发者无需再为联查结果手写冗长的接口定义。

六、Zod 运行时类型校验与 OpenAPI 生成

TypeScript 的静态类型只在编译期生效,运行时收到的请求数据仍然可能是恶意或畸形的。Zod 是一个以 TypeScript 优先的 Schema 校验库,通过 .parse() 或 .safeParse() 在运行时守护数据边界。

定义通用校验 Schema 并导出推断类型:

// src/schemas/user.schema.ts
import { z } from 'zod';

export const CreateUserSchema = z.object({
  name: z.string().min(1, '用户名不能为空'),
  email: z.string().email('邮箱格式不正确'),
  age: z.number().int().min(0).optional(),
});

export type CreateUserInput = z.infer<typeof CreateUserSchema>;

在 Express 中封装校验中间件:

// src/middlewares/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';

export const validate =
  (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        return res.status(400).json({
          message: '参数校验失败',
          errors: error.flatten().fieldErrors,
        });
      }
      next(error);
    }
  };

路由中直接复用:

import { Router } from 'express';
import { validate } from '../middlewares/validate';
import { CreateUserSchema } from '../schemas/user.schema';

const router = Router();

router.post('/users', validate(CreateUserSchema), (req, res) => {
  // req.body 已通过校验,可安全使用
  res.json({ success: true, data: req.body });
});

借助 @asteasolutions/zod-to-openapi,Zod Schema 可直接驱动 OpenAPI 文档生成:

// src/openapi/registry.ts
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

extendZodWithOpenApi(z);

export const registry = new OpenAPIRegistry();

export const UserSchema = z
  .object({
    id: z.number().openapi({ example: 1 }),
    name: z.string().openapi({ example: 'Alice' }),
    email: z.string().openapi({ example: 'alice@example.com' }),
  })
  .openapi('User');

registry.registerPath({
  method: 'get',
  path: '/users/{id}',
  request: { params: z.object({ id: z.string() }) },
  responses: {
    200: {
      description: '获取用户成功',
      content: { 'application/json': { schema: UserSchema } },
    },
  },
});

将生成的 OpenAPI 对象喂给 swagger-ui-express,即可在 /api-docs 路径获得交互式文档,前后端接口契约由此统一。

七、Monorepo 工程化:pnpm workspace + Turborepo + Changesets

当项目规模扩大,或者存在共享的公用库(如 @myapp/types、@myapp/utils)时,Monorepo 是更优的组织形式。pnpm 的 workspace 机制配合 Turborepo 的任务编排与远程缓存,可显著加速构建流程。

首先创建 pnpm-workspace.yaml:

packages:
  - 'packages/*'
  - 'apps/*'

目录结构示意:

my-repo/
├── packages/
│   ├── shared-types/
│   └── logger/
├── apps/
│   ├── api/
│   └── web/
├── turbo.json
└── package.json

turbo.json 中配置任务依赖与缓存策略:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

dependsOn: ["^build"] 表示当前包的 build 任务依赖上游 workspace 包的 build 先完成,Turborepo 会自动分析依赖拓扑并按正确顺序执行。构建产物通过远程缓存共享后,团队成员与 CI 环境只需下载缓存即可跳过重复编译。

使用 Changesets 管理版本号与 CHANGELOG:

pnpm add -D @changesets/cli
npx changeset init

开发者在修改包后执行 npx changeset 选择变更集,CI 中运行 changeset version 自动提升版本并生成 CHANGELOG,changeset publish 则负责批量发布到 npm 私有仓库。整个流程规范了多包版本演进,避免了手动修改版本号带来的遗漏与冲突。

八、ESLint + Prettier + Husky 代码质量保证

代码风格的统一不仅关乎阅读体验,更是减少不必要代码审阅噪音的关键。采用 ESLint Flat Config 新格式,可将所有配置收敛到单一文件:

// eslint.config.js
import js from '@eslint/js';
import tsEslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';

export default tsEslint.config(
  js.configs.recommended,
  ...tsEslint.configs.recommended,
  {
    rules: {
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/explicit-function-return-type': 'off',
      'no-console': 'warn',
    },
  },
  prettier
);

Prettier 专注于格式化,避免与 ESLint 规则冲突:

// prettier.config.js
export default {
  semi: true,
  singleQuote: true,
  trailingComma: 'es5',
  printWidth: 100,
  tabWidth: 2,
};

Husky 与 lint-staged 配合,在代码提交前自动执行格式修正与静态检查:

pnpm add -D husky lint-staged
npx husky init
// lint-staged.config.js
export default {
  '*.{ts,tsx,js,jsx}': ['eslint --fix', 'prettier --write'],
};
# .husky/pre-commit
npx lint-staged

提交前的钩子保证了进入仓库的每一行代码都符合规范,历史记录的整洁度由此得到长期维护。

九、统一错误处理与日志体系(自定义 ExceptionFilter + Pino/Winston)

在 Nest.js 中,自定义 ExceptionFilter 可以拦截应用内抛出的全部异常,输出标准化的错误响应:

// src/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';

    response.status(status).json({
      success: false,
      statusCode: status,
      message,
      timestamp: new Date().toISOString(),
    });
  }
}

在主模块中全局注册:

import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter,
    },
  ],
})
export class AppModule {}

日志方面,Pino 以其极低的性能开销成为 Node.js 高性能服务的首选。通过 pino-pretty 在开发环境输出可读格式,生产环境则直接输出结构化 JSON:

// src/logger.ts
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport:
    process.env.NODE_ENV !== 'production'
      ? { target: 'pino-pretty', options: { colorize: true } }
      : undefined,
  redact: ['req.headers.authorization', 'password'],
});

在请求上下文中注入 request-id,实现全链路追踪:

import { v4 as uuidv4 } from 'uuid';

app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || uuidv4();
  res.setHeader('x-request-id', req.id);
  logger.child({ requestId: req.id }).info({ req }, 'incoming request');
  next();
});

结构化日志结合 ELK 或 Loki 等日志收集系统,可以在分布式环境下快速定位问题根因。

十、Docker 多阶段构建与生产部署

多阶段构建(Multi-stage Build)是瘦身 Docker 镜像的黄金法则。首阶段基于完整 Node 镜像完成依赖安装与构建,二阶段仅复制构建产物与生产依赖,最终镜像体积可从数百 MB 压缩到百余 MB 以内。

# Dockerfile
# 阶段一:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY tsconfig.json ./
RUN npm ci
COPY . .
RUN npm run build

# 阶段二:生产运行
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
CMD ["node", "dist/index.js"]

.dockerignore 文件同样不可忽视:

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
.env
.env.local
.env.*.local
coverage
dist
tests

配合 [docker](/posts/docker/)-compose.yml 实现本地一键启动:

version: '3.8'
services:
  api:
    build: .
    ports:
      - '3000:3000'
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
      - LOG_LEVEL=info
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

生产部署时,推荐使用健康检查探针(如 Kubernetes 的 LivenessProbe 与 ReadinessProbe)配合上述 HEALTHCHECK 指令,确保容器在依赖就绪后才接受流量,异常时能被编排系统自动重启或摘除。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈