目录

TypeScript 最佳实践

核心原则

在 TypeScript 6.0 时代,类型系统的价值不应停留在“把 JavaScript 写得更像静态语言”,而应体现在降低错误率、压缩重构成本、强化跨层契约。真正成熟的 TypeScript 实践,关注的是设计质量,而不是注解数量。

稳定、可扩展的团队通常遵循以下原则:

  1. 先建模领域,再书写实现。 类型应该表达业务约束,而不是临时迎合界面或传输层结构。
  2. 把不确定性收敛在边界。 HTTP 请求、数据库结果、消息队列、文件系统、第三方 SDK 返回值,都应先视为 unknown
  3. 让推断做局部工作,让显式类型守住边界。 不必为每个局部变量写类型,但对公共 API、跨模块接口、关键抽象要明确声明。
  4. 为可维护性设计,而不是为炫技设计。 高级类型能力只有在减少重复、提升正确性时才值得使用。
  5. 让编译期契约与运行时契约一致。 TypeScript 只负责静态分析,不会替你验证线上输入。

安装与版本管理

应将最新稳定版 TypeScript 6.0.x 作为项目级开发依赖,而不是依赖全局安装版本。

npm install -D typescript@~6.0.0
npx tsc --version

建议的版本策略:

  • 在生产项目中使用 ~6.0.0 这类 patch 级可控升级 范围。
  • 将 TypeScript 升级与框架升级拆开处理,便于定位回归。
  • 保证本地 CLI、编辑器语言服务、CI 使用同一主版本。
  • 在 monorepo 中尽量统一 TypeScript 版本。
  • 对共享库或平台仓库,记录编译器升级及兼容性影响。

如果你维护的是库而不是应用,还应验证声明文件与消费者运行环境的兼容性。

tsconfig 策略

tsconfig 不是样板文件,而是工程约束的一部分。成熟项目通常会分层管理:

  • tsconfig.base.json:共享基础配置
  • tsconfig.json:编辑器体验、项目引用
  • tsconfig.build.json:生产构建/声明输出
  • tsconfig.test.json:测试环境覆盖项

Node.js ESM 项目的推荐基线:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "importsNotUsedAsValues": "error",
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": false,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

实践建议:

  • 对库、平台层、基础设施代码,优先保持 skipLibCheck: false
  • 大型项目和 monorepo 应优先考虑 project references
  • 如果构建由 bundler 负责,tsc --noEmit 应作为独立的权威类型检查步骤。
  • Node、Browser、Worker、Test 的全局类型环境应隔离管理,避免互相污染。

严格模式策略

"strict": true 只是起点,不是终点。大量线上缺陷都可以通过额外的严格选项提前暴露。

建议优先开启:

  • noUncheckedIndexedAccess:防止误以为数组或字典索引一定存在。
  • exactOptionalPropertyTypes:区分“字段缺失”和“字段存在但值为 undefined”。
  • noImplicitOverride:在继承场景中防止重构误伤。
  • useUnknownInCatchVariables:避免默认把异常当成 Error 使用。
  • noFallthroughCasesInSwitch:减少状态分支遗漏。
  • noPropertyAccessFromIndexSignature:处理不可信键值对象时很有价值。

如果某个严格选项引入大量告警,不要第一时间全局关闭。先判断它揭示的是“噪声”还是“真实风险”。

类型设计

优秀的类型设计首先关注语义边界概念分层

更推荐的做法:

  • 使用具备业务语义的别名,例如 UserIdOrderStatusIsoTimestamp
  • 使用可判别联合来描述状态机和结果流
  • 默认偏向 readonly 和不可变数据
  • 明确建模 null / undefined,而不是把“可选”滥用到所有地方

应避免:

  • 一个超大接口同时承担 API、数据库、缓存、UI 四层职责
  • 为了“类型炫技”把业务规则全塞进复杂条件类型
  • 大量 Record<string, unknown> 占位但从不收敛成真实模型

示例:把创建输入、持久化记录、展示摘要拆开。

type UserId = string & { readonly brand: unique symbol };

type CreateUserInput = {
  email: string;
  displayName: string;
};

type UserRecord = {
  id: UserId;
  email: string;
  displayName: string;
  createdAt: string;
  deletedAt: string | null;
};

type UserSummary = Pick<UserRecord, "id" | "displayName">;

unknownany

在生产代码里,any 应被视为受控逃逸口,而不是默认选项。面对不确定输入时,通常优先使用 unknown

  • any 会关闭类型检查,并污染后续推断。
  • unknown 要求你在使用前先收窄。
  • never 适合表达不可能状态,并帮助穷尽性检查。
function parseJson(text: string): unknown {
  return JSON.parse(text);
}

function isFeatureFlagMap(value: unknown): value is Record<string, boolean> {
  return typeof value === "object" && value !== null &&
    Object.values(value).every((item) => typeof item === "boolean");
}

如果必须与遗留插件或低质量第三方类型定义集成,应把 any 封装在单独适配层内,而不是扩散到业务代码中。

联合类型与交叉类型

联合类型最适合表达“多种可能之一”,交叉类型更适合表达“能力组合”。在生产系统中,可判别联合往往是描述工作流状态的最佳工具。

type PaymentResult =
  | { kind: "authorized"; authorizationId: string }
  | { kind: "declined"; reason: string }
  | { kind: "failed"; retryable: boolean };

function handlePayment(result: PaymentResult): string {
  switch (result.kind) {
    case "authorized":
      return result.authorizationId;
    case "declined":
      return result.reason;
    case "failed":
      return result.retryable ? "retry" : "stop";
    default: {
      const exhaustive: never = result;
      return exhaustive;
    }
  }
}

交叉类型适合组合正交能力,例如时间戳、审计信息、分页元数据;不适合拿来“强行拼接”互相并不兼容的对象结构。

泛型

泛型的核心价值是保留输入与输出之间的关系。如果一个泛型没有表达真实关系,它大概率就没有存在必要。

适合使用泛型的场景:

  • 集合处理工具
  • 仓储/网关抽象
  • 通用异步结果包装
  • 基于 schema 的复用工具
type Page<T> = {
  items: T[];
  nextCursor: string | null;
};

async function fetchPage<TDto>(url: string): Promise<Page<TDto>> {
  const response = await fetch(url);
  return response.json() as Promise<Page<TDto>>;
}

建议:

  • 当单字母泛型会降低可读性时,改用 TItemTErrorTContext 这类具名参数。
  • 当实现依赖某些属性时,显式加约束。
  • 尽量让调用方依赖推断,而不是频繁手工传类型参数。
  • 对业务代码中的深层条件泛型保持警惕,除非它确实显著减少重复。

satisfies

satisfies 的价值在于:既检查对象是否符合目标类型,又尽量保留字面量精度

type RouteConfig = {
  path: `/${string}`;
  cache: "public" | "private";
  timeoutMs: number;
};

const routes = {
  health: { path: "/health", cache: "private", timeoutMs: 1_000 },
  docs: { path: "/docs", cache: "public", timeoutMs: 5_000 }
} satisfies Record<string, RouteConfig>;

特别适合用于:

  • 配置对象
  • 国际化字典
  • feature flag 清单
  • 路由表
  • 需要保持字面量信息的测试数据

只要你想获得“校验 + 精确推断”,就应优先考虑 satisfies,而不是宽泛的 as 断言。

模板字面量类型

模板字面量类型非常强大,但在工程实践里应优先解决实际问题,而不是追求类型层面的“语言解析器”。

高价值场景:

  • 路由模式
  • 事件名
  • 埋点键名
  • 环境变量命名约定
  • 设计系统 token 标识
type Entity = "user" | "invoice";
type Action = "created" | "deleted";
type DomainEvent = `${Entity}.${Action}`;

如果字符串协议本身解析复杂,优先编写运行时校验,再把校验结果映射到更窄的类型。

实用工具类型

内置工具类型是 TypeScript 的基础设施,但不能把它们当作“省事捷径”滥用。

典型用途:

  • Pick / Omit:派生聚焦视图
  • Partial:表达 patch 语义,而不是“我现在还没想好结构”
  • Readonly:为不可变 API 提供约束
  • Record:表达已知键空间
  • Exclude / Extract:收窄联合类型
  • ReturnTypeParametersAwaited:连接现有函数与异步层
type User = {
  id: string;
  email: string;
  displayName: string;
  role: "admin" | "member";
};

type UserPatch = Partial<Pick<User, "displayName" | "role">>;
type PublicUser = Omit<User, "email">;

如果工具类型链条已经影响阅读,应及时命名中间概念。

模块边界

成熟项目里,大多数 TypeScript 问题发生在模块边界,而不是纯函数内部。

建议:

  • 默认只导出公共领域类型和稳定接口,不随意暴露内部辅助类型。
  • 一个文件只承担一个清晰职责。
  • 避免跨特性目录的深层导入。
  • 对纯类型依赖使用 import type,保持语义清晰,也让 ESM 输出更干净。
  • 在共享库中优先使用命名导出,便于重构与检索。

健康的边界流程通常是:

  • 外部数据以 unknown 进入
  • 通过校验得到稳定 DTO
  • 再通过映射转成领域对象
  • 业务逻辑仅消费领域类型

运行时校验

TypeScript 不会校验线上输入。任何跨越信任边界的数据,都必须在运行时解析或验证。

常见信任边界:

  • HTTP 请求/响应
  • 数据库记录
  • 消息队列消息
  • 环境变量
  • CMS 内容
  • 本地存储 / Cookie
  • 第三方 SDK 回调

可根据技术栈选择 Zod、Valibot、ArkType 或 JSON Schema 方案。

import { z } from "zod";

const UserDtoSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1)
});

type UserDto = z.infer<typeof UserDtoSchema>;

function parseUserDto(value: unknown): UserDto {
  return UserDtoSchema.parse(value);
}

只有静态类型、没有运行时校验,会带来虚假的安全感;只有运行时校验、没有类型输出,则会制造重复劳动。生产系统需要两者配合。

API 与客户端 DTO

不要用一个类型贯穿分布式系统的所有层。DTO 存在的根本原因,是传输层约束与领域层约束并不相同。

复杂度足够时,至少拆分以下概念:

  • 请求 DTO
  • 响应 DTO
  • 领域实体
  • 持久化记录
  • UI 视图模型

建议:

  • 把 API 返回值先视为“待校验 DTO”。
  • DTO 到领域对象之间做显式映射。
  • 对公共 API 契约进行有计划的版本管理。
  • 不要把数据库字段名直接泄露到前端类型。
  • 如果接口需要区分“字段未传”和“字段为空”,优先使用 null 与明确语义,而不是混乱的可选字段。

这种分层会略微增加类型数量,但能显著降低迁移与演进时的耦合成本。

NodeNext 与 ESM 指南

对于现代 Node.js 项目,除非有明确历史包袱,建议优先采用:

  • module: "NodeNext"
  • moduleResolution: "NodeNext"

关键实践:

  • 既然选择原生 ESM,就尽量保持一致,不要半 ESM 半 CommonJS。
  • 当运行时要求时,为相对导入写出明确扩展名。
  • 使用 verbatimModuleSyntax: true 降低输出行为的意外性。
  • 合理使用 import type
  • 理解 package.jsontypeexportsimportstypes 的边界作用。
  • 不要在同一包内随意混用两套模块语义。

如果你维护的是库:

  • 先明确是只发布 ESM,还是发布双格式
  • 验证发布后的包结构,而不只是源代码能编译
  • 确保声明文件与真实入口一致

对应用而言,模块系统越简单,长期维护成本越低。

构建与类型检查工作流

除非你的产物完全依赖 tsc 输出,否则应把转译/打包类型检查视为两个独立环节。

常见生产方案:

  • tsupesbuildswc、Vite 或框架编译器负责快速构建
  • tsc --noEmit 负责权威类型检查
  • 需要声明文件或编译器输出时,再用 tsc -p tsconfig.build.json

示例脚本:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsup src/index.ts --format esm --dts",
    "build:types": "tsc -p tsconfig.build.json"
  }
}

CI 建议顺序:

  1. 使用锁文件安装依赖
  2. 运行 lint
  3. 运行 typecheck
  4. 运行 tests
  5. 运行生产构建

“能构建通过”并不等于“类型设计健康”。

测试

TypeScript 项目的测试,应同时覆盖运行时行为类型契约

建议分层:

  • 单元测试:验证纯逻辑
  • 集成测试:验证 I/O 边界与适配层
  • 契约测试:验证 API 约定
  • 类型测试:验证公共类型 API 与高级类型工具

推荐实践:

  • 当模块解析或发布形态重要时,在接近真实运行路径下执行测试。
  • 对共享库使用 tsdexpect-type 或 Vitest 的类型断言能力。
  • 避免过度 mock;只 mock 你不想真正执行的边界。
  • satisfies 约束 fixture,让测试数据既精确又易维护。

Lint 与格式化

Lint 的职责是发现语义问题,格式化工具的职责是消除风格争论。

推荐组合:

  • ESLint
  • typescript-eslint
  • Prettier
  • 按需增加 eslint-plugin-importeslint-plugin-unicorn 或框架规则

高价值规则通常包括:

  • 限制不安全的 any
  • 检查未处理的 Promise
  • 阻止被误用的异步函数
  • 统一 type-only imports
  • 捕获多余断言和冗余条件

格式化应尽量自动化,不要把 Prettier 已经解决的问题再通过大量 lint 规则重复编码。

性能

大型项目中,TypeScript 的性能直接影响编辑器反馈速度,而反馈速度又会影响工程质量。

优化方向:

  • 在 monorepo 中使用 project references
  • 避免巨大联合类型爆炸和深层递归条件类型
  • 复杂类型应命名并复用,而不是反复即时展开
  • 减少内部包不必要的 .d.ts 暴露面
  • 先定位慢文件/慢类型,再做针对性优化
  • 必要时升级类型性能较差的依赖

一个实用经验:如果某个类型需要一整段话才能解释清楚,它往往也不适合被长期维护。

迁移策略

为既有代码库升级 TypeScript 实践,关键不在“一次性重写”,而在于有顺序地降低风险

建议迁移路径:

  1. 固定现代编译器版本
  2. 收敛并统一 tsconfig
  3. 开启 strict
  4. 优先修复 API、数据库、配置加载等边界类型
  5. 用收窄和校验替代宽泛断言
  6. 逐步开启 noUncheckedIndexedAccess 等高价值严格选项
  7. 把过大的共享类型拆成分层模型
  8. any / as 增加 lint 约束与可见性

对于超大仓库,按目录、包、业务域分批推进,通常比大爆炸式改造更稳妥。

反模式

生产环境中常见的 TypeScript 反模式包括:

  • as 当成设计工具,而不是最后的受控逃逸
  • 一个“实体类型”同时服务后端、前端、存储、测试
  • 在包入口导出内部实现细节类型
  • 明明对象 + 函数更合适,却默认使用类
  • 明明字符串字面量联合更灵活,却滥用 enum
  • 用泛型工具隐藏意图,而不是澄清意图
  • 不加审查地把生成类型直接当成领域模型
  • 永久开启 skipLibCheck 来掩盖依赖质量问题
  • response.json() as SomeType 盲目信任而不做校验

边界处求显式,核心处求简洁,通常是更稳的方向。

团队检查清单

一个成熟的 TypeScript 团队,通常应对以下问题大多回答“是”:

  • 是否已固定当前稳定的 6.0.x 版本?
  • 是否全仓开启了 strict
  • 是否有意识地评估了额外严格选项,而不是停留在默认值?
  • 是否把外部输入先视为 unknown 再校验?
  • 是否在必要时区分 DTO、领域模型、持久化模型?
  • 是否能清楚看到并解释 anyas 的使用?
  • 是否明确了模块边界,并一致使用 import type 与命名导出?
  • CI 是否分别执行 lint、typecheck、tests、build?
  • 公共 API 是否有类型测试或契约测试?
  • 迁移债务是否被跟踪,而不是被默认接受?

相关资源