本节目标:把
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 时,编译器实际执行了三步:
- 收集文件。 根据配置(或命令行参数)找出所有需要编译的
.ts文件,以及它们import进来的依赖。 - 类型检查。 按照类型系统的规则逐行检查,把不合法的地方汇总成报错列表。这一步只读不改。
- 生成输出。 对每个
.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 的类型只存在于编译期,运行时一点都不保留。
由此可以推出三条对后续学习至关重要的结论:
- 运行时无法判断类型。 你写
if (typeof x === "string")能判断原始类型,但无法判断x是否符合某个interface——因为 interface 在运行时不存在。 - 类型不会替你挡住外部数据。 从 API 拿到的 JSON,即使你声明成
User,运行时也可能缺字段。这正是第 13 章要讲运行时校验的原因。 - 不能依赖类型做分支。 任何「根据类型走不同逻辑」的需求,都必须有运行时可用的判别依据,这是第 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 第一个项目:从零到运行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。