【TS】day22-tsconfig

作者:mario 发布时间: 2026-09-01 阅读量:6 评论数:0

TypeScript tsconfig 深度配置 — 编译器的驾驶舱,一次讲透

写了三周类型代码,你一直在和"编译器"打交道——但今天才正式坐进驾驶舱。tsconfig.json 里的每个开关,都在决定编译器以多严格的眼光审视你的代码。今天逐项拆解 strict 全家桶(8 个开关,每个配"关掉会漏掉什么错误"的对照实验),讲清 target/module/moduleResolution 三大坐标的选型逻辑,最后落地开发/测试/生产三环境的配置方案。今天之后,看到任何编译报错,你的第一反应不再是"搜错误信息",而是"哪个开关管这个"。


目录


一、tsconfig 是什么:编译器的指令集

1.1 从 tsc 说起

# TypeScript 编译器最基本的用法:
tsc app.ts

# 但每次都传一堆参数太蠢——tsconfig.json 就是"参数的持久化":
tsc          # 自动读取当前目录的 tsconfig.json

1.2 最小可用的 tsconfig

{
  "compilerOptions": {
    "target": "ES2020",        // 编译产物的 JS 版本
    "module": "ESNext",        // 模块系统
    "strict": true,            // 严格模式总开关(今天的主角)
    "outDir": "./dist"         // 产物输出目录
  },
  "include": ["src/**/*"],     // 参与编译的文件
  "exclude": ["node_modules", "dist"]
}

1.3 extends:配置的继承

// tsconfig.base.json —— 团队共享的基准配置
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "ESNext"
  }
}

// tsconfig.json —— 项目配置,只写差异
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
// 三环境方案(第六节)就靠 extends 实现"一份基准 + 三份差异"

二、三大坐标:target / module / moduleResolution

2.1 target:编译产物的"年代"

{
  "compilerOptions": {
    "target": "ES2020"
    // 产物兼容到 2020 年的 JS 引擎
  }
}

// ⚠ 两个常见误解:
// 1. target 不影响"你写什么",只影响"编译成什么"
//    你写 ES2022 的代码,target ES5 时会被降级翻译(?. 变成三元)
// 2. target 影响 lib 的默认值(见 2.4)

选型逻辑

- 跑在现代浏览器(不用管 IE)→ ES2020 或更高
- Node.js 18+               → ES2022
- 需兼容老旧环境/嵌入式 WebView → ES2017 甚至 ES5

2.2 module:产物的模块系统

{
  "compilerOptions": {
    "module": "ESNext"      // 产物保留 ESM(import/export)
    // "module": "CommonJS" // 产物转成 CJS(require/module.exports)
  }
}

// ⚠ 大坑预警:module 决定的是【产物】的模块格式
// 你的源码永远写 import/export,由 tsc 负责翻译

2.3 moduleResolution:模块解析策略

{
  "compilerOptions": {
    // "moduleResolution": "node10"   // 老式 Node 解析(读 main 字段)
    // "moduleResolution": "node16"   // 严格按 Node 16+ 规则(读 exports 字段)
    // "moduleResolution": "bundler"  // 给 Vite/webpack 等打包器用
  }
}

// 它决定:import "lodash" 时,编译器去哪找这个模块的类型
// - node10:node_modules/lodash/package.json 的 main + 同级 types
// - node16:严格读 exports 字段的 types 条件(现代包的正确姿势)
// - bundler:允许目录导入不带扩展名等打包器特性

选型速查表

场景 module moduleResolution
Node.js 项目(ESM) Node16/NodeNext Node16/NodeNext
Node.js 项目(CJS) CommonJS Node10 或 Node16
Vite/webpack 前端 ESNext bundler
给打包器用的库 ESNext bundler

2.4 lib:环境 API 的类型库

{
  "compilerOptions": {
    "target": "ES2020",
    // 不写 lib 时,默认加载 ES2020 的内置类型(Promise、Map...)
    "lib": ["ES2020", "DOM"]
    //                          ↑ 前端项目加 DOM(document、HTMLElement)
    // Node 项目用 @types/node 补(process、Buffer)
  }
}

// 典型报错:"Cannot find name 'document'"
// → lib 里没有 DOM —— 这是 lib 的锅,不是你代码的锅

三、strict 全家桶逐项拆解

"strict": true 是 8 个开关的总和。逐个拆解——每个都配"关掉会漏掉什么"的对照实验。

3.1 strictNullChecks:null/undefined 不再隐形(最重要)

// ===== 关闭时(远古模式) =====
// null 和 undefined 被静默赋给任何类型:
let name: string = getUserInput();   // getUserInput 可能返回 null
name.length;                          // 编译通过,运行时炸 💥

// ===== 开启时 =====
let name: string = getUserInput();
// ❌ 编译错误:Type 'string | null' is not assignable to type 'string'
// 你被迫处理 null —— 这正是开关的意义:把运行时炸弹提前到编译期

// 正确写法:
let name: string | null = getUserInput();
name?.length;              // 可选链
name ?? "default";         // 空值合并
if (name !== null) { name.length; }   // 收窄

3.2 noImplicitAny:隐式 any 必须显式化

// ===== 关闭时 =====
function parse(input) {      // input 静默变成 any
  return input.trim();        // 任何操作都放行
}

// ===== 开启时 =====
function parse(input) {
//              ~~~~~
// ❌ 编译错误:Parameter 'input' implicitly has an 'any' type
}

// 你有两个选择:
function parseA(input: string) { ... }   // ① 标注真实类型(优先)
function parseB(input: any) { ... }      // ② 显式 any(留下"我知道这里有 any"的标记)

3.3 strictFunctionTypes:函数参数逆变检查(Day 20 理论的落地)

interface Animal { name: string }
interface Dog extends Animal { bark(): void }

// ===== 关闭时(双向协变,unsafe) =====
let f1: (d: Dog) => void = (a: Animal) => {};   // ✅ 放行(正确:逆变)
let f2: (a: Animal) => void = (d: Dog) => {};   // ⚠ 也放行(错误:协变赋参)

// ===== 开启时 =====
let f3: (a: Animal) => void = (d: Dog) => {};
// ❌ 编译错误(参数是逆变位,Dog 参数的函数不能赋给 Animal 参数的变量)

// 回忆 Day 20 的漏斗规则:"宽进(参数逆变)窄出(返回值协变)"
// 这个开关就是把漏斗规则写进法律

3.4 strictBindCallApply:bind/call/apply 的参数检查

function add(a: number, b: number): number { return a + b; }

// ===== 关闭时 =====
add.call(null, "1", "2");     // ✅ 放行 —— 字符串传进了 add
// ===== 开启时 =====
add.call(null, "1", "2");
// ❌ 编译错误:Argument of type 'string' is not assignable to 'number'

3.5 strictPropertyInitialization:类属性必须初始化

class Device {
  name: string;              // ❌ 开启时报错:没初始化也没在构造器里赋值
  temp!: number;              //    ① 用 ! 断言"我确定运行时会有值"
  readonly id = "CNC-001";   //    ② 声明即初始化 ✅
  status!: "running" | "fault";

  constructor() {
    this.name = "机床";       //    ③ 构造器里赋值 ✅
  }
}

// 注意 ! 的滥用风险:temp! 只是"骗过编译器",运行时没值照样 undefined
// 工业实践:优先构造器赋值,! 只用于"框架代为注入"的场景(如 Angular 的 @Input)

3.6 noImplicitThis:this 必须有明确类型

// ===== 关闭时 =====
function oldSchool() {
  return this.value;   // this 是 any —— 哪来的 value?没人知道
}

// ===== 开启时 =====
function oldSchool() {
  return this.value;
  //  ❌ 'this' implicitly has type 'any'
}

// 修复:this 参数标注(第 1 周讲过的技巧)
function typed(this: { value: number }) {
  return this.value;    // ✅ this: { value: number }
}

3.7 alwaysStrict:产出 “use strict”

// 编译产物顶部加 "use strict" —— JS 严格模式
// 防止静默变量泄漏到全局等 JS 层面的问题

3.8 useUnknownInCatchVariables:catch 的 e 是 unknown

// ===== 关闭时 =====
try {
  risky();
} catch (e) {
  e.message;      // e: any —— 随便访问,如果 e 是字符串就炸
}

// ===== 开启时(TS 4.4+) =====
try {
  risky();
} catch (e) {
  e.message;
  // ❌ 'e' is of type 'unknown'
  // 必先收窄:
  if (e instanceof Error) {
    e.message;    // ✅
  } else {
    String(e);    // ✅
  }
}

3.9 一张总表(打印贴墙版)

开关 拦截的错误 修复姿势
strictNullChecks null/undefined 混入正常类型 | null + ?. + ??
noImplicitAny 隐式 any 参数 标注类型 / 泛型
strictFunctionTypes 参数协变的错误赋值 漏斗规则(Day 20)
strictBindCallApply call/apply 传错参数 修参数类型
strictPropertyInitialization 类属性未初始化 构造器赋值 / !
noImplicitThis this 为 any this 参数标注
alwaysStrict 产物缺 use strict 自动加
useUnknownInCatchVariables catch 的 e 为 any instanceof 收窄

四、strict 之外的高价值开关

4.1 noUncheckedIndexedAccess:索引访问自动加 undefined

// strict 都开了,这个还是漏网之鱼:
const arr = [1, 2, 3];
const dict: Record<string, number> = { a: 1 };

const x = arr[10];      // number —— 但运行时明明是 undefined!
const y = dict["zzz"];  // number —— 同上

// 开启 noUncheckedIndexedAccess 后:
const x2 = arr[10];      // number | undefined ✅ 强迫你检查
const y2 = dict["zzz"];  // number | undefined

// 代价:代码里到处 ?? 0 —— 但对"下标来自外部输入"的场景(设备面板、表格)值得开
// type-challenges 的 Equal 判题也会受它影响(读数组要 non-null 断言)

4.2 exactOptionalPropertyTypes:区分"缺失"与"undefined"

interface Options {
  timeout?: number;
}

// 关闭时:timeout?: number 等价于 timeout?: number | undefined
const o1: Options = { timeout: undefined };   // ✅ 放行

// 开启后:? 只表示"可以没有这个键",不表示"值可以是 undefined"
const o2: Options = { timeout: undefined };   // ❌ 编译错误
const o3: Options = {};                        // ✅ 唯一合法的"无值"
// 需要显式 undefined 时写 timeout?: number | undefined
// 语义更精确,但生态兼容性一般 —— 大项目慎开

4.3 noFallthroughCasesInSwitch:switch 禁止穿透

switch (status) {
  case "running":
    log("运行中");
    // 忘了 break —— 关闭时静默穿透到下一个 case
  case "fault":
    alarm();    // running 也报警了 💥
    break;
}

// 开启后:case "running" 缺 break 直接编译错误
// (空 case 分组 case "a": case "b": 仍允许)

五、文件与工程管理配置

{
  "compilerOptions": { /* 前面讲的都在这 */ },
  "include": ["src/**/*"],          // 编译范围(glob)
  "exclude": ["node_modules", "dist", "tests"],

  // 单文件编译隔离(Day 23 详讲):
  // "isolatedModules": true

  // 引用工程(monorepo 场景,了解即可):
  // "composite": true
}

六、三环境配置方案(开发/测试/生产)

开发环境:要快、要详细报错、要源码映射。
测试环境:类型必须全量检查,不能跳过测试文件。
生产构建:只要产物,测试文件排除。

// ===== tsconfig.base.json(团队基准) =====
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,          // 跳过 node_modules 里 .d.ts 的检查(提速)
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  }
}

// ===== tsconfig.json(开发:IDE 用,不产出) =====
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true,               // 只检查,不编译(vite/esbuild 负责产物)
    "sourceMap": true
  },
  "include": ["src", "tests", "examples"]
}

// ===== tsconfig.test.json(测试:含测试文件,仍不产出) =====
{
  "extends": "./tsconfig.json",
  "include": ["src", "tests"],
  "compilerOptions": {
    "types": ["vitest/globals"]   // 测试全局 API 的类型
  }
}

// ===== tsconfig.build.json(生产:只编 src,产出 dist) =====
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,          // 产出 .d.ts(Day 24 的主角)
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src"]              // 关键:排除 tests!
}
// package.json 里的配套脚本:
{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run",
    "build": "tsc -p tsconfig.build.json"
  }
}

七、实战:给 typed-utils 配置 tsconfig

今天在 typed-utils 项目里落地(第 2 周建的项目):

1. 建 tsconfig.base.json(复制第六节的基准配置)
2. 改造 tsconfig.json 为开发配置(noEmit: true)
3. 新建 tsconfig.build.json(declaration: true —— 为 Day 28 发布做准备)
4. package.json 加三个脚本(typecheck / test / build)
5. 实验:故意把 strictNullChecks 关掉 → 看哪些第 2 周写的代码突然出现新错误
   (错误数量 = 这个开关替你拦住的隐患数量)

八、类比记忆:驾驶舱仪表盘

tsconfig = 飞机驾驶舱的仪表与限制系统

- target        → 机场跑道的年代标准(新跑道能起降新机型)
- module        → 货舱的集装箱规格
- strict 全家桶 → 8 个安全警报:
  strictNullChecks 是"安全带警报"(最重要的那条)
  noImplicitAny  是"未知物体警报"
  ...
- skipLibCheck  → 不检查隔壁飞机(node_modules)的货单 —— 提速但要信任对方

老飞行员(资深 TS 工程师)的标志:
不是背下所有开关,而是看到任何警报(编译错误),
能立刻指出"这是哪个仪表在响、为什么响、怎么正确地消音(修复)"。

九、常见坑点与最佳实践

坑点 1:skipLibCheck 不等于跳过自己的类型检查

{
  "compilerOptions": {
    "skipLibCheck": true   // 只跳过【依赖包】的 .d.ts 检查
  }
}
// 你自己代码里的类型错误一个都不会少报
// 它解决的是:老依赖用了新语法 / 两个依赖的类型互相冲突 —— 这些"不是你的锅"

坑点 2:tsconfig.json 不在根目录导致 IDE 失灵

症状:VS Code 里类型全飘红 / 全是 any
排查顺序:
1. 打开的文件夹是否是 tsconfig 所在工程根(多开窗口容易错乱)
2. jsconfig 与 tsconfig 是否打架(JS 项目误配)
3. "TypeScript: Restart TS Server" 重启语言服务(治 90% 的玄学)

坑点 3:把配置当摆设直接抄

// ❌ 网上抄一份 200 行的 tsconfig,其中 30 个开关不知道干嘛的
// ✅ 每个 开启的开关 = 你能说出它拦截的错误 + 举一个例子
// 配置是防御工事——不知道防什么的工事等于没有

坑点 4:build 配置忘了排除测试文件

tsconfig.build.json 里 include 只写 ["src"]
否则:dist/ 里混入 tests 产物 + tests 的依赖(vitest)被要求安装到 dependencies

十、自测挑战

挑战 1(基础):开关对号入座

以下错误分别由哪个开关拦截?
a. Property 'length' does not exist on type 'never'
b. Variable 'x' is used before being assigned
c. 'this' implicitly has type 'any'
d. Type 'string | undefined' is not assignable to type 'string'
e. Property 'name' has no initializer

挑战 2(进阶):三分钟修复

// 开启 noImplicitAny 后,下面的代码报了 3 处隐式 any —— 全部修掉:
function handler(req, res) {
  const id = req.params.id;
  db.find(id, function (err, doc) {
    if (err) res.json({ error: err.message });
  });
}

挑战 3(实验):开关的代价

在 typed-utils 里:
1. 关闭 strictNullChecks → 记录新增的错误数
2. 重新开启 → 数一数:这个开关每天替你拦多少雷
3. 写进今日博客:你的"strict 收益报告"

挑战 4(论文级):给新人讲 strict

不看资料,向橡皮鸭讲清楚:
- strict 八开关分别拦什么(每个一个例子)
- 为什么 strictFunctionTypes 的规则"反直觉"却正确(联系 Day 20 逆变)
- noUncheckedIndexedAccess 为什么默认不在 strict 里(权衡了什么)

十一、总结与知识图谱

tsconfig 深度配置(Day 22)
│
├── 三大坐标
│   ├── target —— 产物的 JS 年代(不影响你写什么)
│   ├── module —— 产物的模块系统(ESNext / CommonJS)
│   ├── moduleResolution —— 解析策略(node10 / node16 / bundler)
│   └── lib —— 环境 API 类型(ES20xx / DOM / @types/node)
│
├── strict 全家桶(8 开关)
│   ├── strictNullChecks —— null/undefined 显式化 ⭐ 最重要
│   ├── noImplicitAny —— 隐式 any 显式化
│   ├── strictFunctionTypes —— 参数逆变检查(Day 20 落地)
│   ├── strictBindCallApply —— call/apply 参数检查
│   ├── strictPropertyInitialization —— 类属性必须初始化
│   ├── noImplicitThis —— this 必须有类型
│   ├── alwaysStrict —— 产物加 use strict
│   └── useUnknownInCatchVariables —— catch 的 e 为 unknown
│
├── 高价值补充开关
│   ├── noUncheckedIndexedAccess —— 索引访问加 undefined
│   ├── exactOptionalPropertyTypes —— 缺失 ≠ undefined
│   └── noFallthroughCasesInSwitch —— 禁止 switch 穿透
│
└── 三环境方案(extends 继承)
    ├── base —— 团队基准
    ├── dev —— noEmit + 全量检查(IDE 用)
    ├── test —— 含 tests + vitest 类型
    └── build —— 只编 src + declaration 产物

一句话总结:tsconfig 不是配置文件,是防御工事的图纸——strict 八开关是八道防线,每道防线拦一类错误;看懂图纸的人,才能在错误爆发前就知道它会死在哪道防线。


明日预告:Day 23 模块系统——TS 里藏着两套模块(值的模块 + 类型的模块),import typeisolatedModulesesModuleInterop 这些"翻译官"规则,把 ESM/CJS 混用的报错一次斩清。

评论