大型项目 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
}
}
按影响范围从小到大启用:
noImplicitAny→ 标注遗漏的类型strictNullChecks→ 处理 null/undefinedstrictFunctionTypes→ 修复函数类型兼容性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)创建名义上不同的类型,使得 UserId 和 OrderId 虽然底层都是 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)控制类型的导出边界。