TypeScript Node 工程化实践

Node.js + TypeScript 工程化最佳实践:类型安全、装饰器模式、Clean Architecture、模块系统、构建配置与 Docker 多阶段构建。

1. TypeScript 类型系统

TypeScript 的类型系统是其区别于普通 JavaScript 的核心竞争力。在 Node.js 后端开发中,充分利用类型系统可以在编译阶段捕获大量潜在错误,减少运行时异常,同时为 IDE 提供精准的自动补全与重构能力。本节从实战角度梳理后端开发中最常用的高级类型工具。

1.1 关键类型工具

接口(interface)与类型别名(type)是定义数据结构的基础。接口支持声明合并,更适合描述对象形状;类型别名则更灵活,可表达联合类型、交叉类型与元组。在大型项目中,通常用接口定义实体,用类型别名定义 DTO 与 API 响应变体。

泛型(Generic)使得函数与类可以在不牺牲类型安全的前提下处理多种数据类型。条件类型(Conditional Type)与映射类型(Mapped Type)则是类型层面的逻辑运算,能够实现自动化类型转换,例如从数据库模型生成可选更新字段。

模板字面量类型(Template Literal Type)在构造类型安全的枚举路径时非常有用,比如 RESTful API 的路由参数。infer 关键字则用于在条件类型中提取子类型,是实现 ReturnType、Parameters 等内置工具类型的基石。

品牌类型(Branded Type)是一种名义类型系统模拟方案,通过在结构相同的基础类型上附加不可见的标记属性,防止不同业务语义 ID 的意外混用。

// 接口与类型别名
interface User {
    id: string;
    name: string;
    email: string;
    role: 'admin' | 'user' | 'guest';
}

type UserInput = Omit<User, 'id'>;
type UserUpdate = Partial<User>;
type UserResponse = Pick<User, 'id' | 'name'>;

// 泛型函数
async function fetchApi<T>(url: string): Promise<T> {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json() as Promise<T>;
}

const user = await fetchApi<User>('/api/users/1');

// 条件类型
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>;  // true
type B = IsString<123>;      // false

// 映射类型
type ReadonlyUser = { readonly [K in keyof User]: User[K] };
type NullableUser = { [K in keyof User]: User[K] | null };

// 模板字面量类型
type ApiPath = `/api/users/${string}` | `/api/orders/${number}`;
const validPath: ApiPath = '/api/users/42';

// 使用 infer 提取 Promise 内部类型
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type UserType = UnwrapPromise<Promise<User>>; // User

// 品牌类型:防止 UserId 与 OrderId 混用
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function findUser(id: UserId): User {
    // 即使都是 string,也不能传入 OrderId
    return { id: id as unknown as string, name: 'Alice', email: 'a@x.com', role: 'user' } as User;
}

1.2 严格模式配置

tsconfig.json 是 TypeScript 项目的灵魂配置。启用 strict: true 会同时开启 noImplicitAny、strictNullChecks、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis 与 alwaysStrict。对于后端服务,强烈建议开启严格模式,因为 null 与 undefined 引发的异常在生产环境中往往是致命的。moduleResolution 设为 NodeNext 与 module 对齐,确保 ESM 与 CommonJS 的互操作行为与 Node.js 原生保持一致。

{
    "compilerOptions": {
        "target": "ES2022",
        "module": "NodeNext",
        "moduleResolution": "NodeNext",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "resolveJsonModule": true,
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true,
        "outDir": "./dist",
        "rootDir": "./src",
        "noUncheckedIndexedAccess": true
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "dist"]
}

2. Clean Architecture

Clean Architecture 由 Robert C. Martin 提出,其核心思想是依赖关系必须始终向内指向核心业务逻辑。外层的基础设施细节(数据库、Web 框架、第三方 SDK)可以轻易替换,而内核的实体与业务规则保持纯净,不依赖任何外部库。这种分层架构在 TypeScript 项目中通过目录强制约定与接口隔离原则得以落地。

2.1 目录结构与依赖规则

四层结构是 Clean Architecture 在 Node.js 中的常见落地形式。domain 层存放实体与仓库接口,application 层编排用例,infrastructure 层实现具体技术细节,main.ts 作为组合根(Composition Root)完成依赖装配。任何层的代码都不允许导入更外层的模块,这一规则可通过 ESLint 的 import/no-restricted-paths 规则强制执行。

src/
├── domain/              # 核心业务逻辑(不依赖外部)
│   ├── entities/        # 实体:包含业务规则与校验
│   └── repositories/    # 仓库接口:定义数据访问契约
│
├── application/         # 应用层
│   ├── services/        # 业务服务 / 用例
│   ├── dto/             # 数据传输对象
│   └── ports/           # 输入/输出端口接口
│
├── infrastructure/      # 基础设施
│   ├── db/              # 数据库实现(Prisma / TypeORM / Knex)
│   ├── http/            # 路由、控制器、中间件
│   ├── cache/           # Redis / Memcached 实现
│   └── container.ts     # 依赖注入容器
│
└── main.ts              # 入口,组装依赖并启动服务

2.2 接口实现与依赖注入

通过面向接口编程,应用层只依赖 domain 中定义的抽象接口,而具体实现由基础设施层提供。这种模式使得单元测试极为便捷——只需为仓库接口提供一个内存实现即可隔离测试业务逻辑。依赖注入容器负责解析接口与实现的映射关系,避免在业务代码中出现 new 关键字导致的硬编码依赖。

// domain/entities/user.entity.ts
export class User {
    constructor(
        public readonly id: string,
        public name: string,
        public email: string,
        public role: 'admin' | 'user' | 'guest'
    ) {
        if (!email.includes('@')) throw new Error('Invalid email');
    }

    toPrisma() {
        return { id: this.id, name: this.name, email: this.email, role: this.role };
    }
}

// domain/repositories/user.repository.interface.ts
export interface IUserRepository {
    findById(id: string): Promise<User | null>;
    save(user: User): Promise<void>;
    delete(id: string): Promise<void>;
}

// application/services/create-user.usecase.ts
export class CreateUserUseCase {
    constructor(private userRepo: IUserRepository) {}

    async execute(input: { name: string; email: string }): Promise<User> {
        const id = crypto.randomUUID();
        const user = new User(id, input.name, input.email, 'user');
        await this.userRepo.save(user);
        return user;
    }
}

// infrastructure/db/prisma-user.repository.ts
export class PrismaUserRepository implements IUserRepository {
    constructor(private prisma: PrismaClient) {}

    async findById(id: string): Promise<User | null> {
        const user = await this.prisma.user.findUnique({ where: { id } });
        return user ? new User(user.id, user.name, user.email, user.role as User['role']) : null;
    }

    async save(user: User): Promise<void> {
        await this.prisma.user.upsert({
            where: { id: user.id },
            update: user.toPrisma(),
            create: user.toPrisma()
        });
    }

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

// infrastructure/http/user.controller.ts
export class UserController {
    constructor(private createUser: CreateUserUseCase) {}

    async create(req: Request, res: Response) {
        const user = await this.createUser.execute(req.body);
        res.status(201).json(user);
    }
}

// main.ts:组合根
const prisma = new PrismaClient();
const userRepo = new PrismaUserRepository(prisma);
const createUserUseCase = new CreateUserUseCase(userRepo);
const userController = new UserController(createUserUseCase);
// 注册路由并启动服务器...

3. Docker 多阶段构建

Docker 多阶段构建(Multi-stage Build)能够在单一 Dockerfile 中定义多个构建阶段,每个阶段拥有独立的文件系统上下文。最终镜像仅保留生产运行所必需的最小文件集,从而显著降低攻击面与镜像体积。对于 TypeScript 项目,典型的两阶段构建分别为编译阶段(builder)与运行阶段(production)。

3.1 多阶段 Dockerfile 设计

在 builder 阶段,我们安装所有依赖(包括 devDependencies),挂载源码与配置,执行 tsc 编译。在 production 阶段,仅复制 package*.json 并执行 npm ci --only=production,这将跳过 TypeScript、测试框架等开发依赖的安装,最终镜像大小可压缩 60% 以上。使用 node:20-alpine 作为基础镜像进一步减少体积,但需注意 Alpine 使用 musl libc,若项目依赖包含原生 C++ 扩展,应改用 node:20-slim。USER node 指令确保容器不以 root 身份运行进程,降低提权风险。健康检查(HEALTHCHECK)通过定期向应用端点发送请求,让 Docker 或编排系统能够自动重启无响应容器。

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

# 生产阶段
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production

# 安全:创建非 root 用户
RUN addgroup -g 1001 -S nodejs && adduser -S node -u 1001

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist

USER node
EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD node -e "require('http').get('http://localhost:3000/health', (r) => r.statusCode === 204 ? process.exit(0) : process.exit(1))"

CMD ["node", "dist/main.js"]

3.2 构建上下文优化

.dockerignore 文件与 .gitignore 类似,用于排除不需要发送至 Docker 守护进程的文件。合理配置可显著加快构建速度并减少缓存失效。应排除源码中的测试文件、本地环境配置与日志输出,同时保留 package-lock.json 以确保依赖安装的确定性复现。

node_modules
npm-debug.log
dist
.env
.env.local
.git
.vscode
coverage
*.test.ts
*.spec.ts

4. Error Handling 最佳实践

Node.js 的异步特性使得错误处理比同步代码更加复杂。未经捕获的 Promise 异常或抛出的 Error 对象一旦冒泡至进程级别,可能导致服务崩溃。工程化的错误处理体系应区分预期异常(如参数校验失败)与意外异常(如数据库连接断开),并以一致的 JSON 结构返回给调用方,便于前端与监控系统解析。

4.1 自定义错误类层级

通过继承原生 Error 类构建层级化的自定义错误体系,可在不依赖字符串匹配的前提下精确判断异常类型。基类 AppError 包含 HTTP 状态码、错误码与是否可暴露给客户端的标识。子类分别对应不同的业务异常场景,例如资源不存在、权限不足、数据冲突等。

export class AppError extends Error {
    constructor(
        message: string,
        public readonly code: string,
        public readonly statusCode: number,
        public readonly isOperational: boolean = true
    ) {
        super(message);
        Object.setPrototypeOf(this, AppError.prototype);
        Error.captureStackTrace(this, this.constructor);
    }
}

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

export class NotFoundError extends AppError {
    constructor(resource: string, id: string) {
        super(`${resource} with id ${id} not found`, 'NOT_FOUND', 404);
    }
}

export class ConflictError extends AppError {
    constructor(message: string) {
        super(message, 'CONFLICT', 409);
    }
}

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

4.2 全局异常处理中间件

在 Express 或 Fastify 中注册全局异常处理中间件,作为请求处理管道的最后一道防线。该中间件负责统一格式化错误响应:对于可预期的业务异常,返回包含错误码的结构化 JSON;对于不可预期的系统异常,则记录详细堆栈并返回模糊的 Internal Server Error,避免暴露内部实现细节。

import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app.error';

export function errorHandler(
    err: Error,
    _req: Request,
    res: Response,
    _next: NextFunction
): void {
    if (err instanceof AppError) {
        res.status(err.statusCode).json({
            success: false,
            code: err.code,
            message: err.isOperational ? err.message : 'Internal Server Error'
        });
        return;
    }

    // 未预料的异常:记录后返回 500
    console.error('Unexpected error:', err);
    res.status(500).json({
        success: false,
        code: 'INTERNAL_ERROR',
        message: 'Internal Server Error'
    });
}

4.3 Zod 验证错误转换

Zod 是 TypeScript 优先的 Schema 验证库,能够在运行时对传入数据进行类型检查。当验证失败时,Zod 抛出 ZodError,其中包含详细的字段级失败信息。为了避免将 Zod 的内部数据结构直接暴露给 API 消费者,应在控制器层捕获 ZodError 并将其转换为统一的 ValidationError。

import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
import { ValidationError } from '../errors/app.error';

const CreateUserSchema = z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    age: z.number().int().min(18).optional()
});

export function validateBody<T extends z.ZodTypeAny>(schema: T) {
    return (req: Request, _res: Response, next: NextFunction) => {
        const result = schema.safeParse(req.body);
        if (!result.success) {
            const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
            return next(new ValidationError(issues));
        }
        req.body = result.data;
        next();
    };
}

// 路由使用
// router.post('/users', validateBody(CreateUserSchema), userController.create);

5. 日志体系:Pino 结构化日志与全链路追踪

日志是后端系统可观测性的三大支柱之一(其余为指标与追踪)。相比于 console.log,结构化日志以 JSON 形式输出,便于 Logstash、Fluentd、Datadog 等日志收集器解析与索引。Pino 是 Node.js 生态中性能最优的日志库之一,其核心理念是最小化日志序列化开销,在开发环境提供可读格式,在生产环境输出紧凑 JSON。

5.1 Pino 结构化日志配置

通过 pino 与 pino-pretty 的配合,可以实现环境自适应的日志格式。生产环境启用 redact 配置自动脱敏敏感字段(如密码、Token),防止日志泄露导致的安全隐患。日志级别应支持通过环境变量动态调整,便于排查问题时临时开启 debug 输出。

import pino from 'pino';

const isDev = process.env.NODE_ENV !== 'production';

export const logger = pino({
    level: process.env.LOG_LEVEL || 'info',
    redact: {
        paths: ['password', '*.password', 'token', 'authorization'],
        remove: true
    },
    transport: isDev
        ? {
              target: 'pino-pretty',
              options: {
                  colorize: true,
                  translateTime: 'SYS:standard',
                  ignore: 'pid,hostname'
              }
          }
        : undefined
});

// 使用示例
logger.info({ userId: 'u-123' }, 'User login succeeded');
logger.error({ err: new Error('DB timeout') }, 'Database query failed');

5.2 request-id 全链路追踪

在微服务与异步事件驱动的架构中,单次用户请求可能跨越多个服务、数据库与消息队列。没有统一标识的日志就像散落的拼图,难以串联。通过在请求入口处生成 request-id 并将其注入 AsyncLocalStorage,后续的日志输出、数据库查询与 HTTP 调用均可自动携带该标识,实现全链路追踪。

import { AsyncLocalStorage } from 'async_hooks';
import { randomUUID } from 'crypto';
import { Request, Response, NextFunction } from 'express';
import { logger } from './logger';

export const asyncLocalStorage = new AsyncLocalStorage<{ requestId: string }>();

export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
    const requestId = (req.headers['x-request-id'] as string) || randomUUID();
    res.setHeader('x-request-id', requestId);

    asyncLocalStorage.run({ requestId }, () => {
        logger.info({ method: req.method, path: req.path, requestId }, 'Incoming request');
        next();
    });
}

// 获取当前上下文的子 logger
export function getContextLogger() {
    const store = asyncLocalStorage.getStore();
    return store ? logger.child({ requestId: store.requestId }) : logger;
}

// 服务层使用
export async function fetchUser(id: string) {
    const log = getContextLogger();
    log.debug({ userId: id }, 'Fetching user from database');
    // ... database call
}

6. 测试策略:Vitest 单元测试与 Supertest 集成测试

测试是工程化体系中保障代码质量的最后一道防线,但并非唯一防线。合理的测试策略应当呈金字塔结构:底层是大量快速、廉价的单元测试,中层是覆盖关键路径的集成测试,顶层是少量的端到端测试。Vitest 作为 Vite 生态的测试运行器,对 TypeScript 提供原生支持(无需 ts-jest),测试启动速度比 Jest 快一个数量级。

6.1 Vitest 单元测试配置

单元测试的核心原则是隔离性:被测模块的依赖应当全部替换为测试替身(test double)。通过 vi.mock 自动模拟模块导入,或通过 IDE Repository 注入内存实现,可以确保单元测试不触及真实数据库或外部 API,从而保持毫秒级执行速度。覆盖率阈值通过 coverage.thresholds 强制执行,防止新代码未经测试便合入主干。

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
    test: {
        globals: true,
        environment: 'node',
        coverage: {
            provider: 'v8',
            reporter: ['text', 'json', 'html'],
            thresholds: {
                lines: 80,
                functions: 80,
                branches: 75,
                statements: 80
            }
        }
    }
});

6.2 测试替身与用例测试

// tests/unit/create-user.usecase.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { CreateUserUseCase } from '../../src/application/services/create-user.usecase';
import { IUserRepository } from '../../src/domain/repositories/user.repository.interface';

const mockRepo: IUserRepository = {
    findById: vi.fn(),
    save: vi.fn().mockResolvedValue(undefined),
    delete: vi.fn()
};

describe('CreateUserUseCase', () => {
    it('should create a user with generated id', async () => {
        const useCase = new CreateUserUseCase(mockRepo);
        const user = await useCase.execute({ name: 'Alice', email: 'alice@x.com' });

        expect(user.name).toBe('Alice');
        expect(user.email).toBe('alice@x.com');
        expect(user.role).toBe('user');
        expect(mockRepo.save).toHaveBeenCalledTimes(1);
    });

    it('should throw for invalid email', async () => {
        const useCase = new CreateUserUseCase(mockRepo);
        await expect(
            useCase.execute({ name: 'Bob', email: 'not-an-email' })
        ).rejects.toThrow('Invalid email');
    });
});

6.3 Supertest 集成测试

集成测试验证多个组件协同工作的正确性,通常以真实 HTTP 请求的形式驱动应用。Supertest 能够挂载 Express/Fastify 应用实例并发送请求,无需真正监听网络端口。集成测试的数据库状态应通过 beforeEach / afterEach 进行回滚或重置,确保测试之间互不干扰。

// tests/integration/user.routes.spec.ts
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createApp } from '../../src/main';

const app = createApp();

describe('POST /users', () => {
    it('should create a user and return 201', async () => {
        const res = await request(app)
            .post('/users')
            .send({ name: 'Charlie', email: 'charlie@x.com' })
            .set('Accept', 'application/json');

        expect(res.status).toBe(201);
        expect(res.body).toHaveProperty('id');
        expect(res.body.name).toBe('Charlie');
    });

    it('should return 400 for invalid payload', async () => {
        const res = await request(app)
            .post('/users')
            .send({ name: '', email: 'bad' });

        expect(res.status).toBe(400);
        expect(res.body.code).toBe('VALIDATION_ERROR');
    });
});

7. 配置管理:环境变量与运行时验证

现代 Node.js 应用通常部署在多个环境(开发、测试、预发布、生产),每个环境的配置项(数据库连接串、API 密钥、日志级别)各不相同。将配置硬编码在源码中是不可接受的工程实践。将配置完全依赖环境变量虽然灵活,但缺少类型安全与运行时校验,一旦某个必填变量缺失,问题只能在运行时暴露。

7.1 分层加载与 dotenv

dotenv 允许从 .env 文件加载环境变量,但不应将其提交至版本控制。通过分层加载策略,.env 提供默认值,.env.local 提供本地覆盖,而生产环境则完全依赖平台注入的系统环境变量。加载顺序至关重要:本地覆盖文件应当在最后加载,以取得最高优先级。

import { config } from 'dotenv';
import { resolve } from 'path';

const env = process.env.NODE_ENV || 'development';
const envFiles = ['.env', `.env.${env}`, '.env.local'];

for (const file of envFiles) {
    config({ path: resolve(process.cwd(), file), override: true });
}

7.2 运行时配置验证

env-var 与 Zod 的组合能够在应用启动时一次性验证所有环境变量,失败即崩溃(fail fast),避免携带无效配置运行。定义一个集中式的 Config 对象,将原始字符串转换为适当的类型(数字、布尔值、枚举、URL)。其他业务模块只导入 config 对象,不再直接访问 process.env。

import { z } from 'zod';

const configSchema = z.object({
    NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
    PORT: z.string().transform(Number).default('3000'),
    DATABASE_URL: z.string().url(),
    LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
    JWT_SECRET: z.string().min(32),
    REDIS_URL: z.string().url().optional()
});

const parsed = configSchema.safeParse(process.env);

if (!parsed.success) {
    console.error('Invalid environment variables:', parsed.error.format());
    process.exit(1);
}

export const config = parsed.data;

// 使用
// import { config } from './config';
// const port = config.PORT;

8. TypeScript ESM 与 CommonJS 互操作

Node.js 14 起正式支持 ESM, TypeScript 4.7 引入 .mts / .cts 扩展名以明确指定模块格式。然而,npm 生态中仍有大量包仅提供 CommonJS 导出,混合格式引发的 ERR_REQUIRE_ESM 与 Can't use import statement 错误是 TS Node 项目中最令人头疼的构建问题之一。

8.1 模块格式与扩展名语义

TypeScript 编译后的文件扩展名由源文件扩展名与 tsconfig 配置共同决定:

源文件扩展名module 目标输出扩展名语义
.tsCommonJS.jsCJS(当 type: commonjs)
.tsESM.jsESM(当 type: module)
.mts任意.mjs强制 ESM
.cts任意.cjs强制 CJS

在 package.json 中设置 "type": "module" 后,所有 .js 与 .ts 文件默认被视为 ESM,而 .cjs 与 .cts 则强制为 CommonJS。建议新项目直接采用 ESM,仅在迁移遗留工具脚本或依赖 CJS-only 包的特殊场景下使用 .cts 文件。

{
    "name": "my-service",
    "type": "module",
    "main": "./dist/main.js",
    "exports": {
        ".": {
            "import": "./dist/main.js",
            "require": "./dist/main.cjs"
        }
    }
}

8.2 混合格式项目最佳实践

当 ESM 项目需要引入仅支持 require 的 CJS 包时,Node.js 允许 ESM 使用 createRequire 合成模块导入。反之,CJS 通过动态 import() 加载 ESM 模块。TypeScript 编译选项 esModuleInterop 与 allowSyntheticDefaultImports 会减少大部分导入语法差异,但在运行时仍需关注模块格式的匹配。

// 在 ESM 中使用 createRequire 加载 CJS 配置
import { createRequire } from 'module';
import { fileURLToPath } from 'url';

const require = createRequire(import.meta.url);
const legacyConfig = require('./legacy.cjs');

// CJS 中动态导入 ESM
async function loadEsmModule() {
    const { foo } = await import('./esm-module.mjs');
    return foo;
}

9. Monorepo 支持:workspace 配置与共享类型

随着业务规模增长,后端服务往往分解为多个可独立部署的子服务,而工具库、类型定义与配置需要跨服务复用。Monorepo 架构将所有相关代码仓库放入单一版本控制树中,通过 Workspace 机制统一管理依赖与构建脚本,避免多仓库之间的版本漂移与重复CI配置。

9.1 pnpm Workspace 配置

pnpm 的 Workspace 特性是解决 Monorepo 依赖冗余的最佳方案之一:公共依赖(如 TypeScript、Zod)仅在仓库根目录安装一份,各子包通过软链接引用,大幅减少 node_modules 总体积。

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// package.json(根)
{
    "name": "my-platform",
    "private": true,
    "scripts": {
        "build": "pnpm -r build",
        "test": "pnpm -r test",
        "lint": "pnpm -r lint"
    },
    "devDependencies": {
        "typescript": "^5.5.0",
        "vitest": "^2.0.0"
    }
}

9.2 共享类型包与交叉引用

将核心业务类型(DTO、Entity、API 响应)抽取到独立的 @my-platform/types 包中,前后端均可依赖。每个子包在 package.json 的 exports 字段显式声明公开接口,配合 tsconfig 的 references 实现增量编译与 IntelliSense 跳转。

// packages/types/package.json
{
    "name": "@my-platform/types",
    "version": "1.0.0",
    "type": "module",
    "exports": {
        ".": "./dist/index.js",
        "./user": "./dist/user.js"
    },
    "typesVersions": {
        "*": {
            "*": ["./dist/*"]
        }
    }
}
// tsconfig.json(根)
{
    "compilerOptions": {
        "composite": true,
        "declarationMap": true
    },
    "references": [
        { "path": "./packages/types" },
        { "path": "./apps/api" }
    ]
}
// apps/api/src/services/user.service.ts
import { CreateUserDto, UserResponse } from '@my-platform/types/user';

export async function createUser(dto: CreateUserDto): Promise<UserResponse> {
    // 业务逻辑...
}

通过在根目录统一配置 ESLint、Prettier 与 TypeScript 标准,团队可以在新增服务时复用已有工程化基建,显著降低 Marginal Cost(边际成本),保持技术栈的一致性。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「nodejs」更多文章

  1. Node.js 优雅停机与健康检查实战
  2. Node.js 内存泄漏诊断实战
  3. BullMQ 后台任务队列实战