《TypeScript编程入门》16.1 编译目标与严格模式配置

本节把 tsconfig.json 里最容易配错的两组选项讲透:编译目标与严格模式。先看 target、lib、module 三者如何共同决定产物语法与可用 API,再逐项拆开 strict 背后的多个子开关,说明每个开关能拦住哪一类真实 bug;最后给出老项目从低严格度到全严格的分阶段迁移路线,并汇总高频报错与排查方法。读完你能独立为任意项目写出一份有理有据的 tsconfig。

本节目标:理解 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、箭头函数、可选链
es2015Node 6+、现代浏览器有 class、Promise、解构
es2017Node 8+有 async/await
es2020Node 14+有可选链、空值合并、BigInt
es2022Node 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产物格式适用场景
commonjsrequire/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 的参数与变量
strictNullChecksnull / undefined 未检查就使用
strictFunctionTypes函数参数的双变(bivariant)检查
strictBindCallApplybind / call / apply 的参数类型
strictPropertyInitialization类字段未初始化
noImplicitThis隐式的 this: any
alwaysStrict产物里注入 "use strict"
useUnknownInCatchVariablescatch 的变量是 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"

第二步,按「收益 / 成本」排序逐个开。 建议顺序如下:

顺序开关原因
1noImplicitAny拦住最多的隐性 bug,改动集中在加标注
2strictNullChecks收益最大,但改动也最多,需要专门排期
3noImplicitThis影响面小,通常很快能清干净
4alwaysStrict零成本,直接开
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 与打包产物 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes