跳转至

第 5 章:工程化实践

学习时间:3 小时 | 难度:⭐⭐⭐ 中级 | 前置知识:第 1 章


本章概览

TypeScript 工程化是大型项目保持代码质量的关键。本章覆盖 ESLint 配置、测试框架 Vitest、monorepo 管理与 CI/CD 集成。

学习目标:

  • 配置 typescript-eslint 进行代码质量检查
  • 掌握 Vitest 写类型安全的单元测试
  • 理解 monorepo 与 pnpm workspaces
  • 配置 CI/CD 自动化类型检查
  • 掌握 .d.ts 声明文件编写

5.1 ESLint + typescript-eslint 配置

Bash
# 安装 eslint 8.x 与 typescript-eslint(2025 年推荐配置)
pnpm add -D eslint @eslint/js typescript-eslint
JavaScript
// eslint.config.mjs(扁平配置格式,ESLint 8+ 默认)
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  // 基础推荐规则
  eslint.configs.recommended,

  // TypeScript 严格规则(强烈推荐)
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,

  {
    // 类型感知规则需要提供 tsconfig
    languageOptions: {
      parserOptions: {
        projectService: true,              // 自动发现 tsconfig.json
        tsconfigRootDir: import.meta.dirname,
      },
    },

    rules: {
      // 关闭与 TypeScript 冲突的规则
      "no-unused-vars": "off",
      "@typescript-eslint/no-unused-vars": ["error", {
        argsIgnorePattern: "^_",           // 允许 _param 形式的未使用参数
        varsIgnorePattern: "^_",
      }],

      // 禁止 any(允许显式 as unknown as T 转换)
      "@typescript-eslint/no-explicit-any": "error",

      // 要求异步函数返回 Promise 时使用 await 或返回类型明确
      "@typescript-eslint/require-await": "error",
      "@typescript-eslint/no-floating-promises": "error",  // 必须处理 Promise

      // 命名约定
      "@typescript-eslint/naming-convention": [
        "error",
        { selector: "interface", format: ["PascalCase"] },
        { selector: "typeAlias",  format: ["PascalCase"] },
        { selector: "enum",       format: ["PascalCase"] },
        { selector: "variable",   format: ["camelCase", "UPPER_CASE", "PascalCase"] },
      ],
    },
  },

  // 忽略文件
  {
    ignores: ["dist/**", "node_modules/**", "*.config.{js,mjs}"],
  }
);

5.2 Vitest 测试框架

Vitest 是专为 Vite/TypeScript 设计的测试框架,开箱即用支持 TS,比 Jest 快 3-5 倍:

Bash
pnpm add -D vitest @vitest/coverage-v8
TypeScript
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,         // 全局 describe/it/expect(无需导入)
    environment: "node",   // 或 "jsdom"(浏览器环境)
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
  },
});
TypeScript
// src/utils/validator.ts
export function validateEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function validateAge(age: number): { valid: boolean; message?: string } {
  if (age < 0)   return { valid: false, message: "Age cannot be negative" };
  if (age > 150) return { valid: false, message: "Age seems unrealistic" };
  return { valid: true };
}

// src/utils/validator.test.ts
import { describe, it, expect } from "vitest";
import { validateEmail, validateAge } from "./validator";

describe("validateEmail", () => {
  it("should accept valid emails", () => {
    expect(validateEmail("user@example.com")).toBe(true);
    expect(validateEmail("user+tag@domain.co.uk")).toBe(true);
  });

  it("should reject invalid emails", () => {
    expect(validateEmail("not-an-email")).toBe(false);
    expect(validateEmail("@nodomain.com")).toBe(false);
    expect(validateEmail("spaces in@email.com")).toBe(false);
  });
});

describe("validateAge", () => {
  it("should return valid for reasonable ages", () => {
    expect(validateAge(25)).toEqual({ valid: true });
    expect(validateAge(0)).toEqual({ valid: true });
  });

  it("should return error for negative age", () => {
    expect(validateAge(-1)).toEqual({
      valid: false,
      message: "Age cannot be negative",
    });
  });

  it("should return error for unrealistic age", () => {
    const result = validateAge(200);
    expect(result.valid).toBe(false);
    expect(result.message).toContain("unrealistic");
  });
});

类型测试(expectTypeOf

TypeScript
// src/types.test.ts
import { expectTypeOf, assertType } from "vitest";
import { type UserPublic } from "./types";

// 确保类型转换/工具类型的输出类型正确
it("UserPublic should not include password", () => {
  expectTypeOf<UserPublic>().not.toHaveProperty("password");
  expectTypeOf<UserPublic>().toHaveProperty("name");
  expectTypeOf<UserPublic["name"]>().toBeString();
});

// 确保函数返回类型正确
it("parseId should return number", () => {
  assertType<number>(parseId("42"));
});

5.3 pnpm Monorepo 与 Workspaces

YAML
# pnpm-workspace.yaml(monorepo 根目录)
packages:
  - "packages/*"   # 所有子包
  - "apps/*"       # 应用
Text Only
my-monorepo/
├── pnpm-workspace.yaml
├── package.json          # 根 package.json(工具脚本)
├── tsconfig.base.json    # 共享 tsconfig
├── packages/
│   ├── shared-types/     # @myapp/shared-types
│   ├── ui/               # @myapp/ui
│   └── utils/            # @myapp/utils
└── apps/
    ├── web/              # Next.js 前端
    └── api/              # Fastify 后端
Text Only
// tsconfig.base.json(所有子包继承)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
Text Only
// packages/shared-types/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
Text Only
// packages/shared-types/package.json
{
  "name": "@myapp/shared-types",
  "version": "0.1.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

5.4 声明文件(.d.ts)编写

当你需要为没有类型定义的 JS 库、环境变量或全局对象添加类型时:

TypeScript
// src/env.d.ts — 类型化环境变量(与实际配置同步!)
declare namespace NodeJS {
  interface ProcessEnv {
    readonly NODE_ENV: "development" | "production" | "test";
    readonly DATABASE_URL: string;
    readonly REDIS_URL: string;
    readonly JWT_SECRET: string;
    readonly API_KEY: string;
    // PORT 可能未设置
    readonly PORT?: string;
  }
}

// 使用时有完整类型提示
const dbUrl = process.env.DATABASE_URL; // string(不是 string | undefined!)
const port = process.env.PORT ?? "3000"; // string | undefined → string(有默认值)

// src/global.d.ts — 全局类型扩展
declare global {
  // 扩展全局对象
  interface Window {
    gtag: (...args: unknown[]) => void;
    dataLayer: Record<string, unknown>[];
  }

  // 全局工具类型(在整个项目中可用,无需导入)
  type Maybe<T> = T | null | undefined;
  type AsyncFn<T = void> = () => Promise<T>;
}

// 模块增强:给已有模块添加类型
import "express";
declare module "express" {
  interface Request {
    user?: { id: string; role: "admin" | "user" };
    requestId: string;
  }
}

5.5 CI/CD 类型检查配置

YAML
# .github/workflows/typecheck.yml
name: Type Check & Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      # 类型检查(严格模式)
      - name: TypeScript Type Check
        run: pnpm tsc --noEmit

      # ESLint 检查
      - name: ESLint
        run: pnpm eslint src --max-warnings 0

      # 运行测试 + 覆盖率
      - name: Unit Tests
        run: pnpm vitest run --coverage

      # 上传覆盖率报告
      - uses: codecov/codecov-action@v4
        with:
          files: coverage/lcov.info

📌 本章小结

工具 用途 要点
typescript-eslint 代码质量 strictTypeChecked 规则集,类型感知检查
Vitest 测试 原生 TS,expectTypeOf 做类型测试
pnpm workspaces Monorepo 统一版本管理,共享 tsconfig
.d.ts 文件 类型声明 环境变量、全局类型、模块增强
GitHub Actions CI/CD tsc --noEmit + ESLint + Vitest

TypeScript 5.8 · 2025