第 16 章

tsconfig.json 完全解析:strict 8个子选项、Monorepo

第16章:tsconfig.json 完全解析:strict 8个子选项、Monorepo 配置

理解tsconfig.json 完全解析是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用strict 8个子选项、Monorepo 配置?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

tsconfig.json 不是可选项

很多团队的做法是:把别人的 tsconfig.json 复制过来,遇到报错就把某个选项关掉。这样做会留下一堆安全漏洞,而且你不知道自己漏掉了什么保护。

本章从 strict 的 8 个子选项开始,逐一解释每个选项拦截的是哪类错误,然后覆盖模块系统、路径别名、Monorepo 项目引用、增量编译,最后给出三种场景的推荐基础配置。


strict: true 展开为 8 个子选项

在 tsconfig 里写 "strict": true,等价于同时打开以下 8 个选项。每个选项都可以单独控制——strict: true 只是省事的写法。

1. strictNullChecks

关闭时,nullundefined 可以赋给任何类型,这是大多数空指针崩溃的根源。

// 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;
}

开启之后,每个可能为 nullundefined 的地方必须显式标注并处理,这是 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

bindcallapply 的参数有正确的类型检查,而不是退化成 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,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/corepackages/apipackages/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 要求:

第二步:依赖方的 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 的作用是"不把这个文件作为独立入口扫描"。


增量编译:incrementaltsBuildInfoFile

大型项目每次 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 团队为兼容大量已有代码做出的妥协。modulemoduleResolution 必须配套使用: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 友好的基础机制。

本章评分
4.5  / 5  (15 评分)

💬 留言讨论