《TypeScript编程入门》2.2 tsc 与 tsconfig.json 初探

本节拆开 TypeScript 编译器 tsc 与它的配置文件 tsconfig.json。先运行 tsc 观察 .ts 转成 .js 的过程,再用 tsc --init 生成配置,讲清 target、module、outDir、strict 的含义与取舍;再通过编译前后对照理解「类型擦除」,最后给出 include/exclude 的误用与报错。读完你能读懂一份 tsconfig.json。

本节目标:把 tsc 从「一个神秘命令」变成「一个你能预测行为的工具」。读完后,你能说清楚 tsc 在编译时做了哪几件事、tsconfig.json 里每一个常用选项在控制什么,以及为什么编译出来的 JavaScript 里看不到任何类型标注。

2.2 tsc 与 tsconfig.json 初探

上一节我们装了环境,验证了 npx tsc -v 能打印版本号。但版本号只是个开场白——tsc 真正的职责是把 TypeScript 变成 JavaScript,并在过程中把类型错误挑出来。而它怎么变、变成什么样、挑哪些文件,全都由 tsconfig.json 决定。

这两个东西必须一起理解:tsc 是发动机,tsconfig.json 是方向盘。只学命令不看配置,你会永远靠抄别人的配置文件过日子。

2.2.1 tsc 做的那三件事

在命令行敲下 npx tsc 时,编译器实际执行了三步:

  1. 收集文件。 根据配置(或命令行参数)找出所有需要编译的 .ts 文件,以及它们 import 进来的依赖。
  2. 类型检查。 按照类型系统的规则逐行检查,把不合法的地方汇总成报错列表。这一步只读不改。
  3. 生成输出。 对每个 .ts 文件,剥掉类型信息后输出一份等价的 .js 文件;如果配置了 declaration,还会额外输出 .d.ts。

理解这三步的顺序很重要:类型检查与输出是分离的。默认的 noEmitOnError 是 false:即使报告类型错误,tsc 仍会生成 JavaScript,但命令会以非零退出码结束。已有 .js 文件不代表类型检查通过;构建脚本必须检查退出码。

这些行为可对照官方 noEmitOnError 说明 。要阻止有错误的新产物写出,可以开启这个选项;只检查类型则使用 --noEmit:

npx tsc --noEmitOnError true
# 有类型错误时不生成新产物;已有旧产物不会被自动删除

2.2.2 第一次运行 tsc:先用命令行参数

为了看清 tsconfig.json 到底省了什么,我们先不建配置文件,纯靠命令行参数编译。

mkdir ts-first && cd ts-first
npm init -y
npm install --save-dev --save-exact typescript@5.9.3

# 写一个最简单的源文件
cat > hello.ts <<'EOF'
const greeting: string = "hello, tsc";
console.log(greeting);
EOF

# 不指定任何参数,直接编译
npx tsc hello.ts

执行完你会看到目录里多了一个 hello.js:

// 生成的 hello.js —— 注意类型标注消失了
var greeting = "hello, tsc";
console.log(greeting);

这里固定 TypeScript 5.9.3,未指定 target 时默认输出 ES5,所以 const 也被降级成 var。注意两件事。第一,const greeting: string 里的 : string 不见了,这就是我们后面要专门讲的类型擦除。第二,输出文件与源文件同目录,这会污染源码目录——真实项目里没人这么干。

如果用命令行参数把输出放到别处,命令会迅速变长:

npx tsc hello.ts --outDir dist --target es2020 --module commonjs --strict

参数一多就没人记得住,而且每个同事都得抄同一串命令。这就是 tsconfig.json 存在的理由:把编译器选项固化在文件里,让「怎么编译」成为项目的一部分,而不是某个人脑子里的记忆。

2.2.3 生成一份 tsconfig.json

TypeScript 提供了初始化命令,直接生成一份带注释的模板:

npx tsc --init

tsc --init 生成的模板随版本变化:5.9.3 的模板采用 nodenext,并启用若干额外检查;旧版模板常含大量注释。新手容易犯的错误是「全部打开」,结果项目里到处报错却不知道是哪个选项造成的。正确做法是只开你理解的选项,用到再开。

下面是一份适合初学者的最小可用配置,我们逐项拆解:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noEmitOnError": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

2.2.4 核心选项逐个拆

target:编译成哪个版本的 JavaScript

TypeScript 支持把新语法降级成老语法。比如可选链 a?.b 在 target: "ES5" 下会被改写成三元表达式。本节固定的 5.9.3 已不支持 ES3;示例使用 ES2020,不要照搬旧教程里的已移除目标。

target 取值典型场景代价
ES5需要兼容 IE 的老项目产物冗长,生成大量辅助函数
ES2017只需支持较老的 Node保留 async/await,体积适中
ES2020现代 Node 与主流浏览器可选链、空值合并原样保留
ESNext只在本机跑的工具脚本不降级,行为依赖运行时

选择原则很简单:先确定你的代码要跑在哪里,再选能满足它的最低版本。目标环境越新,降级工作量越小,产物越接近你写的代码,调试时也越容易对照。

module:产物用哪种模块格式

这一项决定输出的 .js 是 require() 风格还是 import 风格。选 CommonJS 得到 require,选 ES2020/ESNext 得到 import。它必须和 target 以及运行环境匹配:给 Node 写脚本通常用 CommonJS,给打包器(Vite、esbuild)写源码通常用 ESNext。

模块格式的坑极多,第 11 章会用一整节讲 ESM 与 CJS 的互操作。现在你只要记住一句话:模块产物必须与执行环境匹配。例如把保留 import 的文件交给按 CommonJS 解析的环境,可能出现 Cannot use import statement outside a module;target 主要控制语法降级,不直接决定模块加载方式。想提前了解可以看 TypeScript 模块解析与 ESM/CJS 。

outDir 与 rootDir:输出与输入的分界线

这两个选项要成对理解。rootDir 告诉编译器「源码的根在哪」,outDir 告诉它「产物放哪」。编译器会保持源码在 rootDir 下的相对目录结构,映射到 outDir 下。

# 源码结构
src/
  index.ts
  utils/
    math.ts

# 配置 rootDir: "./src",outDir: "./dist" 之后的产物
dist/
  index.js
  utils/
    math.js

如果你只设了 outDir 不设 rootDir,编译器会自己去推断公共根目录。一旦项目里混入了不在 src 下的文件(比如根目录的 scripts/build.ts),推断出的公共根会意外变成项目根,产物结构随之变成 dist/src/index.js——这是非常经典的「产物多了一层目录」事故。显式声明 rootDir 能避免它。

strict:一组严格检查的总开关

strict: true 不是单个选项,而是一个开关组,它会一次性打开 strictNullChecks、noImplicitAny、strictFunctionTypes 等多项检查。新手最想关掉它,理由是「报错太多」。但请务必忍住:这些报错是 TypeScript 最大的价值所在,关掉等于花钱买了车却不开。

其中影响最大的是 strictNullChecks。它开启后,null 和 undefined 不再能赋值给任意类型:

// strictNullChecks: false 时,这行不报错,运行时可能崩溃
const name: string = null;

// strictNullChecks: true 时,编译器直接拒绝
// 类型"null"不能赋值给类型"string"

关于严格模式的完整清单与迁移策略,第 16 章会专门展开,也可以先延伸阅读 TypeScript 严格模式配置 。项目级配置的组织方式(多份 tsconfig 如何继承)见 TypeScript 项目架构与 tsconfig 。

esModuleInterop、skipLibCheck、forceConsistentCasingInFileNames

选项作用建议
esModuleInterop允许用 import x from "cjs 包" 的写法引入 CommonJS 模块开启,几乎所有项目都需要
skipLibCheck跳过对 node_modules 里 .d.ts 的类型检查开启,能显著加快编译并规避第三方类型冲突
forceConsistentCasingInFileNames强制 import 路径大小写与文件名完全一致开启,避免 macOS 能跑、Linux CI 挂掉

2.2.5 类型擦除:编译产物里为什么没有类型

现在回到那个最值得深究的问题:类型标注去哪了?

// src/index.ts
interface User {
  id: number;
  name: string;
}

function greet(user: User): string {
  return `你好,${user.name}`;
}

const alice: User = { id: 1, name: "Alice" };
console.log(greet(alice));

编译后:

// dist/index.js —— interface 与类型标注整体消失
"use strict";

function greet(user) {
    return `你好,${user.name}`;
}

const alice = { id: 1, name: "Alice" };
console.log(greet(alice));

interface User 完全不见了,函数签名只剩 function greet(user)。这就是类型擦除(type erasure):TypeScript 的类型只存在于编译期,运行时一点都不保留。

由此可以推出三条对后续学习至关重要的结论:

  1. 运行时无法判断类型。 你写 if (typeof x === "string") 能判断原始类型,但无法判断 x 是否符合某个 interface——因为 interface 在运行时不存在。
  2. 类型不会替你挡住外部数据。 从 API 拿到的 JSON,即使你声明成 User,运行时也可能缺字段。这正是第 13 章要讲运行时校验的原因。
  3. 不能依赖类型做分支。 任何「根据类型走不同逻辑」的需求,都必须有运行时可用的判别依据,这是第 7 章判别联合的主题。

这三条结论是本书后半部分多条主线的源头。现在你只需要记住一句话:类型是写给人看、给编译器查的,不是给运行时用的。

2.2.6 include、exclude 与 files

配置文件的下半部分决定「哪些文件参与编译」:

{
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}
字段含义常见误用
include用路径或 glob 收集入口文件;"src" 也可递归匹配目录以为它能阻止编译器追踪入口的依赖
exclude从 include 结果中排除以为能排除被 import 的文件——被引用的文件始终会被编译
files精确列出文件,不做 glob项目一大就难以维护,仅在极小型脚本中使用

关于 exclude 有一个必须澄清的误解:它只影响「入口收集」,不影响「依赖追踪」。假设 exclude 里写了 src/legacy,但 src/index.ts 里 import 了 src/legacy/old.ts,那么这个文件照样会被编译和检查。要真正隔离,只能靠独立的 tsconfig.json(第 16 章的项目引用方案)。

2.2.7 四类典型报错

报错一:error TS6059: File 'xxx.ts' is not under 'rootDir'

原因:某个被编译的文件不在 rootDir 指定的目录下。常见于把 tsconfig.json 放在根目录、源码放在 src,却又不小心 import 了根目录的脚本。解决:把文件移进 src,或调整 rootDir。

报错二:error TS5055: Cannot write file 'xxx.js' because it would overwrite input file

原因:开启 allowJs 后把已有 .js 收入输入,输出路径又与输入重合;单纯重复编译 .ts 不一定触发它。解决:配置 outDir,并避免把生成目录收为入口。

报错三:Cannot find module 'xxx' or its corresponding type declarations.

原因:包没装,或者包没有类型声明。解决:先确认 npm install 已执行,再检查 node_modules/xxx 是否存在;若是纯 JS 包缺类型,需要装对应的 @types/xxx——第 12 章专门讲这件事。

报错四:修改 tsconfig.json 后不生效

原因:不传输入文件时,tsc 会从当前目录向父目录查找 tsconfig.json;子目录中的另一份配置可能先被找到。传入文件名(如 tsc hello.ts)则会忽略项目配置。使用 -p 显式选择配置,并且不要同时传输入文件;查找规则见官方 tsconfig 使用说明 :

npx tsc -p ./tsconfig.json

2.2.8 开发时的 watch 模式

每次改代码都手动敲一遍 npx tsc 显然不现实。tsc 自带监听模式:

npx tsc --watch
# 或简写
npx tsc -w

它会常驻终端,检测到文件变化后增量重编译,并打印:

[10:00:00 AM] Starting compilation in watch mode...
[10:00:03 AM] Found 0 errors. Watching for file changes.

需要说明的是,tsc --watch 只负责编译,不会运行你的代码。改完代码想看结果,仍需手动 node dist/index.js。下一节我们会用一个更顺手的方案把「编译 + 运行」串成一条命令。

小结

本节把 tsc 拆成了三个可预测的动作——收集文件、类型检查、生成输出——并说明类型检查与输出是分离的:默认遇错仍可能输出,开启 noEmitOnError 才会阻止生成新产物。随后我们逐项解读了 tsconfig.json 的核心选项:target 决定降级到什么语法,module 决定产物用哪种模块格式,outDir/rootDir 决定目录结构映射,strict 是一组不可轻易关闭的严格检查总开关。最后通过编译前后的代码对照,确认了类型擦除这一贯穿全书的底层事实。

到这里你已经有能力配置一个可用的编译流程,但还停留在「手动敲命令」的阶段。下一节 2.3 第一个项目:从零到运行 会把目录结构、配置文件、源码、npm scripts 串成一个完整项目,让你体验一次从空白目录到可运行程序的全过程。如果你对 interface 的写法还有疑问,先不用担心——3.1 原始类型与类型注解 会从最基础的标注讲起。

阅读导航:上一节:2.1 安装 Node.js、TS 与编辑器配置 · 下一节:2.3 第一个项目:从零到运行 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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