本节目标:把
tsconfig.json从「复制来的神秘文件」变成你能逐行解释的配置。读完后你会知道strict到底开了什么、还有哪些更严的开关值得打开、module与moduleResolution为什么要配套,以及怎样用三层extends让编辑器检查与构建产物各用各的规则。
1.2 严格模式与 tsconfig 分层
上一节搭好的脚手架里,pnpm typecheck 跑的是 tsc --noEmit。这条命令读的就是 tsconfig.json。可以说,这个文件决定了 TypeScript 在你项目里的「性格」——它宽容还是严格、认识哪些文件、按哪套模块规则解析导入,全部由它拍板。
大多数项目的 tsconfig.json 是从某个模板复制来的,一放就是三年。这很危险:模板往往开着 strict: false 以便「先跑起来」,然后所有人都在宽松模式下写代码,等到想收紧时已经有几万行需要修。所以这一节我们从「为什么要严」讲起。
1.2.1 tsc 如何找到配置
先建立一个准确的心智模型。执行 npx tsc 时,编译器的行为分三步:
- 从当前目录向上逐级查找
tsconfig.json,找到第一个就用它; - 读取
files/include/exclude决定哪些文件进编译; - 读取
compilerOptions决定怎么编译这些文件。
如果既没写 files 也没写 include,默认包含当前目录下所有 .ts、.tsx、.d.ts,并自动排除 node_modules、bower_components、jspm_packages 和 outDir。
一个常见误用是以为 exclude 能挡住 import:
{
"exclude": ["src/legacy/**"]
}
exclude 只影响「自动扫描」的范围。如果 src/index.ts 里 import "./legacy/old",那个文件依然会被拉进来编译。要真正隔离,得靠独立的 tsconfig 文件与项目引用(project references)。这一点在设计 monorepo 时尤其重要,第 2.1 节会再展开。
想更系统地理解 tsconfig 在项目架构中的位置,可以延伸阅读 TypeScript 项目架构与 tsconfig 。
1.2.2 strict:一个开关,六项检查
strict: true 不是一个检查,而是一组检查的总开关。把它打开,等于同时打开了下面这些:
| 子选项 | 关掉时会发生什么 | 打开后的收益 |
|---|---|---|
noImplicitAny | 没写类型的参数被默默当成 any | 逼你显式声明,或让编译器推导 |
strictNullChecks | null 和 undefined 可赋给任何类型 | 空值必须被显式处理 |
strictFunctionTypes | 函数参数按双变(bivariant)检查 | 参数按逆变检查,回调更安全 |
strictBindCallApply | call/apply/bind 不校验参数 | 三者获得完整类型检查 |
strictPropertyInitialization | 类字段可以不初始化 | 构造完成时字段必须有值 |
useUnknownInCatchVariables | catch (e) 里 e 是 any | e 是 unknown,必须收窄后再用 |
其中最有价值的是 strictNullChecks。它把 null 和 undefined 从「可以赋给任何类型」变成「独立类型」,于是下面这段代码会直接报错:
function getLength(text: string) {
return text.length;
}
const value = process.env.APP_NAME;
getLength(value);
// 错误:类型 'string | undefined' 的参数不能赋给类型 'string' 的参数
在 strict: false 下这段代码静默通过,然后在生产环境抛出 Cannot read properties of undefined。TypeScript 的绝大部分实际价值,就来自把这类运行时崩溃提前到编译期。
至于 useUnknownInCatchVariables,它带来的差别很具体:
try {
await riskyOperation();
} catch (e) {
// strict: false 下 e 是 any,下面这行能通过
// strict: true 下 e 是 unknown,这行报错
console.error(e.message);
}
// 正确写法:先收窄
try {
await riskyOperation();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error(message);
}
结论很直接:新项目一律 strict: true。 老项目迁移的策略是先打开、把报错当成待办清单逐条修,实在来不及可以先 // @ts-expect-error 并附上工单号,但绝不能长期关闭。迁移路径可延伸阅读 TypeScript 大版本升级
与 从 JavaScript 迁移到 TypeScript
。
1.2.3 严格之外的五个护栏
strict: true 是起点,不是终点。下面五项不在 strict 家族里,但强烈建议打开。
noUncheckedIndexedAccess —— 索引访问的结果自动加上 undefined:
const list = ["a", "b", "c"];
const first = list[0];
// 不加此选项:first 类型是 string
// 加上此选项:first 类型是 string | undefined
// 于是下面这行会报错,逼你处理越界
console.log(first.toUpperCase());
// 正确写法
console.log(first?.toUpperCase() ?? "(empty)");
这个选项是性价比最高的一个。数组越界和对象键缺失是运行时崩溃的高频来源,而它一次性堵住了。
exactOptionalPropertyTypes —— 区分「属性不存在」与「属性值为 undefined」:
interface Options {
retries?: number;
}
// 不加此选项:允许显式传 undefined
const a: Options = { retries: undefined };
// 加上此选项:上面这行报错,因为 retries 的类型是 number,不是 number | undefined
// 想允许显式 undefined,必须写成 retries?: number | undefined
它让可选属性的语义变精确,代价是配置对象多了些声明负担。
noImplicitOverride —— 重写父类方法必须写 override:
class Base {
save(): void {}
}
class Child extends Base {
save(): void {}
// 错误:此成员必须有 'override' 修饰符,因为它重写了基类中的成员
override save(): void {}
// 正确
}
好处是重命名父类方法时,子类会立刻报错,而不是悄悄变成两个不相干的方法。
noPropertyAccessFromIndexSignature —— 索引签名只能点不到:
interface Env {
[key: string]: string;
}
declare const env: Env;
env.PORT;
// 错误:属性 'PORT' 来自索引签名,请使用 ['PORT'] 访问
env["PORT"]; // 正确
它强迫你在「确定存在的字段」和「可能不存在的动态键」之间做出视觉区分。
noFallthroughCasesInSwitch —— 禁止 switch 意外穿透,这个几乎没有争议,直接开。
1.2.4 module 与 moduleResolution 必须配套
这是最容易配错的一组。两者的关系是:module 决定产物用哪种模块语法,moduleResolution 决定导入语句按什么规则去找文件。
常见的三种组合:
| 场景 | module | moduleResolution | 说明 |
|---|---|---|---|
| 现代 Node 应用 | NodeNext | NodeNext | 要求导入写完整扩展名,最严格 |
| 打包器构建(tsup/vite) | ESNext | Bundler | 允许省略扩展名,交给打包器解析 |
| 老库需要兼容 CJS | CommonJS | Node10 | 只在必须发布 CJS 时使用 |
上一节我们用 tsup 做构建,所以推荐 Bundler:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
为什么?因为 Bundler 模式专为「产物会被打包器处理」的场景设计:它允许 import "./utils" 这样省略扩展名的写法(打包器会补全),也支持 exports 字段的条件导出。而 NodeNext 要求你连 .js 扩展名都写全,虽然更严格,但在打包场景下是多余的负担。
反过来,如果你直接把 tsc 产物扔给 Node 运行,就必须用 NodeNext,否则 Node 会报 ERR_MODULE_NOT_FOUND。选哪套,取决于「产物最终由谁执行」。模块解析的完整细节可延伸阅读 TypeScript 模块解析:ESM 与 CJS
。
target 则相对独立,它只影响语法降级:target: "node20" 表示「产出的 JS 允许使用 Node 20 支持的全部语法」。它与 package.json 的 engines.node 应当保持一致,否则可能出现「声明支持 Node 18 但产物用了 Node 20 才有的语法」。
1.2.5 verbatimModuleSyntax 与 isolatedModules
这两个选项解决的是同一类问题:让每个文件能被独立处理。
isolatedModules: true 要求每个文件的转译不依赖其他文件的信息。它禁止了「只导出类型」的模糊写法:
// 错误:在 isolatedModules 下,仅类型导出必须用 export type
export { User } from "./types";
// 正确
export type { User } from "./types";
原因很实际:tsx、esbuild、swc 都是逐文件转译的,它们无法判断 User 是类型还是值。如果不加 type 关键字,转译器会保留这行导入,运行时就会去找一个不存在的导出。
verbatimModuleSyntax: true 更进一步,要求导入语句原样保留,不做任何智能擦除:
import type { Config } from "./config";
import { loadConfig } from "./config";
// 允许:类型用 import type,值用普通 import
// 禁止:把类型混在值导入里,指望编译器帮你删
打开它之后,写法的约束更明确,产物也更可预测。两个选项都建议开启,尤其当你用 tsx 或 tsup 时——它们正是逐文件转译器。
1.2.6 三层 tsconfig:base / 应用 / 构建
现在把上面的选项组装起来。单文件配置的问题在于「编辑器要看的」和「构建要做的」往往不一致,所以用三层结构:
第一层:tsconfig.base.json —— 所有严格规则,不涉及路径。
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
skipLibCheck: true 是唯一一处「放宽」:它跳过 node_modules 里 .d.ts 文件之间的互相检查。这不是偷懒,而是必要——第三方库的类型声明冲突你无法修复,开着只会得到一堆无解的报错,同时显著拖慢编译。
第二层:tsconfig.json —— 编辑器与 pnpm typecheck 用。
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "scripts/**/*.ts", "tsup.config.ts"]
}
关键在 noEmit: true:这个配置只检查、不产出。编辑器默认读的就是它。
第三层:tsconfig.build.json —— 只用于需要 tsc 产出 .d.ts 的场景。
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
注意它 include 只留 src,把 scripts 和配置文件排除在产物之外。
分层的好处是改动点唯一:想收紧某个规则,只改 tsconfig.base.json,应用与构建两处自动继承。想了解更复杂的项目引用与增量编译组织方式,可延伸阅读 TypeScript 工程化进阶
与 TypeScript 构建性能优化
。
1.2.7 增量编译与构建缓存
项目变大后,全量类型检查会成为瓶颈。两个缓解手段:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"
}
}
incremental 让 tsc 把上次的编译状态写入 .tsbuildinfo,下次只重查变化的文件。把缓存文件放进 node_modules/.cache 而不是项目根,是为了不污染工作区——这也是为什么 .gitignore 里要写 *.tsbuildinfo。
还有一个实测有效的技巧:用 --watch 常驻一个类型检查进程。
npx tsc --noEmit --watch --preserveWatchOutput
这样类型错误会在保存的瞬间出现,而不用等 CI。它和编辑器提示互补:编辑器可能因为内存或版本问题漏报,独立进程不会。
1.2.8 常见错误信息与排查
错误一:Cannot find module './xxx' or its corresponding type declarations.
先看 moduleResolution。Bundler 模式下 import "./utils" 合法;NodeNext 下必须写 import "./utils.js"。切换模块解析模式前,先确认产物由谁执行。
错误二:Option 'moduleResolution' must be set to 'Bundler' when option 'module' is set to 'Preserve'.
这是「必须配套」的典型报错。把 module 与 moduleResolution 当成一对参数来改,不要只动其中一个。
错误三:Property 'xxx' does not exist on type 'unknown'.
这是 useUnknownInCatchVariables 或 noImplicitAny 生效的结果,不是 bug。按类型收窄写:
if (e instanceof Error) {
console.error(e.message);
}
错误四:打开 exactOptionalPropertyTypes 后大量对象字面量报错。
根因通常是「用 undefined 表示缺省」的旧习惯。两个选择:把属性类型改成 foo?: T | undefined,或在构造对象时用条件展开:
const payload = {
id,
...(retries === undefined ? {} : { retries }),
};
错误五:tsc 很慢。
先跑 npx tsc --noEmit --extendedDiagnostics,它会打印每个阶段的耗时和文件数。如果 Files 数量远超预期,说明 include 太宽,把测试、脚本、产物目录误纳入了。
1.2.9 配置自检清单
| 检查项 | 通过标准 |
|---|---|
strict | 为 true |
| 额外护栏 | noUncheckedIndexedAccess 等五项已开 |
module / moduleResolution | 成对且与产物执行方式匹配 |
isolatedModules / verbatimModuleSyntax | 均为 true |
| 分层结构 | 存在 base 与应用两层,必要时加构建层 |
skipLibCheck | true |
tsBuildInfoFile | 指向 node_modules/.cache 下 |
小结
本节把 tsconfig.json 拆成了三层来看:strict 是六项检查的总开关,其中 strictNullChecks 贡献了 TypeScript 的大部分实际价值;noUncheckedIndexedAccess 等五项是严格之外更值得开的护栏;module 与 moduleResolution 必须成对配置,选哪套取决于产物由 Node 还是打包器执行;isolatedModules 与 verbatimModuleSyntax 则保证逐文件转译不出错。最后用 base / 应用 / 构建三层 extends 把「编辑器检查」与「构建产出」分开,让规则改动只有一个入口。
配置就位后,脚手架与类型规则都已经立起来,但还缺一道「人为失误的防线」——代码风格不统一、提交信息乱写、类型检查被本地绕过。下一节 1.3 代码规范与提交门禁(ESLint / Biome / husky) 就把这些约束落到自动化工具上。如果你对 TypeScript 严格模式能带来的类型表达能力更感兴趣,可以延伸阅读 TypeScript 严格模式配置 与 TypeScript 高级类型 。
阅读导航:上一节:1.1 从零搭建(pnpm / tsx / tsup) · 下一节:1.3 代码规范与提交门禁(ESLint / Biome / husky) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。