《TypeScript编程实战》1.2 严格模式与 tsconfig 分层

本节把 tsconfig.json 讲成可维护的配置:先解释 strict 总开关包含哪几项检查,再补齐 noUncheckedIndexedAccess、exactOptionalPropertyTypes 等护栏,然后梳理 module 与 moduleResolution 的联动,最后用三层 extends 把编辑器检查与产物构建分开。读完你能判断每个选项该开还是该关,并看懂常见报错。

本节目标:把 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 时,编译器的行为分三步:

  1. 从当前目录向上逐级查找 tsconfig.json,找到第一个就用它;
  2. 读取 files / include / exclude 决定哪些文件进编译;
  3. 读取 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逼你显式声明,或让编译器推导
strictNullChecksnull 和 undefined 可赋给任何类型空值必须被显式处理
strictFunctionTypes函数参数按双变(bivariant)检查参数按逆变检查,回调更安全
strictBindCallApplycall/apply/bind 不校验参数三者获得完整类型检查
strictPropertyInitialization类字段可以不初始化构造完成时字段必须有值
useUnknownInCatchVariablescatch (e) 里 e 是 anye 是 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 决定导入语句按什么规则去找文件。

常见的三种组合:

场景modulemoduleResolution说明
现代 Node 应用NodeNextNodeNext要求导入写完整扩展名,最严格
打包器构建(tsup/vite)ESNextBundler允许省略扩展名,交给打包器解析
老库需要兼容 CJSCommonJSNode10只在必须发布 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 与应用两层,必要时加构建层
skipLibChecktrue
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) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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