TypeScript tsconfig 深度配置 — 编译器的驾驶舱,一次讲透
写了三周类型代码,你一直在和"编译器"打交道——但今天才正式坐进驾驶舱。tsconfig.json 里的每个开关,都在决定编译器以多严格的眼光审视你的代码。今天逐项拆解 strict 全家桶(8 个开关,每个配"关掉会漏掉什么错误"的对照实验),讲清 target/module/moduleResolution 三大坐标的选型逻辑,最后落地开发/测试/生产三环境的配置方案。今天之后,看到任何编译报错,你的第一反应不再是"搜错误信息",而是"哪个开关管这个"。
目录
- 一、tsconfig 是什么:编译器的指令集
- 二、三大坐标:target / module / moduleResolution
- 三、strict 全家桶逐项拆解
- 四、strict 之外的高价值开关
- 五、文件与工程管理配置
- 六、三环境配置方案(开发/测试/生产)
- 七、实战:给 typed-utils 配置 tsconfig
- 八、类比记忆:驾驶舱仪表盘
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、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 type、isolatedModules、esModuleInterop 这些"翻译官"规则,把 ESM/CJS 混用的报错一次斩清。