Monorepo 文件级 TypeScript 类型检查工具原理与面试问题解析

Monorepo 文件级 TypeScript 类型检查工具原理与面试问题解析

一、工具解决的核心问题

这段工具的目标是:

在 monorepo 中,只把指定文件的类型错误作为检查结果输出,同时仍然使用该文件所属项目的完整 TypeScript 上下文进行分析。

它不是简单地孤立编译单个文件,而是执行以下流程:

1
2
3
4
5
6
7
8
9
10
11
接收待检查文件
↓
为每个文件向上寻找最近的 tsconfig.json
↓
按照 tsconfig.json 对文件分组
↓
每个分组创建一个包含完整项目上下文的 Program
↓
从 Program 中取得目标文件对应的 SourceFile
↓
只输出这些目标文件的 TypeScript diagnostics

它的核心思路可以概括为:

使用完整项目上下文保证类型判断正确,再把诊断结果限制在本次关心的文件中。


二、完整执行流程

1. 加载与项目一致的 TypeScript

工具首先调用 loadTypeScript() 加载 TypeScript:

1
const tsPath = require.resolve("typescript", { paths: [cwd] });

优先使用当前项目安装的 TypeScript;如果项目中不存在,再使用工具自身携带的 TypeScript。

这样做的原因是不同 TypeScript 版本可能存在以下差异:

  • 类型系统行为不同;
  • 新语法支持程度不同;
  • 编译选项不同;
  • diagnostics 错误码和错误信息不同。

优先加载项目版本,可以让工具的诊断结果尽量与项目直接运行 tsc 的结果一致。

2. 确定待检查文件

工具支持两种输入方式。

直接指定文件:

1
ts-check-monorepo packages/a/src/foo.ts packages/b/src/bar.tsx

扫描所有文件:

1
ts-check-monorepo --all

使用 --all 时,工具优先扫描 packages 目录;如果该目录不存在,则扫描当前目录。

扫描时会排除:

  • node_modules;
  • dist;
  • build;
  • .git;
  • 隐藏目录;
  • .d.ts 文件;
  • .spec.* 测试文件;
  • .test.* 测试文件。

这里的排除只影响 --all 的文件发现过程,并不意味着这些文件永远不会进入 TypeScript Program。例如,被源码引用的 .d.ts 仍可能作为类型上下文被编译器加载。

3. 为文件寻找所属的 tsconfig

findTsConfigPath() 从文件所在目录开始逐级向上查找最近的 tsconfig.json:

1
2
3
4
5
6
7
8
packages/app/src/components/Button.tsx
↓
packages/app/src/components/tsconfig.json
↓ 没有
packages/app/src/tsconfig.json
↓ 没有
packages/app/tsconfig.json
↓ 找到

monorepo 中的不同 package 往往拥有不同配置,例如:

  • 不同的 strict 设置;
  • 不同的 jsx 设置;
  • 不同的 baseUrl 和 paths;
  • 不同的 types 和 lib;
  • 不同的模块解析策略。

因此,不能简单地用根目录的一份配置检查全部文件。

4. 按 tsconfig 对文件分组

工具使用一个 Map 保存分组结果:

1
Map<tsconfigPath, files[]>

例如:

1
2
3
4
5
6
packages/app/tsconfig.json
→ packages/app/src/a.ts
→ packages/app/src/b.tsx

packages/shared/tsconfig.json
→ packages/shared/src/types.ts

同一配置下的多个文件可以共享一个 Program,避免为每个文件重复读取配置、解析模块和创建类型上下文。

5. 读取和解析 tsconfig

工具先调用:

1
ts.readConfigFile(tsconfigPath, host.readFile)

读取 tsconfig.json,然后调用:

1
2
3
4
5
6
7
ts.parseJsonConfigFileContent(
configFile.config,
host,
path.dirname(tsconfigPath),
{},
tsconfigPath
);

解析以下配置:

  • files;
  • include;
  • exclude;
  • extends;
  • compilerOptions;
  • 文件路径基准;
  • 部分 Project References 信息。

解析结果中最重要的内容是:

1
2
3
parsedCmd.fileNames
parsedCmd.options
parsedCmd.errors

6. 创建完整 Program

代码使用以下方式创建 Program:

1
2
3
4
const program = ts.createProgram(
parsedCmd.fileNames,
parsedCmd.options
);

Program 表示一个完整的 TypeScript 编译项目,包含:

  • tsconfig 中的根文件;
  • 被 import 或 export 引用的依赖文件;
  • TypeScript 标准库声明;
  • 第三方类型声明;
  • 编译选项;
  • 模块解析结果;
  • SourceFile 集合;
  • Symbol 与类型系统上下文。

创建完整 Program 十分重要。单个文件的类型是否正确,往往依赖其他文件中的类型声明和导出信息。

例如:

1
2
3
4
5
import type { User } from "@shared/types";

const user: User = {
name: "Noelle"
};

如果没有按照项目的 paths、模块解析规则和依赖声明加载 User,就无法正确判断对象是否符合该类型。

7. 只获取指定文件的诊断

创建完整 Program 后,工具从中找到目标文件:

1
const sourceFile = program.getSourceFile(path.resolve(file));

然后执行:

1
2
3
4
const diagnostics = ts.getPreEmitDiagnostics(
program,
sourceFile
);

因此,这个工具并不是只解析目标文件,而是:

  • 用完整项目建立类型上下文;
  • 只提取目标 SourceFile 的诊断信息。

这是正确性和性能之间的折中。

8. 格式化、上报并返回退出码

每条诊断会被转换成以下信息:

  • TypeScript 错误码,如 TS2322;
  • 相对文件路径;
  • 行号和列号;
  • 错误说明;
  • 带源码上下文的终端输出。

工具还会调用 reportTscErrors() 上报错误:

1
2
3
4
5
{
ruleId: "TS2322",
errorLocation: "packages/app/src/foo.ts:10:5",
info: "Type 'string' is not assignable to type 'number'."
}

上报使用 Promise.allSettled(),即使上报失败,也不会改变类型检查的主要结果。

如果存在错误,脚本使用状态码 1 退出;没有错误则使用状态码 0。因此它可以直接作为 Git hook 或 CI 门禁。


三、为什么 tsc --noEmit 不完全满足这个场景?

普通命令:

1
tsc --noEmit -p packages/foo/tsconfig.json

会检查该 tsconfig 所包含的整个项目。

在大型 monorepo 中,这会带来两个常见问题:

  1. 只修改一个文件,却检查整个 package,反馈较慢;
  2. 项目中已有的历史错误也会导致检查失败,无法把本次提交与历史问题区分开。

这段工具创建完整 Program,却只输出指定文件的 diagnostics,因而更适合:

  • pre-commit 快速检查;
  • 对 PR 变更文件提供快速反馈;
  • 存在历史类型债务的渐进治理场景。

不过,它优化的主要是诊断范围,并不表示只处理一个文件。createProgram 仍然需要读取项目文件、解析依赖并建立类型上下文。


四、为什么 ESLint 类型规则也不够?

ESLint 的主要职责是代码规范和静态规则检查。即使使用 typescript-eslint 的类型感知规则,也不等价于完整的 TypeScript 编译器诊断。

TypeScript 编译器会处理:

  • 泛型约束;
  • 参数与返回值类型;
  • 类型赋值兼容性;
  • 函数重载匹配;
  • 模块导入与类型解析;
  • 声明生成相关问题;
  • strictNullChecks 等编译选项;
  • JSX 类型;
  • 跨文件类型推导。

例如:

1
const age: number = "18";

这在语法上完全合法,但 TypeScript 会产生 TS2322 语义错误。

ESLint 适合补充代码质量和规范检查,不能完整代替:

1
ts.getPreEmitDiagnostics(...)

此外,类型感知的 ESLint 规则通常也需要创建 TypeScript Program,因此并不必然比这类工具更轻量。


五、“文件级检查”的输入、输出和触发时机

输入

指定文件:

1
ts-check-monorepo packages/a/src/foo.ts packages/b/src/bar.tsx

扫描全部文件:

1
ts-check-monorepo --all

输出

工具可以输出:

  • tsconfig 读取或解析错误;
  • TypeScript 错误码;
  • 文件路径、行号和列号;
  • 带源码上下文的彩色诊断;
  • 总错误数量;
  • 成功或失败的进程退出码。

触发时机

代码本身没有监听 Git 或文件系统,也不会主动判断哪些文件发生了变化。它需要由外部流程触发,例如:

  • 编辑器保存后;
  • lint-staged 的 pre-commit 阶段;
  • pre-push 阶段;
  • CI 中检查 PR 变更文件;
  • 本地手动运行 --all。

六、本地与 CI 中的使用方式

本地配合 lint-staged

1
2
3
4
5
{
"lint-staged": {
"*.{ts,tsx}": "ts-check-monorepo"
}
}

lint-staged 会将 Git 暂存区中的文件路径作为命令行参数传给工具。

CI 中检查变更文件

CI 可以先通过 Git 或平台 API 获得 PR 的变更文件,再执行:

1
ts-check-monorepo <changed-files>

如果需要完整检查,则执行:

1
ts-check-monorepo --all

由于出现错误时会返回非零状态码,因此 CI 可以直接使用它作为质量门禁。


七、变更文件和受影响文件如何确定?

当前代码只接收待检查文件,没有自行计算 Git 变更文件。

变更文件通常由以下工具或流程提供:

  • lint-staged;
  • git diff --name-only;
  • CI 平台的 PR changed files API;
  • Nx 或 Turborepo 等任务系统。

更重要的是,这段代码没有真正计算“受影响文件集合”。

例如:

1
2
3
4
5
types.ts 被修改
↑
a.ts 导入 types.ts
↑
b.ts 导入 a.ts

如果只把 types.ts 传给工具,当前代码只要求输出 types.ts 的诊断,不会主动检查 a.ts 和 b.ts 是否因公共类型变化而产生错误。

虽然 Program 中具有依赖信息,但代码没有构建反向依赖图,也没有利用依赖图扩展目标文件集合。


八、依赖变化时如何避免漏检?

当前实现无法彻底避免依赖变化造成的漏检。

以下修改可能影响大量消费者:

  • 公共类型声明;
  • 被多个文件引用的工具函数;
  • package 的公共导出入口;
  • tsconfig.json;
  • package.json;
  • lockfile;
  • 第三方依赖版本。

推荐采用分层检查策略:

  1. 普通叶子文件变更:只检查变更文件;
  2. 公共类型或导出入口变更:检查所属 package;
  3. tsconfig、依赖或 lockfile 变更:检查全部受影响项目;
  4. 合并前或定期 CI:执行完整 tsc 或 tsc -b;
  5. 更成熟的工具:建立模块反向依赖图,递归计算所有消费者。

因此,这个工具适合作为快速反馈层,但不应完全替代全量类型检查。


九、性能与正确性如何同时保证?

这段代码采用了三个关键策略:

  1. 通过完整 tsconfig 文件列表创建 Program,保证类型上下文完整;
  2. 同一 tsconfig 下的多个文件共享一个 Program;
  3. 只提取目标文件的 diagnostics,控制结果范围。

假设一个 package 修改了 20 个文件:

  • 如果逐文件创建 Program,可能创建 20 次;
  • 当前实现按 tsconfig 分组,只创建 1 次 Program。

这能够避免重复执行:

  • tsconfig 解析;
  • 文件读取;
  • 模块解析;
  • AST 构建;
  • 类型环境初始化。

但它仍有优化空间:

  • 不同 tsconfig 分组目前串行执行;
  • 每次脚本启动都会重新创建 Program;
  • 没有使用 createIncrementalProgram;
  • 没有跨运行复用旧 Program;
  • 对多个文件分别调用 getPreEmitDiagnostics,全局或配置类诊断可能重复;
  • --all 使用同步递归扫描,大仓库中可能较慢。

十、具体使用了哪些 TypeScript Compiler API?

1. ts.readConfigFile

1
ts.readConfigFile(tsconfigPath, host.readFile)

用于读取并解析 tsconfig.json 的 JSON 内容,能够发现配置文件语法错误。

2. ts.parseJsonConfigFileContent

1
ts.parseJsonConfigFileContent(...)

将原始 tsconfig 转换成 TypeScript 编译器可以使用的配置,包括:

  • options;
  • fileNames;
  • errors;
  • projectReferences 等。

3. ts.sys

1
const host = ts.sys;

提供 TypeScript 默认的系统能力,例如:

  • 读取文件;
  • 判断文件是否存在;
  • 获取当前目录;
  • 获取换行符;
  • 读取目录。

4. ts.createProgram

1
ts.createProgram(parsedCmd.fileNames, parsedCmd.options)

创建整个 TypeScript 项目的编译上下文,是类型检查的核心。

5. program.getSourceFile

1
program.getSourceFile(path.resolve(file))

从 Program 中获得目标文件对应的 SourceFile。

SourceFile 包含源码文本、抽象语法树以及位置等信息。

6. ts.getPreEmitDiagnostics

1
ts.getPreEmitDiagnostics(program, sourceFile)

获取输出 JavaScript 或声明文件之前的诊断,通常涉及:

  • 配置和编译选项诊断;
  • 语法诊断;
  • 全局诊断;
  • 语义诊断;
  • 声明生成相关诊断。

7. ts.flattenDiagnosticMessageText

1
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")

将可能具有嵌套结构的诊断信息转换成便于显示和上报的普通字符串。

8. ts.formatDiagnosticsWithColorAndContext

用于生成带颜色、文件位置和源码上下文的终端错误信息。


十一、Program、SourceFile、CompilerHost 和 TypeChecker 的作用

Program

代表一个完整的 TypeScript 编译项目,管理文件、配置、依赖图和类型系统上下文。

SourceFile

代表一个源文件及其抽象语法树。当前工具通过它把诊断范围限制到指定文件。

CompilerHost

负责向编译器提供文件系统能力,例如读取文件、判断文件是否存在、创建 SourceFile 和写入输出文件。

这段代码没有显式创建自定义 CompilerHost。调用 createProgram 时没有传入 host,因此 TypeScript 会在内部使用默认 CompilerHost。

TypeChecker

TypeChecker 是 TypeScript 语义分析的核心,可以通过以下方法取得:

1
const checker = program.getTypeChecker();

它可以查询:

  • 表达式类型;
  • Symbol;
  • 函数签名;
  • 泛型实例化;
  • 类型之间的关系。

当前代码没有直接调用 getTypeChecker(),但 getPreEmitDiagnostics() 在语义检查过程中会间接驱动 TypeChecker。


十二、语法诊断和语义诊断的区别

语法诊断

不需要理解类型关系即可发现,例如:

1
const value =

表达式不完整、括号缺失等属于语法问题。

语义诊断

需要理解类型、Symbol 和跨文件关系,例如:

1
const age: number = "18";

语法正确,但字符串不能赋给数字,因此会产生语义错误。

又如:

1
2
import { foo } from "./utils";
foo.notExists();

编译器必须解析模块并取得 foo 的类型,才能判断 notExists 属性不存在。


十三、createProgram 的主要耗时

createProgram 及后续诊断的主要成本包括:

  1. 读取并解析根文件;
  2. 根据 import 和 export 构建依赖关系;
  3. 解析 node_modules 中的声明文件;
  4. 加载 TypeScript 标准库;
  5. 执行模块解析和路径映射;
  6. 构建 AST;
  7. 创建 Symbol 并绑定声明;
  8. 按需执行语义类型检查。

因此,“只输出一个文件的错误”不代表整个过程只读取该文件。


十四、Program 复用的条件

当前代码只实现了一次执行过程内的复用:

1
同一 tsconfig 下的多个目标文件 → 共享一个 Program

它没有实现跨脚本运行的复用。

如果要跨运行复用,需要考虑:

  • tsconfig 编译选项是否变化;
  • 根文件集合是否变化;
  • 哪些 SourceFile 已经变化;
  • 模块解析缓存是否仍然有效;
  • 依赖关系是否变化。

TypeScript 提供了更适合持续检查的 API:

  • ts.createIncrementalProgram;
  • ts.createSemanticDiagnosticsBuilderProgram;
  • ts.createWatchProgram;
  • TypeScript Language Service。

十五、不同 tsconfig 项目如何分组?

工具为每个文件寻找最近的 tsconfig.json,再按其绝对路径分组。

例如:

1
2
3
4
5
6
7
packages/
├── app/
│ ├── tsconfig.json
│ └── src/a.ts
└── shared/
├── tsconfig.json
└── src/b.ts

得到:

1
2
app/tsconfig.json    → [a.ts]
shared/tsconfig.json → [b.ts]

这种方式适合每个 package 都以 tsconfig.json 作为主要配置的仓库。

它也存在边界:仓库可能实际使用以下配置文件:

  • tsconfig.app.json;
  • tsconfig.build.json;
  • tsconfig.test.json;
  • tsconfig.node.json。

当前实现只查找固定名称 tsconfig.json,无法自动判断目标文件实际属于哪一种构建配置。


十六、Project References 和增量编译如何处理?

当前实现没有完整支持 Project References。

虽然 parseJsonConfigFileContent() 可以解析出:

1
parsedCmd.projectReferences

但代码创建 Program 时只传入了:

1
ts.createProgram(parsedCmd.fileNames, parsedCmd.options)

它没有把 projectReferences 传给 createProgram,也没有使用 Solution Builder,因此不等价于:

1
tsc -b

更完整的 Program 创建方式可以是:

1
2
3
4
5
ts.createProgram({
rootNames: parsedCmd.fileNames,
options: parsedCmd.options,
projectReferences: parsedCmd.projectReferences
});

复杂 monorepo 更适合考虑:

1
2
ts.createSolutionBuilder(...)
ts.createSolutionBuilderHost(...)

增量编译则可以使用:

1
ts.createIncrementalProgram(...)

并结合:

  • incremental: true;
  • composite: true;
  • .tsbuildinfo。

当前工具属于按 tsconfig 分组的普通 Program 检查,不是真正的 Project References 构建器或跨运行增量编译器。


十七、Vue 文件和非标准模块如何兼容?

当前代码不支持 .vue 文件,原因是:

  • 扫描器只接受 .ts 和 .tsx;
  • TypeScript 默认 CompilerHost 不认识 .vue;
  • 没有将 Vue SFC 转换成虚拟 TypeScript 文件;
  • 没有使用 Vue/Volar 的语言服务能力。

Vue 项目通常应使用:

1
vue-tsc --noEmit

或者基于 Volar 创建虚拟文件映射。

对于 CSS、图片等非标准模块:

1
import styles from "./index.module.css";

通常需要项目提供声明:

1
2
declare module "*.module.css";
declare module "*.png";

如果希望 Compiler API 原生处理特殊模块,则需要自定义:

  • CompilerHost;
  • resolveModuleNames;
  • resolveModuleNameLiterals;
  • 虚拟文件读取和转换逻辑。

当前代码没有实现这些能力。


十八、内存占用如何控制?

当前实现按 tsconfig 串行处理:

1
2
3
for (const [tsconfigPath, files] of filesByTsconfig) {
await runTscForGroup(tsconfigPath, files);
}

这有助于限制峰值内存:前一组检查结束后,如果没有其他引用,其 Program 可以被垃圾回收,不需要同时保留全部项目的 Program。

已有的内存控制措施还包括:

  • 扫描时跳过 node_modules、dist 和 build;
  • 同一 tsconfig 只创建一个 Program;
  • 不执行 emit;
  • 不在全局长期缓存 Program。

进一步优化方式包括:

  • 限制并行 Program 的数量;
  • 使用增量 Program 复用 AST;
  • 检查结束后释放 Program、SourceFile 和 diagnostics 引用;
  • 大型仓库按 package 分批检查;
  • 避免长期保留全部 diagnostics;
  • 必要时调整 Node.js 内存上限。

十九、这段实现的主要优点

1. 适配 monorepo 的多配置结构

不同 package 可以使用各自的 tsconfig.json,不会被根配置一刀切。

2. 使用项目自己的 TypeScript

诊断行为与项目实际执行的 tsc 更一致,避免版本差异。

3. 保留完整类型上下文

它不是通过 transpileModule 做孤立语法转换,而是创建完整 Program,因此能够进行跨文件语义检查。

4. 同组文件共享 Program

避免为同一项目中的每个文件重复加载配置和构建类型环境。

5. 诊断范围更聚焦

只输出目标文件相关的诊断,适合提交阶段的快速反馈和渐进式清理历史类型问题。

6. 不生成编译产物

只进行诊断,不写入 JavaScript 或声明文件,适合本地检查和 CI。

7. 输出适合工程化集成

它提供错误码、位置、上下文、统计数据以及标准退出码,容易接入 Git hooks、CI 和数据平台。

8. 上报失败不会影响检查结果

错误上报通过 Promise.allSettled() 执行,监控系统故障不会让类型检查本身失败。


二十、当前实现的正确性边界和改进方向

1. 目标文件可能不在 Program 中

如果传入文件不属于 tsconfig 的 files/include,且没有被其他文件导入:

1
program.getSourceFile(path.resolve(file))

可能返回 undefined。当前代码会静默跳过,建议增加明确警告或把目标文件加入 rootNames。

2. 没有计算反向依赖

公共类型变更可能破坏消费者,但工具不会自动检查这些消费者。可以构建模块反向依赖图,扩大检查范围。

3. 不完整支持 Project References

需要传入 projectReferences 或使用 Solution Builder,才能更接近 tsc -b 的行为。

4. 不支持 tsconfig 变体

可以允许命令行显式指定配置:

1
ts-check-monorepo --project tsconfig.build.json

或者根据项目规则映射不同文件到不同配置。

5. 诊断可能重复

对每个 SourceFile 分别调用 getPreEmitDiagnostics() 时,部分全局或配置诊断可能重复出现。可以拆分获取:

  • 全局诊断只取一次;
  • 语法和语义诊断按目标文件获取;
  • 最后按照错误码、文件和位置去重。

6. 不能完全替代全量检查

文件级检查主要解决快速反馈问题。最终合并门禁仍建议保留:

1
tsc --noEmit

或者:

1
tsc -b

Vue 项目则应保留:

1
vue-tsc --noEmit

二十一、面试中的总结回答

可以用下面这段话概括工具原理:

这个工具主要解决 monorepo 中按变更文件快速进行 TypeScript 类型检查的问题。它先为每个文件向上寻找最近的 tsconfig,再按照 tsconfig 对文件分组。每一组通过 TypeScript Compiler API 读取配置,并使用配置中的完整文件列表创建 Program,从而保留模块解析、跨文件类型和编译选项等完整上下文。随后,它从 Program 中取得目标文件的 SourceFile,只获取这些文件的 pre-emit diagnostics。这样既避免了孤立检查导致的不准确,也避免了对同一 tsconfig 下的每个文件重复创建 Program。

对于它与全量 tsc 的关系,可以补充:

它主要缩小诊断输出范围并减少重复初始化,适合作为 pre-commit 或 PR 阶段的快速反馈层。但它不会自动计算反向依赖,也没有完整实现 Project References 和增量构建,因此不能完全替代合并前的 tsc --noEmit 或 tsc -b。

对于性能和正确性的平衡,可以回答:

正确性来自使用 tsconfig 的完整 Program,而性能优化来自按 tsconfig 分组复用 Program,并只提取目标 SourceFile 的 diagnostics。它不是孤立编译单文件,而是在完整类型环境中限制检查结果范围。


二十二、tsc、tsc-files、公司基建与本工具的比较

这四种方案都可以用于 TypeScript 类型检查,但它们解决的问题和适用范围不同。

方案 检查范围 多 tsconfig 支持 是否按 tsconfig 分组复用 Program 主要特点 适用场景
原生 tsc 默认检查指定 tsconfig 覆盖的整个项目 可以分别指定配置,也可以结合 Project References 使用 tsc -b 不是以“变更文件分组检查”为目标 TypeScript 官方方案,正确性和能力最完整,但大型项目全量检查的反馈时间可能较长 合并前质量门禁、全量检查和规范使用 Project References 的项目
tsc-files 接收指定文件进行检查 更适合单一 tsconfig 或配置关系较简单的项目 不能直接完成 Monorepo 中跨多个 tsconfig 的自动路由和统一分组调度 接入简单,适合在 lint-staged 中检查指定 TypeScript 文件,维护成本较低 单项目、单一 tsconfig、文件级快速检查
公司基建 支持传入指定文件进行检查 支持多个 tsconfig 不会先按 tsconfig 对文件分组,同一配置下的多个文件可能重复创建检查上下文 能满足 Monorepo 多配置检查,但重复初始化会降低批量文件检查效率 已接入公司基础设施、文件数量较少或对检查耗时不敏感的项目
本工具 接收指定文件,也支持 --all 支持;自动为文件寻找最近的 tsconfig 支持;同一 tsconfig 下的文件共享一个 Program 面向 Monorepo 设计,在保留完整类型上下文的同时减少重复初始化,只输出目标文件的 diagnostics Monorepo 的 pre-commit、PR 变更检查、渐进式治理历史类型问题

1. 与原生 tsc 的区别

原生 tsc --noEmit -p <tsconfig> 会检查该配置覆盖的整个项目。它的检查范围最完整,适合作为最终质量门禁,但在大型 Monorepo 中,即使只修改少量文件,也可能需要检查大量未修改文件。

本工具仍然使用 TypeScript Compiler API 创建包含完整项目上下文的 Program,但最终只提取目标 SourceFile 的 diagnostics。它的目标不是替代 tsc,而是在提交和 PR 阶段提供更快、更聚焦的反馈。公共类型、公共导出、依赖、lockfile 或 tsconfig 发生变化时,仍应扩大检查范围,并在最终门禁中保留 tsc --noEmit 或 tsc -b。

2. 与 tsc-files 的区别

tsc-files 已经能够满足“传入指定 TypeScript 文件进行检查”的基础需求。对于单一 tsconfig 的普通项目,优先使用社区工具通常更简单,也能降低自研维护成本。

但 Monorepo 中的一次提交可能同时修改多个应用和公共包,而这些文件可能分别属于不同的 tsconfig。此时不仅需要接收文件,还需要完成以下编排:

  1. 为每个文件找到所属的 tsconfig;
  2. 按照 tsconfig 对文件分组;
  3. 控制每个分组只创建一次 Program;
  4. 统一收集、格式化和上报 diagnostics。

tsc-files 不能直接覆盖这一整套多配置路由与分组需求。如果继续在它外层编写这些逻辑,核心的 Monorepo 调度能力仍然需要自行实现,因此本工具选择直接使用 TypeScript 官方 Compiler API。

3. 与公司基建的区别

公司原有基建已经能够支持多 tsconfig,因此它并不是不能在 Monorepo 中工作。它的主要问题是没有先按照 tsconfig 对目标文件分组。

假设一次需要检查 20 个文件,并且它们全部属于同一个 tsconfig:

1
2
公司基建:多个文件分别进入检查流程,可能重复创建相同配置的检查上下文
本工具: 20 个文件先归入同一分组,该分组只创建一个 Program

重复创建检查上下文会重复发生配置读取、文件加载、模块解析、AST 构建和类型环境初始化。随着同组文件数量增加,这部分重复成本会更加明显。

本工具最核心的优化不是改变 TypeScript 的类型检查算法,而是在检查开始前建立:

1
Map<tsconfigPath, files[]>

随后每个分组只解析一次配置并创建一次 Program,再从共享的 Program 中依次获取目标文件的 diagnostics。因此,它在保留公司基建多 tsconfig 能力的基础上,进一步减少了同一配置下的重复初始化。

4. 本工具的定位

本工具可以概括为一个面向 Monorepo 的高效文件级 TypeScript 检查工具:

  • 相比原生 tsc,它把诊断结果聚焦到本次关心的文件,更适合快速反馈;
  • 相比 tsc-files,它增加了多 tsconfig 自动路由、分组调度和统一工程化集成;
  • 相比公司基建,它通过按 tsconfig 分组复用 Program,减少批量检查中的重复初始化;
  • 相比孤立编译单文件,它仍使用完整项目 Program,保留模块解析、声明文件、编译选项和跨文件类型信息。

因此,这四种方案并不是简单的替代关系。原生 tsc 适合完整质量门禁,tsc-files 适合配置简单的文件级检查,公司基建解决了多 tsconfig 支持问题,而本工具进一步针对 Monorepo 的批量变更场景优化了分组和 Program 复用效率。


Monorepo 文件级 TypeScript 类型检查工具原理与面试问题解析
http://example.com/2026/08/07/monorepo-ts-type-checker-explanation/
Author
John Doe
Posted on
August 7, 2026
Licensed under