本节目标:理解
tsconfig.json里target、lib、module、strict各自控制什么,知道它们配错时会出现什么症状,并能根据「运行环境」反推出该配什么。读完你能读懂任何一份 tsconfig,也能自己从零写一份。
16.1 编译目标与严格模式配置
第 2 章我们用 tsc --init 生成过一份 tsconfig,当时只求「能跑起来」。现在到了收口的时候:真实项目里这份文件会被团队反复讨论、反复修改,而争论的焦点几乎总落在两处——编译目标与严格模式。
前者决定你的代码能跑在什么环境上,后者决定编译器愿意替你拦下多少错误。两者都不是「越新越好」「越严越好」的简单问题,它们要和运行时、依赖库、团队现状一起权衡。
16.1.1 target:决定产物里能用哪些语法
target 控制的是输出 JavaScript 的语法版本。写一句可选链:
const name = user?.profile?.name;
如果 target 是 es2020 或更高,产物里保留 ?.;如果是 es5,编译器会把它改写成等价的逻辑判断:
var _a, _b;
const name =
(_b =
(_a = user === null || user === void 0 ? void 0 : user.profile) === null ||
_a === void 0
? void 0
: _a.name) !== null && _b !== void 0
? _b
: undefined;
这就是 target 最直观的作用:语法降级。它只关心语法,不关心 API——你写 Array.prototype.includes,无论 target 是多少,编译器都不会把它改写成 indexOf,因为那是「库函数」,不是「语法」。
各档位的含义可以这样记:
| target | 大致对应环境 | 典型特征 |
|---|---|---|
es5 | 老浏览器、老 Node | 没有 let/const、箭头函数、可选链 |
es2015 | Node 6+、现代浏览器 | 有 class、Promise、解构 |
es2017 | Node 8+ | 有 async/await |
es2020 | Node 14+ | 有可选链、空值合并、BigInt |
es2022 | Node 18+ | 有顶层 await、类字段、at() |
esnext | 只在最新运行时 | 保留当前支持的全部新语法 |
怎么选? 一个可靠的判据是「你的代码实际运行在哪」。Node 服务端项目,直接对照 Node 版本:Node 18 以上就配 es2022。浏览器项目则看你支持的浏览器范围——但如果你用打包器(下一节讲),通常可以把 target 设高一些,把降级交给打包器与 browserslist 处理。
16.1.2 lib:可用 API 的清单
target 只给语法,lib 才给类型定义层面的内置 API。默认情况下,lib 会跟着 target 推导(target: es2020 相当于 lib: ["es2020"]),但一旦你手动写了 lib,默认值就被完全覆盖。
这带来一个非常经典的坑:在 Node 项目里想用 console,结果报错。
error TS2584: Cannot find name 'console'. Do you need to change your
'lib' compilation option to include 'dom'?
原因是这份配置里手动写了 "lib": ["es2020"],把默认的 DOM 类型定义挤掉了。而 console 在 TypeScript 的类型体系里被归到 DOM 里(历史上如此),Node 项目要显式补上:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"types": ["node"]
}
}
这里要分清两个容易混淆的字段:
| 字段 | 作用 | 举例 |
|---|---|---|
lib | 内置 API 的类型声明(语言自带) | es2022、dom、webworker |
types | 要自动加载的 @types 包(第三方) | node、jest、vite/client |
types 的默认行为是「加载 node_modules/@types 下的全部包」,这在大型项目里会拖慢编译、还会互相污染全局类型。所以工程实践里常显式收窄:
{
"compilerOptions": {
"types": ["node"]
}
}
16.1.3 module 与 moduleResolution:产物格式与解析策略
module 决定产物的模块格式,moduleResolution 决定编译器怎么找到 import 的目标。这两个选项组合错了,症状往往不是编译报错,而是运行时 ERR_MODULE_NOT_FOUND 或 Cannot use import statement outside a module。
先看 module 的常见取值:
| module | 产物格式 | 适用场景 |
|---|---|---|
commonjs | require/exports | 老 Node 项目、CJS 生态库 |
esnext / es2022 | 原生 import/export | 现代 Node、打包器 |
nodenext | 按 package.json 的 type 决定 | 双格式库、严格 ESM 项目 |
preserve | 原样保留 | 交给打包器处理 |
新手最容易踩的坑是:target 设得很新,module 却还是 commonjs,于是产物里出现 require,而你用的是 import。TypeScript 通常会在这种情况下给出提示:
error TS5110: Option 'module' must be set to 'NodeNext' when
option 'moduleResolution' is set to 'NodeNext'.
现代配置的推荐组合按场景分三类:
// 1) Node 应用(package.json 里 "type": "module")
{ "module": "nodenext", "moduleResolution": "nodenext" }
// 2) 前端项目(交给 Vite / webpack 打包)
{ "module": "preserve", "moduleResolution": "bundler" }
// 3) 发布给 CJS 消费者的库
{ "module": "commonjs", "moduleResolution": "node10" }
bundler 这个解析模式是给打包器用的:它允许省略扩展名(import "./util")、允许读 exports 字段,规则比 nodenext 宽松。但注意:moduleResolution: "bundler" 产出的代码不能直接 node 运行,因为它可能留下没写扩展名的相对导入。运行环境和构建工具必须对齐——这块的细节在第 11 章 11.2 ESM/CJS 互操作与 moduleResolution
有更完整的展开。
16.1.4 strict:不是一个开关,是一组开关
很多人以为 "strict": true 是一件事。其实它是一个总开关,一次性打开下面这一整组子选项:
| 子选项 | 打开后拦住什么 |
|---|---|
noImplicitAny | 隐式推断成 any 的参数与变量 |
strictNullChecks | null / undefined 未检查就使用 |
strictFunctionTypes | 函数参数的双变(bivariant)检查 |
strictBindCallApply | bind / call / apply 的参数类型 |
strictPropertyInitialization | 类字段未初始化 |
noImplicitThis | 隐式的 this: any |
alwaysStrict | 产物里注入 "use strict" |
useUnknownInCatchVariables | catch 的变量是 unknown 而非 any |
之所以要把它设计成一组,是因为这些检查互相依赖:没有 strictNullChecks,strictPropertyInitialization 几乎无从判断;没有 noImplicitAny,很多地方干脆退化成 any,后面的检查全部失效。
所以工程上的结论很明确:要么全开,要么别声称自己在用严格模式。逐个挑着开,收益远小于心智负担。
16.1.5 最有价值的那一个:strictNullChecks
如果只能开一个,就开 strictNullChecks。它带来的变化是根本性的:null 和 undefined 不再是所有类型的「合法子集」,而是各自独立的类型。
关闭时:
function getLength(s: string) {
return s.length;
}
// 编译通过,运行时崩:TypeError: Cannot read properties of undefined
getLength(undefined as any);
开启后,同样的调用会直接在编译期被拒绝:
function getLength(s: string) {
return s.length;
}
// error TS2345: Argument of type 'undefined' is not assignable to parameter of type 'string'.
getLength(undefined);
它还会逼你把「可能没有值」这件事写进类型里。下面这段代码在开启后无法通过,因为 find 的返回类型是 User | undefined:
const user = users.find((u) => u.id === id);
console.log(user.name);
// error TS18048: 'user' is possibly 'undefined'.
正确的写法是显式处理:
const user = users.find((u) => u.id === id);
if (!user) {
throw new Error(`user ${id} not found`);
}
console.log(user.name); // 收窄后类型为 User
这种「编译器逼着你处理边界」的体验,正是严格模式的核心价值。第 7 章 7.3 类型守卫与控制流分析 讲的收窄机制,就是为它服务的。
16.1.6 另外几个值得单独说的开关
除了 strict 那一组,还有几个开关虽不属于 strict,但对工程质量影响很大:
{
"compilerOptions": {
// 数组/对象索引访问返回 T | undefined,而非 T
"noUncheckedIndexedAccess": true,
// 只读属性不能被写入(配合 readonly 修饰符)
"noImplicitOverride": true,
// 未使用的局部变量报错
"noUnusedLocals": true,
// 未使用的参数报错(下划线开头的参数豁免)
"noUnusedParameters": true,
// switch 语句漏掉 case 时返回 undefined 要报错
"noFallthroughCasesInSwitch": true,
// 让 import type 被显式使用,避免运行时副作用
"verbatimModuleSyntax": true
}
}
其中 noUncheckedIndexedAccess 争议最大。打开后,下面这段「看起来没问题」的代码会报错:
const first = list[0];
// 类型是 string | undefined,而不是 string
console.log(first.toUpperCase());
// error TS18048: 'first' is possibly 'undefined'.
有人觉得它太啰嗦,有人觉得它拦住了真实的越界 bug。折中方案是在核心模块开启、边缘脚本关闭,但更常见的做法是:新项目一律打开。
16.1.7 老项目如何渐进迁移到严格模式
对一个已经跑了几年的项目直接 "strict": true,通常意味着几百个报错,没人愿意修。可行的路线是分阶段:
第一步,先关掉输出,只统计错误量。 不要被 IDE 里满屏红线吓到,先量化:
npx tsc --noEmit --strict 2>&1 | grep -c "error TS"
第二步,按「收益 / 成本」排序逐个开。 建议顺序如下:
| 顺序 | 开关 | 原因 |
|---|---|---|
| 1 | noImplicitAny | 拦住最多的隐性 bug,改动集中在加标注 |
| 2 | strictNullChecks | 收益最大,但改动也最多,需要专门排期 |
| 3 | noImplicitThis | 影响面小,通常很快能清干净 |
| 4 | alwaysStrict | 零成本,直接开 |
| 5 | 其余 strict* 子项 | 依次补齐 |
第三步,用 // @ts-expect-error 做临时豁免,但要可追踪。 与 @ts-ignore 不同,@ts-expect-error 在错误消失后会反过来报错,逼你删掉豁免:
// @ts-expect-error 遗留代码:等待重构(TICKET-1234)
legacyCall();
第四步,用 lint 规则防止回退。 可以禁止新增 any 与 @ts-ignore,把严格度锁住。这部分和第 15 章 15.3 覆盖率、lint 与 CI 门禁
的 CI 门禁思路一致。
16.1.8 高频报错速查
| 报错 | 原因 | 处理 |
|---|---|---|
TS2584: Cannot find name 'console' | lib 被覆盖,丢了 DOM | 加 "dom" 或装 @types/node |
TS2304: Cannot find name 'process' | 缺 Node 类型 | types: ["node"] + 安装 @types/node |
TS18048: X is possibly 'undefined' | 开了 strictNullChecks | 显式判空或收窄 |
TS7006: Parameter 'x' implicitly has an 'any' type | 开了 noImplicitAny | 补类型标注 |
TS2564: Property has no initializer | 开了 strictPropertyInitialization | 初始化或用 ! 断言 |
TS5110: Option 'module' must be set to 'NodeNext' | module 与 moduleResolution 不匹配 | 两者改成同一档 |
一条通用排查建议:先用 npx tsc --showConfig 看编译器实际读到的配置。它会把你继承的 extends、默认值全部展开,很多「明明配了却不生效」的问题,答案就在这里。
npx tsc --showConfig | head -40
如果你对 target / strict 的取舍还想看更细的工程实践,可以延伸阅读本站的 TypeScript 严格模式配置实践
与 TypeScript 项目架构与 tsconfig 设计
。
小结
本节把 tsconfig 里最关键的两组选项拆开了。target 管语法降级,lib 管可用 API,module 与 moduleResolution 管产物格式与解析策略,三者必须和运行环境对齐,否则会出现「编译过、运行崩」的错配。strict 则是一组互相依赖的子开关,其中 strictNullChecks 的价值最高——它把「可能没有值」变成类型系统能表达的事实。最后我们给出老项目渐进开启严格模式的四步路线,以及一张高频报错速查表。
到这里,你的代码「编译出来是什么」已经完全可控了。但 tsc 慢,而且不负责打包。下一节 16.2 esbuild/swc/tsup 与打包产物
会讲清楚:当编译交给更快的新工具,产物格式、sourcemap、tree-shaking 这些事又该怎么管。
阅读导航:上一节:15.3 覆盖率、lint 与 CI 门禁 · 下一节:16.2 esbuild/swc/tsup 与打包产物 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。