tsconfig.json 完全解析:strict 8个子选项、Monorepo
第16章:tsconfig.json 完全解析:strict 8个子选项、Monorepo 配置
理解tsconfig.json 完全解析是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用strict 8个子选项、Monorepo 配置?关键的设计决策和陷阱是什么?
读完本章你将理解:
- tsconfig.json 不是可选项
strict: true展开为 8 个子选项targetvslib:运行的代码 vs 可用的 API
Level 1 · 你需要知道的(1-3年经验)
tsconfig.json 不是可选项
很多团队的做法是:把别人的 tsconfig.json 复制过来,遇到报错就把某个选项关掉。这样做会留下一堆安全漏洞,而且你不知道自己漏掉了什么保护。
本章从 strict 的 8 个子选项开始,逐一解释每个选项拦截的是哪类错误,然后覆盖模块系统、路径别名、Monorepo 项目引用、增量编译,最后给出三种场景的推荐基础配置。
strict: true 展开为 8 个子选项
在 tsconfig 里写 "strict": true,等价于同时打开以下 8 个选项。每个选项都可以单独控制——strict: true 只是省事的写法。
1. strictNullChecks
关闭时,null 和 undefined 可以赋给任何类型,这是大多数空指针崩溃的根源。
// strictNullChecks: false(默认关闭时)
function getLength(s: string): number {
return s.length; // s 可以是 null,运行时报错
}
getLength(null); // 编译通过,运行时 TypeError
// strictNullChecks: true
function getLength(s: string): number {
return s.length;
}
getLength(null); // 编译错误:Argument of type 'null' is not assignable to 'string'
// 正确处理方式
function getLength(s: string | null): number {
return s?.length ?? 0;
}
开启之后,每个可能为 null 或 undefined 的地方必须显式标注并处理,这是 strict 8 个选项里投入产出比最高的一个。
2. strictFunctionTypes
这个选项强制函数类型的参数遵循逆变规则,阻止一类静默的类型不安全。
// strictFunctionTypes: false 时可以通过
type Logger = (msg: string | number) => void;
const log: Logger = (msg: string) => console.log(msg.toUpperCase());
// 调用时传 number,运行时 .toUpperCase() 崩溃
// strictFunctionTypes: true
type Logger = (msg: string | number) => void;
const log: Logger = (msg: string) => console.log(msg.toUpperCase());
// 错误:Type '(msg: string) => void' is not assignable to type 'Logger'
// 参数类型 string 不是 string | number 的子类型(逆变方向)
// 正确写法
const log: Logger = (msg: string | number) => console.log(String(msg).toUpperCase());
注意:这个选项只影响函数类型语法(type F = (x: T) => U),不影响方法声明语法({ method(x: T): U })——方法语法仍是双变的,这是历史遗留。
3. strictBindCallApply
让 bind、call、apply 的参数有正确的类型检查,而不是退化成 any。
function add(a: number, b: number): number {
return a + b;
}
// strictBindCallApply: false
const result = add.call(null, "hello", "world"); // 编译通过,返回 any
// strictBindCallApply: true
const result = add.call(null, "hello", "world");
// 错误:Argument of type 'string' is not assignable to parameter of type 'number'
const result2 = add.call(null, 1, 2); // 正确,result2: number
4. strictPropertyInitialization
类的属性必须在构造函数里初始化,或者标记为可选/有默认值。
// 错误:属性 name 没有初始化
class User {
name: string; // 错误:Property 'name' has no initializer
age: number; // 错误
constructor() {
// 忘记初始化
}
}
// 正确方式 1:构造函数初始化
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
// 正确方式 2:明确断言已在别处初始化(谨慎使用)
class UserWithInit {
name!: string; // ! 表示"我保证会初始化,绕过检查"
init(name: string) {
this.name = name;
}
}
! 断言是逃生舱,不是常规写法。如果你在很多属性上用 !,说明类的设计需要重构。
5. noImplicitAny
禁止 TypeScript 悄悄地把无法推断类型的变量推断为 any。
// noImplicitAny: false 时静默通过
function process(data) { // data 隐式推断为 any
return data.value * 2; // 完全没有类型保护
}
// noImplicitAny: true
function process(data) { // 错误:Parameter 'data' implicitly has an 'any' type
return data.value * 2;
}
// 必须显式标注
function process(data: { value: number }): number {
return data.value * 2;
}
// 或者在确实不知道类型时用 unknown
function process(data: unknown): number {
if (typeof data === "object" && data !== null && "value" in data) {
return (data as { value: number }).value * 2;
}
throw new Error("Invalid data");
}
6. noImplicitThis
在函数里使用 this 时,this 的类型必须明确,不能是隐式的 any。
// 问题:this 类型未知
const counter = {
count: 0,
increment: function() {
this.count++; // noImplicitThis 下报错:'this' implicitly has type 'any'
}
};
// 解决方案 1:用箭头函数(从外层捕获 this)
const counter = {
count: 0,
increment() {
this.count++; // 方法简写语法,this 类型正确推断为 counter 的类型
}
};
// 解决方案 2:显式声明 this 参数类型(TypeScript 特有语法)
interface Counter {
count: number;
}
function increment(this: Counter): void {
this.count++;
}
7. alwaysStrict
让 TypeScript 在每个输出的 JS 文件顶部插入 "use strict";,并且以严格模式解析源文件。
// 开启 alwaysStrict 后,编译输出的 JS 文件会是:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// ...
这个选项本身不影响类型检查,但确保运行时也处于严格模式(比如阻止给未声明变量赋值)。
8. useUnknownInCatchVariables
把 catch 块里的错误变量类型从 any 改为 unknown,强迫你在使用前检查类型。
// useUnknownInCatchVariables: false(旧行为)
try {
fetchData();
} catch (err) {
console.log(err.message); // err 是 any,可以随便访问,没有保护
}
// useUnknownInCatchVariables: true
try {
fetchData();
} catch (err) {
// err 是 unknown
console.log(err.message); // 错误:Object is of type 'unknown'
// 正确处理
if (err instanceof Error) {
console.log(err.message); // 安全
} else {
console.log(String(err));
}
}
target vs lib:运行的代码 vs 可用的 API
这两个选项经常被混淆,但它们控制的是完全不同的东西。
target 决定 TypeScript 把你的代码编译成哪个版本的 JavaScript。
lib 决定 TypeScript 认为哪些浏览器/运行时 API 存在。
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2022", "DOM"]
}
}
这个配置的含义:
target: ES2017:把箭头函数、async/await 等语法降级到 ES2017 语法(async/await会变成 Promise 链)lib: ES2022:允许使用Array.prototype.at()、Object.hasOwn()等 ES2022 的方法(你的打包工具或 polyfill 负责提供运行时实现)lib: DOM:允许使用document、window、fetch等浏览器 API
// target: ES2017,lib: ES2022
const arr = [1, 2, 3];
const last = arr.at(-1); // 编译通过(lib 允许),但你需要 polyfill 在老浏览器里运行
// 如果不包含 DOM 库:
const el = document.getElementById("app"); // 错误:Cannot find name 'document'
常见陷阱:把 target 设得很低(如 ES5)不能替代 polyfill,它只处理语法,不处理新 API。
module + moduleResolution 组合矩阵
这两个选项必须配套使用,选错会导致模块解析失败。
module |
moduleResolution |
适用场景 |
|---|---|---|
CommonJS |
node |
传统 Node.js 项目 |
ESNext |
bundler |
Vite / Webpack 项目(推荐) |
Node16 |
Node16 |
原生 ESM Node.js(.mjs / exports) |
Nodenext |
nodenext |
同上,支持更多 Node.js 特性 |
ESNext |
node |
不推荐,容易出现解析不一致 |
场景 1:Vite / Webpack 前端项目
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020"
}
}
bundler 模式不要求 .js 扩展名,匹配打包工具的行为。
场景 2:原生 Node.js ESM(package.json 里 "type": "module")
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2022"
}
}
Node16 模式要求导入语句带 .js 扩展名:
// 必须写 .js(即使文件实际是 .ts)
import { helper } from "./utils.js";
Level 2 · 它是怎么运行的(3-5年经验)
paths 路径别名
避免 ../../../../utils 这样的相对路径。
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
使用:
// 不用再写 ../../components/Button
import { Button } from "@components/Button";
import { formatDate } from "@utils/date";
重要:paths 只告诉 TypeScript 类型在哪里,不影响运行时。 你还需要配置打包工具(Vite / Webpack / esbuild)识别同样的别名:
// vite.config.ts
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
}
}
});
如果只配置 paths 而不配置打包工具,TypeScript 不报错,但运行时会找不到模块。
项目引用:Monorepo 的正确姿势
当一个 Monorepo 里有多个包(packages/core、packages/api、packages/web),如果所有代码都放在一个 tsconfig 里编译,每次改动一处就要重新检查整个项目,慢且容易出错。
项目引用允许把编译拆分成独立的单元,只重新编译变化的部分。
第一步:每个包有自己的 tsconfig.json,加上 composite: true
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
composite: true 要求:
- 必须有
declaration: true(生成.d.ts) - 必须指定
rootDir - 必须通过
include/files明确指定源文件
第二步:依赖方的 tsconfig 用 references 指向依赖
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" }
],
"include": ["src"]
}
第三步:根 tsconfig 汇总所有包
// tsconfig.json(项目根目录)
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
构建命令:
# 按依赖顺序构建所有包
npx tsc --build
# 只构建某个包及其依赖
npx tsc --build packages/api
# 清除构建缓存
npx tsc --build --clean
项目引用让 api 只能使用 core 的公开类型(通过 .d.ts),而不是直接访问 core 的源文件,这强制了模块边界。
include / exclude / files
三个选项控制哪些文件被 TypeScript 处理,优先级从高到低是 files > include > exclude。
{
"include": ["src/**/*"], // 包含 src 目录下所有文件
"exclude": [
"node_modules", // 默认已排除,但显式写出更清晰
"**/*.test.ts", // 排除测试文件(测试有单独的 tsconfig)
"dist"
],
"files": [ // 显式列出额外文件(数组太长就别用 files)
"src/global.d.ts"
]
}
何时用 files: 只有几个需要特别包含的文件,或者你想精确控制编译入口。大多数项目用 include 就够了。
常见误区: exclude 不等于"不检查"。如果被排除的文件被 include 范围内的文件 import,TypeScript 仍然会处理它。exclude 的作用是"不把这个文件作为独立入口扫描"。
增量编译:incremental 和 tsBuildInfoFile
大型项目每次 tsc 都重新检查所有文件太慢了。incremental 选项让 TypeScript 保存上次编译结果,下次只重新检查变化的文件。
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
把 .tsbuildinfo 加入 .gitignore(它是本地缓存,不需要提交):
# .gitignore
.tsbuildinfo
*.tsbuildinfo
在 CI 里如果要利用缓存,需要把 .tsbuildinfo 作为 cache artifact 保存。
三种场景的推荐基础配置
Node.js 应用
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
React 应用(Vite)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
库(发布到 npm)
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2018"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"composite": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Level 3 · 规范怎么定义的(资深)
strict: true 是 8 个独立选项的集合。strictFunctionTypes 只影响函数类型语法((x: T) => U),不影响方法声明语法——这是 TypeScript 团队为兼容大量已有代码做出的妥协。module 和 moduleResolution 必须配套使用:Node16/nodenext 要求导入路径带 .js 扩展名,这是 Node.js ESM 规范的要求。项目引用(Project References)通过 composite: true + declaration: true 实现增量编译,每个子项目只暴露 .d.ts 作为公共 API,强制了模块边界。
Level 4 · 边界与陷阱(所有人)
反模式
反模式 1:skipLibCheck: true 当作万能药
// 只是掩盖了真正的问题
{ "skipLibCheck": true }
// 什么时候合理用:第三方库的类型声明有 bug,你没法修复它们时
// 不合理的用法:用它来压制你自己代码产生的类型错误
反模式 2:"strict": false 加一堆单独开启
// 不如直接 "strict": true,然后关掉你不想要的
{
"strictNullChecks": true,
"noImplicitAny": true,
// 漏掉了 strictFunctionTypes,留下安全漏洞
}
正确做法:strict: true 打开全部,然后有充分理由时再单独关掉某个。
反模式 3:paths 配置了但运行时不工作
只配 tsconfig 的 paths,没有配打包工具或 ts-node 的路径映射,结果类型检查过了但运行时 Cannot find module。
汇总表
| 选项 | 作用 | 默认值 | 推荐值 |
|---|---|---|---|
strictNullChecks |
null/undefined 安全 | false | true |
strictFunctionTypes |
函数参数逆变 | false | true |
strictBindCallApply |
bind/call/apply 类型 | false | true |
strictPropertyInitialization |
类属性初始化 | false | true |
noImplicitAny |
禁止隐式 any | false | true |
noImplicitThis |
this 类型 | false | true |
alwaysStrict |
输出 use strict | false | true |
useUnknownInCatchVariables |
catch 变量为 unknown | false | true |
target |
编译目标语法版本 | ES3 | ES2020+ |
module |
模块格式 | CommonJS | Node16 / ESNext |
moduleResolution |
模块解析策略 | node | Node16 / bundler |
incremental |
增量编译 | false | true(大项目) |
composite |
项目引用支持 | false | true(Monorepo) |
下一章预告
第 17 章讲声明文件(.d.ts):当你使用一个没有 TypeScript 类型的 JavaScript 库时,如何手写类型声明,以及如何把它发布到 DefinitelyTyped(@types/xxx)。这是让整个 JS 生态系统对 TypeScript 友好的基础机制。