大型项目 TS 架构模式

掌握品牌类型、模块边界约定与严格模式渐进迁移策略,建立大型 TypeScript 项目的类型架构能力。

大型项目 TS 架构模式

概述

小型项目中,TypeScript 的使用主要是"给变量加类型"。大型项目则需要类型系统参与架构设计——通过品牌类型防止语义错误、通过模块边界约定控制类型暴露、通过渐进迁移策略从 JavaScript 安全过渡。这些模式体现的是用类型系统编码业务规则和架构约束的能力。

面试定位:大型项目的 TypeScript 架构问题出现在高级面试中,考察候选人能否超越语法层面,将类型系统作为工程工具来设计和维护代码库。


品牌类型(Branded Types)

问题:原始类型的语义模糊

function processOrder(userId: string, orderId: string, productId: string) {
  // 三个参数都是 string,容易传错位置
}

// TypeScript 不会报错,但逻辑错误
processOrder(orderId, productId, userId); // 参数顺序错了!

解决方案:品牌类型

品牌类型通过在类型上附加一个不存在的属性,创建名义上不同的类型:

// 定义品牌类型
type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;

// 构造函数
function UserId(id: string): UserId {
  return id as UserId;
}

function OrderId(id: string): OrderId {
  return id as OrderId;
}

// 使用
function processOrder(userId: UserId, orderId: OrderId, productId: ProductId) {
  // ...
}

const uid = UserId("user-123");
const oid = OrderId("order-456");

processOrder(uid, oid, ProductId("prod-789")); // ✅
processOrder(oid, uid, ProductId("prod-789")); // ❌ 编译错误

带验证的品牌类型

type Email = Brand<string, "Email">;
type PositiveInt = Brand<number, "PositiveInt">;

function Email(value: string): Email {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
    throw new Error(`Invalid email: ${value}`);
  }
  return value as Email;
}

function PositiveInt(value: number): PositiveInt {
  if (!Number.isInteger(value) || value <= 0) {
    throw new Error(`Expected positive integer: ${value}`);
  }
  return value as PositiveInt;
}

品牌类型与 Zod 结合

import { z } from "zod";

const UserIdSchema = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserIdSchema>;

const EmailSchema = z.string().email().brand<"Email">();
type Email = z.infer<typeof EmailSchema>;

模块边界约定

Barrel 文件(Index Exports)

通过 index.ts 控制模块的公开 API:

features/
├── auth/
│   ├── index.ts          # 公开 API
│   ├── components/
│   │   ├── LoginForm.tsx
│   │   └── SignupForm.tsx
│   ├── hooks/
│   │   └── useAuth.ts
│   ├── types.ts           # 内部类型
│   └── utils.ts           # 内部工具
// features/auth/index.ts — 只导出公开 API
export { LoginForm } from "./components/LoginForm";
export { SignupForm } from "./components/SignupForm";
export { useAuth } from "./hooks/useAuth";
export type { AuthState, User } from "./types";
// utils.ts 不导出,是内部实现细节

ESLint 强制模块边界

使用 eslint-plugin-import@nx/enforce-module-boundaries 规则:

// .eslintrc.js
{
  rules: {
    "import/no-internal-modules": ["error", {
      allow: ["**/index"]
    }],
    // 禁止直接导入模块内部文件
    "no-restricted-imports": ["error", {
      patterns: [{
        group: ["@features/*/components/*", "@features/*/hooks/*", "@features/*/utils/*"],
        message: "请通过 @features/xxx 的 barrel 文件导入"
      }]
    }]
  }
}

接口层设计

大型项目中,模块间通过明确的接口(类型契约)通信:

// features/auth/types.ts
export interface AuthService {
  login(credentials: LoginCredentials): Promise<AuthResult>;
  logout(): Promise<void>;
  getSession(): Promise<Session | null>;
  refreshToken(token: string): Promise<TokenPair>;
}

// features/auth/index.ts
export type { AuthService };
export { createAuthService } from "./service";

// features/dashboard/index.ts
import type { AuthService } from "@features/auth";
// 依赖接口类型,不依赖具体实现

严格模式渐进迁移策略

从 JavaScript 迁移到 TypeScript

阶段一:allowJs + checkJs

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false
  }
}

逐文件启用类型检查:在 JavaScript 文件顶部添加 // @ts-check

阶段二:逐步启用严格选项

{
  "compilerOptions": {
    "strict": false,
    "noImplicitAny": true,
    "strictNullChecks": false
  }
}

按影响范围从小到大启用:

  1. noImplicitAny → 标注遗漏的类型
  2. strictNullChecks → 处理 null/undefined
  3. strictFunctionTypes → 修复函数类型兼容性
  4. strict: true → 启用所有严格检查

阶段三:自动化辅助

# 使用 ts-migrate 自动添加类型标注
npx ts-migrate full /path/to/project

# 使用 TypeScript 的 --noEmit 检查错误数量
npx tsc --noEmit 2>&1 | grep "error TS" | wc -l

per-file 严格模式

对于无法一次性全部迁移的项目:

// 在 tsconfig.json 中保持宽松配置
// 为新文件使用 tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true
  },
  "include": ["src/new-module/**/*"]
}

类型架构层次

分层类型定义

types/
├── domain/              # 业务领域类型
│   ├── user.ts
│   ├── order.ts
│   └── product.ts
├── api/                 # API 传输类型(DTO)
│   ├── user.api.ts
│   └── order.api.ts
├── ui/                  # UI 组件 Props 类型
│   └── common.ts
└── shared/              # 通用工具类型
    ├── brand.ts
    └── utils.ts

Domain 类型 vs DTO 类型

// domain/user.ts — 业务领域类型
interface User {
  id: UserId;
  email: Email;
  name: string;
  createdAt: Date;
  role: Role;
}

// api/user.api.ts — API 传输类型
interface UserDTO {
  id: string;
  email: string;
  name: string;
  created_at: string;    // snake_case, ISO string
  role: string;
}

// 转换函数
function toDomain(dto: UserDTO): User {
  return {
    id: UserId(dto.id),
    email: Email(dto.email),
    name: dto.name,
    createdAt: new Date(dto.created_at),
    role: parseRole(dto.role),
  };
}

实用类型模式

类型安全的事件总线

interface EventMap {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "cart:update": { items: CartItem[]; total: number };
}

class TypedEventEmitter<Events extends Record<string, unknown>> {
  private handlers = new Map<string, Set<Function>>();

  on<K extends keyof Events & string>(
    event: K,
    handler: (payload: Events[K]) => void
  ): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
    return () => this.handlers.get(event)?.delete(handler);
  }

  emit<K extends keyof Events & string>(event: K, payload: Events[K]) {
    this.handlers.get(event)?.forEach((fn) => fn(payload));
  }
}

const bus = new TypedEventEmitter<EventMap>();
bus.on("user:login", (payload) => {
  console.log(payload.userId); // 完全类型安全
});

类型安全的配置系统

interface FeatureFlags {
  darkMode: boolean;
  betaFeatures: boolean;
  maxUploadSize: number;
  allowedFormats: string[];
}

function createConfig<T extends Record<string, unknown>>(
  defaults: T
): {
  get<K extends keyof T>(key: K): T[K];
  set<K extends keyof T>(key: K, value: T[K]): void;
} {
  const config = { ...defaults };
  return {
    get: (key) => config[key],
    set: (key, value) => { config[key] = value; },
  };
}

const flags = createConfig<FeatureFlags>({
  darkMode: false,
  betaFeatures: false,
  maxUploadSize: 10_000_000,
  allowedFormats: ["jpg", "png", "webp"],
});

flags.get("darkMode");       // boolean
flags.set("maxUploadSize", 20_000_000); // ✅
flags.set("darkMode", "yes"); // ❌ 类型错误

面试高频问题

Q: 什么是品牌类型?解决什么问题?

回答要点:TypeScript 是结构类型系统(structural typing),两个结构相同的类型是兼容的。品牌类型通过在类型上附加一个幽灵属性(phantom property)创建名义上不同的类型,使得 UserIdOrderId 虽然底层都是 string,但在类型层面不可互换。这在防止参数传递顺序错误、确保 ID 类型语义正确等场景中非常有价值。

Q: 如何将一个大型 JavaScript 项目迁移到 TypeScript?

回答要点:渐进迁移是唯一可行的策略。首先启用 allowJs,将 .js 文件直接纳入 TypeScript 编译;然后逐文件将 .js 改为 .ts,从叶子模块(无依赖其他模块)开始;严格选项从 noImplicitAny 开始逐步启用,最后开启 strict: true。整个过程中项目始终可以编译和运行,不会出现"停工迁移"。

Q: 如何在大型项目中组织类型定义?

回答要点:类型应分层组织——领域类型(domain types)描述业务概念、API 类型(DTO)描述传输格式、UI 类型描述组件 Props。领域类型与 API 类型之间通过显式的转换函数桥接,避免 UI 层直接依赖 API 响应结构。模块通过 barrel 文件(index.ts)控制类型的导出边界。


延展阅读