大型项目的 TypeScript 最佳实践
大型 TypeScript 项目实践:strict 模式、noUncheckedIndexedAccess、exactOptionalPropertyTypes、生成类型、运行时校验与 CI 约束。
在大规模项目中,TypeScript 的价值只有在严格一致的规范下才能充分体现:以 strict 模式为基准、显式定义并验证边界、在边界处生成类型以防止团队间的类型漂移,以及全团队真正认可并遵守的一套精简模式。语言本身早已不是难点所在——真正的挑战在于如何让一个百万行代码、多人协作的代码库保持可重构性,同时防止类型安全回归悄悄混入每一个 PR。本指南涵盖了在这种规模下行之有效的规范、编译器标志和架构模式,并为你可能接手的那个规范宽松的遗留代码库提供了迁移路径。本文内容与重塑 2026 年格局的两个版本保持同步:TypeScript 6.0(正式版)和 7.0(候选发布版)。
核心要点
- 自 TypeScript 6.0(2026 年 3 月 23 日发布)起,
strict在编译器层面默认为true,因此在现代大型代码库中,strict 模式是起点,而非终点。 - 对大型代码库真正起到关键作用的两个标志根本不在
strict的范畴内:noUncheckedIndexedAccess和exactOptionalPropertyTypes必须显式启用,它们能捕获 strict 模式静默放行的数组索引和可选属性类型错误。 - 生成类型是多团队代码库中单一杠杆效应最高的实践:当前端和后端都从同一个 OpenAPI 或 Prisma schema 派生类型时,双方在物理上就不可能产生漂移,一旦契约发生变更,CI 立即失败。
- 静态类型是编译时的承诺,而非运行时检查——一个被标注为
User类型的响应只是被断言为该类型,这正是为什么每个外部边界除了生成类型之外还需要运行时验证。 - Microsoft 报告称,TypeScript 7.0 基于 Go 的编译器在大型代码库上通常比 6.0 快约 10 倍,在其 2026 年 6 月的候选发布版中,它已集成在标准的
tsc二进制文件和typescript包中。
编译器规范:strict 模式是底线,而非成就
每一篇浅尝辄止的最佳实践文章仍在告诉你”启用 strict 模式”,仿佛这是一项需要主动选择的壮举。这种表述现已过时。根据 TypeScript 6.0 的发布说明,strict 在编译器层面默认为 true——如果你依赖旧版默认值 false,现在必须显式设置 "strict": false。TypeScript 6.0 于 2026 年 3 月 23 日正式发布,并被定位为基于当前 JavaScript 代码库的最后一个版本。因此,对于任何更新了编译器的项目,strict 都是默认基准。
对大型项目真正有价值的升级,是 strict 所不包含的两个高价值标志。strict 大约开启了九项类型安全检查(noImplicitAny、strictNullChecks 等),但它遗漏了 noUncheckedIndexedAccess(为每个未声明的索引访问添加 undefined)和 exactOptionalPropertyTypes(区分属性被设为 undefined 与属性缺失这两种情况)。这两个标志正好能捕获那些从”strict”代码库中溜走的 bug:假设元素一定存在的数组查找,以及存在但值为 undefined 的可选字段。
一份带版本标注的大型项目 tsconfig.json(TypeScript 6.0.x):
{
"compilerOptions": {
"strict": true, // 自 6.0 起为默认值;为兼容旧工具链保留显式声明
"noUncheckedIndexedAccess": true, // arr[i] 的类型为 T | undefined,而非 T
"exactOptionalPropertyTypes": true, // { x?: number } 拒绝 { x: undefined }
"verbatimModuleSyntax": true, // 强制使用仅类型导入(参见构建性能章节)
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"composite": true // project references 的必要条件
}
}
渐进式严格化迁移方案
Discover how at OpenReplay.com.
如果你接手的是一个规范宽松的遗留代码库,不要一次性开启所有标志——应按目录逐步应用严格性,在 CI 中追踪 type-coverage 百分比,并通过棘轮机制确保覆盖率只升不降。一个有数千个隐式 any 的 20 万行应用不可能在第一天就编译通过,一个包含 2800 个错误的 PR 也根本无法 review。
一个切实可行的步骤序列:
- 全局开启
strict: true,但限定执行范围:保留一个宽松的基础tsconfig,并通过 project references 为各功能目录添加更严格的tsconfig.json,优先收紧问题最严重的部分。 - 将
type-coverage作为棘轮工具加入 CI——如果有类型标注的符号百分比低于上次记录的数值,则构建失败。覆盖率可以停滞,但绝不能回退。 - 对剩余的违规项使用
// @ts-expect-error分阶段引入额外标志(noUncheckedIndexedAccess、exactOptionalPropertyTypes),然后逐步消化这份清单。@ts-expect-error会在某个抑制注释变得不再必要时自动上报,因此积压问题不会悄然腐化。
大规模场景下的类型设计
良好的大规模类型设计能让非法状态无法通过编译,并让领域层面的错误清晰可见。以下三种模式承担了大部分工作,其余的关键在于一致性。
关于 interface 与 type 的选择,一次说清: 对公开的、可扩展的对象契约使用 interface(它支持声明合并,在大型对象结构上通常能产生更清晰的错误信息);对联合类型、交叉类型、映射类型和条件类型使用 type。这就是全部争论的核心。确定规则、用 lint 强制执行,然后继续前进。
让非法状态无法表示
布尔标志的滥用是大型 UI 代码库中”这种情况不应该发生”类型 bug 的最常见根源。下面的类型允许十六种组合,其中大多数毫无意义——isLoading 和 error 同时为真,或者在错误状态下 data 仍然存在:
// 反模式:每个字段独立,允许非法状态
interface RequestState<T> {
isLoading: boolean;
isError: boolean;
data?: T;
error?: Error;
}
判别联合类型将其收敛为恰好能够发生的状态,编译器会强制你处理每一种情况:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render<T>(state: RequestState<T>) {
switch (state.status) {
case "success":
return state.data; // data 仅在此处存在
case "error":
return state.error; // error 仅在此处存在
// 若缺少某个 case,配合适当的穷举检查将产生编译错误
}
}
对布尔标志请求状态的会话回放经常能揭示此模式所消除的失败场景:由于两个独立的布尔值失去同步,UI 同时渲染了加载动画和过期数据。
为领域 ID 打上品牌标记
品牌类型(Branded Types)使 UserId 和 OrderId 成为互不兼容的类型,尽管它们在运行时都是 string,将一个传入另一个期望的地方会产生编译错误:
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const asUserId = (s: string) => s as UserId;
function cancelOrder(id: OrderId) { /* ... */ }
const u = asUserId("u_123");
cancelOrder(u); // ❌ 'UserId' 类型的参数不能赋给 'OrderId' 类型的参数
在一个有五个字符串参数的函数签名中,品牌类型是参数位置错误在编译时被发现还是在生产环境中暴露的分水岭。
优先使用 unknown 而非 any。 any 会禁用类型检查器并静默传播;unknown 在使用前强制进行类型收窄。在 lint 中禁止 any,并将每个外部值——JSON.parse、catch 绑定、无类型库的返回值——都视为 unknown,直到被证明为其他类型。对字面量配置和查找表使用 as const,使其推断为窄字面量类型,而非被拓宽的原始类型。
在边界处建模并生成类型
在大型代码库中,杠杆效应最高的架构决策是如何对边界进行类型标注。两条规则。
第一,不要在网络传输、数据库和 UI 之间复用同一个 User 类型——将 API 响应、DTO 和领域实体建模为三种不同的类型,这样某个边界的变更就不会静默地破坏另一个边界。后端序列化的结构、ORM 返回的结构以及组件消费的结构会随时间产生分歧;将它们合并为一种类型会导致每一层都与其他层紧密耦合。
第二,生成边界类型,而非手工编写。当前端和后端都从同一个 schema 派生类型时,双方在物理上就不可能产生漂移,一旦契约发生变更,CI 立即失败。使用 openapi-typescript 将 OpenAPI 3.0/3.1 文档转换为无运行时开销的类型,使用 Prisma 获取数据库派生类型,或使用 GraphQL Code Generator 获取带类型的操作。在 CI 中重新生成并在发现差异时失败:
# CI 步骤:重新生成并在已提交的类型过期时失败
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts
但生成的类型仍然只是编译时的承诺。静态类型是编译时的承诺,而非运行时检查——一个被标注为 User 类型的响应只是被断言为该类型,这正是为什么每个外部边界除了生成类型之外还需要运行时验证。使用 Zod 或 Valibot 等 schema 库验证实际载荷,并从 schema 派生静态类型,使一个定义同时守护两个层面:
import { z } from "zod";
const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;
const res = await fetch("/api/me");
const user = User.parse(await res.json()); // 若实际结构不符则抛出异常
验证边界而非仅仅标注类型的理由是经验性的:静态类型在运行时消失,而会话回放是观察类型无法捕获的失败的一种手段——当真实的 API 响应与其声明的结构不匹配,用户会话中的 UI 随之崩溃的那一刻。
为多人协作的代码库组织类型
- 将类型与使用它们的代码放在一起——同一文件,或同级的
*.types.ts文件——仅将真正共享的契约保留在types/index.ts(或专用包)中。 - 按功能/领域目录组织,而非按技术层次,使一个功能的类型、组件和逻辑放在一起,所有权一目了然。
- 在 monorepo 中,通过 project references 和
paths映射将包连接起来,使导入跨越清晰的模块边界(@org/billing),而非脆弱的../../../相对路径链,并让编译器强制执行依赖关系图。
2026 年的构建性能:原生编译器改变了计算方式
构建性能的讨论已发生根本性转变,不再是通过调整 tsc 标志来节省几秒钟。TypeScript 于 2026 年 6 月 18 日发布了 7.0 候选版;凭借原生代码速度和共享内存并行性,它通常比 TypeScript 6.0 快约 10 倍。Microsoft 的基准测试数据显示,约 150 万行代码的 VS Code 代码库的检查时间从旧编译器的约 77.8 秒降至约 7.5 秒,不过在小型项目上的提升倍数会小一些。
打包方式是需要重点关注的变化。候选版中最重要的实际变化是打包方式:基于 Go 的重写版本已从独立的 native-preview 包迁移到常规的 TypeScript npm 包中,因此 TypeScript 7.0 现在作为标准 tsc 编译器准备好接受更广泛的测试。使用 npm install -D typescript@rc 安装并运行标准的 tsc 二进制文件——旧的 tsgo / @typescript/native-preview 包现在仅承载每日构建版本。截至 2026 年 6 月下旬,最新稳定版为 TypeScript 6.0.3,7.0 处于候选发布阶段,预计在 RC 发布后约一个月内正式发布。请将版本和阶段信息视为动态变化的,在采用前务必重新确认。
你在自己配置中可以控制的结构性手段在任何编译器上都值得采用:通过 project references 实现增量式、感知依赖的构建,以及通过 verbatimModuleSyntax 强制使用仅类型导入,使仅类型符号被擦除,永远不会作为运行时导入被输出。verbatimModuleSyntax(在 5.0 中引入)是当前推荐的方式;它所替代的标志 importsNotUsedAsValues 和 preserveValueImports 在 5.5 中已成为无操作项,在 6.0 中指定它们会产生错误。
import type { User } from "./user"; // 从 JS 输出中完全擦除
import { fetchUser } from "./api"; // 值导入,予以保留
自动化守护机制
未被强制执行的规范终将衰退。运行带有类型感知规则的 typescript-eslint(no-explicit-any、no-floating-promises、no-misused-promises),使上述模式得到机械化检查,而非依赖 review。将仅类型导入的强制执行交给 verbatimModuleSyntax,而非 consistent-type-imports lint 规则——同时运行两者是冗余的,可能产生相互冲突的错误。在 CI 中对每个 PR 运行 tsc --noEmit 作为硬性门控,并配合迁移方案中的 type-coverage 棘轮机制。在所有自动化之上,保留一条人工守护原则:清晰优于巧妙。一个需要资深工程师花十分钟才能读懂的深层嵌套条件映射类型是一种负担,而非炫技——大型项目中的大多数类型代码应该是无聊的、可读的、显而易见的。
贯穿始终的是一致性,而非复杂性。开启 strict 遗漏的两个标志,让非法状态无法通过编译,生成并验证你的边界,让 CI 强制执行其余的一切。本周就以上面带版本标注的 tsconfig 作为起点,然后将 type-coverage 棘轮对准你最薄弱的目录,开始逐步提升。
常见问题
strict 模式对于大型 TypeScript 项目来说足够了吗?
不够。自 TypeScript 6.0 起,strict 在编译器层面已默认为 true,因此它是基准而非成就。对大型代码库影响最大的两个标志根本不在 strict 系列中:noUncheckedIndexedAccess(为未声明的索引访问添加 undefined)和 exactOptionalPropertyTypes(区分属性被设为 undefined 与属性缺失这两种情况)。请显式启用这两个标志,然后添加边界类型和运行时验证。
TypeScript 中 interface 和 type 有什么区别,各自应该在什么时候使用?
对公开的、可扩展的对象契约使用 interface,因为它支持声明合并,在大型对象结构上通常能产生更清晰的错误信息。对联合类型、交叉类型、映射类型和条件类型使用 type,这些是 interface 无法表达的。对于大型团队,实际的做法是:将这个规范确定一次,用 lint 规则强制执行,然后停止争论。对于普通对象结构,两者编译后的类型检查完全相同,因此选择的关键在于表达能力和一致性,而非能力差异。
从 OpenAPI 或 Prisma 生成的类型是否使运行时验证变得不必要?
不。生成的类型仅是编译时的承诺。一个被标注为 User 类型的 JSON 响应只是被断言与该结构匹配;编译器在运行时从不检查实际载荷,因此后端变更或空字段仍然会悄然通过。生成类型能防止前后端在契约上产生漂移,但你仍然需要使用 Zod 或 Valibot 等 schema 库在每个外部边界验证真实载荷。请从 schema 派生静态类型,使一个定义同时守护两个层面。
如何在候选发布阶段安装和运行 TypeScript 7.0?
使用 npm install -D typescript@rc 安装并运行标准的 tsc 二进制文件。在 2026 年 6 月的候选发布版中,基于 Go 的原生编译器已从独立的 native-preview 包迁移到常规的 typescript npm 包中,因此候选版不再有独立的 tsgo 二进制文件;旧的 tsgo 和 typescript native-preview 包现在仅承载每日构建版本。Microsoft 报告称,7.0 在大型代码库上通常比 6.0 快约 10 倍。请将版本和阶段信息视为动态变化的,在采用前务必重新确认。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k