
用 TypeScript 写项目绕不开的东西就三样类型、编译器、tsconfig.json。类型写得好不好是能力问题编译器装没装对是环境问题而 tsconfig.json 配得好不好直接决定一个项目从第一天开始是走得顺还是后面一路填坑。不少开发者用脚手架初始化项目时对这份配置的态度是“能用就行”等到了要手动搭构建流程、发 npm 包、调整 monorepo 引用关系的时候才发现对它了解得有多浅。这篇我从编译模型讲起逐组拆一遍 compilerOptions 的常用项再给出几份能直接抄的完整配置最后把那些文档里不写、但实际开发中一定会踩的坑一并列出来。1. tsconfig.json 到底是什么——先搞懂编译模型再动手1.1 tsc 的工作流程从 .ts 到 .js 发生了什么很多刚接触 TypeScript 的人对编译的理解就是“把 TS 变成 JS”但真正发生的事比这句话复杂一档。tsc 拿到你写的 .ts 文件之后内部流程大致分三步走第一步是语法解析和类型检查它会把整个工程的类型关系全部过一遍这一步决定了你代码里有没有类型错误第二步是根据 target 选项做语法降级比如把箭头函数降成 function、把 class 降成 ES5 的构造函数第三步是根据 module 选项做模块系统转换把 ES Module 的 import/export 转成 CommonJS 的 require/module.exports或者保持原样。这里面有个很多人不知道的细节类型检查通不过tsc 默认也会照样输出 JS 文件。只有你把 noEmitOnError 打开它才会在报错时停手。这个行为乍看反直觉但它的设计逻辑是“即使有类型错误也不妨碍你先把产物跑起来调试”只不过工程实践里大部分人会选择把 noEmitOnError 打开。另一个容易忽视的点是tsc 并不是一次只处理单个文件。当你执行 tsc 命令时它默认从当前目录向上查找 tsconfig.json拿到之后读取 include 和 files 里列出的所有文件把它们看成一个整体程序跨文件做类型检查。这就意味着某个文件里的类型错误完全可能是另一个文件里定义错了接口引起的。理解这一点才明白为什么 tsconfig 的“文件范围”配置那么重要。1.2 tsconfig.json 的三块核心内容整个 tsconfig.json 看起来选项很多但本质上就三块东西。第一块是 compilerOptions编译器选项最庞大也最核心控制输出语法版本、模块规范、类型检查严格度、产物目录、声明文件生成等。第二块是文件范围配置包括 files精确列举文件、include按 glob 模式匹配文件、exclude排除文件用来告诉编译器“你要处理哪些文件”。第三块是工程化配置包括 extends继承其他配置文件、references项目引用用于 monorepo 场景。有一个常见的误区是以为 tsconfig 必须叫这个名字、必须在根目录。实际上 tsc 允许你用 tsc -p 指定任意路径下的任意 JSON 文件作为配置比如 tsc -p tsconfig.build.json。脚手架创建的 tsconfig.json 只是约定俗成的默认入口。文件范围这块默认行为尤其值得注意如果不配 includetsc 会把当前目录及其子目录下所有 .ts、.tsx、.d.ts 文件全部纳入编译范围。这个行为在大型项目里非常危险因为 node_modules 里的类型文件、build 目录里残留的 .ts、测试文件都会被扫进去既拖慢编译速度又容易引发莫名其妙的类型冲突。所以实际工程里 include 几乎是必配项它不只是“优化”而是“约束边界”。2. compilerOptions 核心选项速查与搭配思路2.1 target 到底配多少才算合适语法规格三兄弟target 决定输出 JS 的语法版本是最影响产物的选项。配成 ES5代码里的 async/await、class、箭头函数会被降级成 ES5 兼容写法配成 ES2022这些语法就原样保留。问题在于很多人忽略了一点target 会连带影响默认的 lib。lib 决定代码里能使用哪些 API 的类型声明比如 DOM 相关 APIdocument、window、ES2015 的 Promise、ES2020 的 BigInt 等。不显式配置 lib 时它会根据 target 自动推导。这里最容易踩的坑是你把 target 配成 ES5默认 lib 就不包含 ES2015 以后的 API 类型于是代码里正常使用 Promise、Map、Array.from编辑器直接给你标红。所以在现代项目里我通常建议 target 配 ES2018 以上lib 里根据运行环境补全需要的 API 声明比如前端项目要补 DOM、DOM.Iterable。还有个 jsx 选项专门管 React。早期 React 项目用 jsx: react会要求 React 在作用域内所以每个文件都要 import React。React 17 之后用 jsx: react-jsx 可以开启自动 runtime不再需要手动引入 React。如果你用的是 Next.js 或 Vite 模板大概率已经默认配好了自己手动搭项目时记得选对。2.2 模块解析module 和 moduleResolution 的搭配关系module 控制输出模块规范。写 Node.js 后端通常配 commonjs写前端要交给 Vite/Webpack 打包配 esnext 或 preserve 更合适Node.js 18 原生支持 ESM 的项目可以考虑 nodenext。module 选错会导致一个特别隐蔽的问题产物里还是 import/export但运行时环境根本认不了。moduleResolution 控制 import 语句的解析方式。它的取值必须和模块规范、运行工具链匹配否则就是一连串找不到模块的报错。比较典型的是 node 和 bundler 的区别node 是 Node.js 的 CJS 解析规则适用于后端项目bundler 是给 Vite、Webpack 这类打包器设计的允许更宽松的扩展名解析和目录引用。很多从 CRA 迁移到 Vite 的项目配置还停留在 moduleResolution: node结果遇到一些怪异报错改成 bundler 就顺畅了。esModuleInterop 是互操作开关它解决的是 TS 的 default import 和 CommonJS 模块之间的兼容问题。具体表现为不开这个开关import React from react 会报错只能写 import * as React from react开了之后两种写法都行编译器会自动做一层兼容处理。Node 项目我建议直接开启能省掉大量无意义的写法规避。2.3 类型检查严格度strict 家族为什么是必选项strict 是总开关开启后同时启用一批严格检查规则。核心的几个包括noImplicitAny禁止隐式 any。函数参数没写类型、变量初始值不明确TS 不能推断出类型时默认是 any这个规则会直接报错逼你把类型写清楚。strictNullChecks开启后 null 和 undefined 不再是任何类型的子类型变量必须显式处理可能为空的情况。这是把大量运行时崩溃变成编译期报错的关键规则。strictFunctionTypes函数类型参数逆变检查更严格避免把参数类型放宽的函数赋值给收窄类型的变量。strictPropertyInitialization类的属性必须在构造函数里初始化或者有默认值否则报错。我的观点是新项目无脑开 strict 就行了。它确实会让起步阶段的报错变多但那些报错本质上是提前暴露问题。老项目没开 strict 的话建议按条款逐个打开先开 noImplicitAny 和 strictNullChecks 收益最大修完一批开一批别一把梭。2.4 产物输出outDir、rootDir、declaration、sourceMap 如何配合outDir 指定编译产物的输出目录rootDir 指定源码的根目录这两个选项必须配合好。TS 会按源码目录结构在 outDir 下生成对应的目录结构根目录的基准就是 rootDir。如果不显式配 rootDir编译器会从所有输入文件里推算一个公共根路径推算不准就会出现 dist/src/index.js 这种多套一层目录的情况。declaration 和 declarationMap 是给库作者用的。开 declaration 之后编译会生成 .d.ts 类型声明文件使用者不需要看你源码也能获得完整类型提示。declarationMap 则生成声明文件到源码的映射方便使用者在 IDE 里跳转看实现。发布 npm 包时这两个选项建议都开。sourceMap 生成 .map 文件调试时浏览器和 Node 才能把编译后的代码映射回 TS 源码。开发环境建议开启生产环境看需求。noEmit 则是一个经常被误解的选项它表示“只做类型检查但不要输出任何文件”。前端工程里 TS 通常只担任类型检查角色真正转译交给 Vite、esbuild所以 noEmit: true 是标准做法。2.5 路径别名与特殊文件支持baseUrl、paths、resolveJsonModulepaths 是工程里最常用的选项之一用于配置路径别名。比如你希望 import utils from /utils 映射到 src/utils就在 paths 里配置 /: [./src/]。TS5.0 之后paths 不再强制要求 baseUrl路径相对 tsconfig.json 所在目录解析这一点比老版本方便不少。但有一个非常重要的认知tsconfig 里的 paths 只影响 TypeScript 的类型检查和编译解析不影响打包器的实际模块解析。前端项目里你的打包器是 Vite 或 Webpack如果只改了 tsconfig 的 paths没同步修改 Vite 的 resolve.alias 或 Webpack 的 alias编译期没问题运行时就报模块找不到。这个“双端同步”问题我见太多人踩过后面细说。resolveJsonModule 打开后允许 import json 文件TS 会自动推断 JSON 里的字段类型。isolatedModules 则是为了配合 Babel、esbuild 这类单文件转译工具确保每个文件可以被单独转译而不依赖跨文件的类型信息。开了之后你需要在 re-export 类型时显式用 export type我当时第一次碰到还挺不适应。3. 三份可以直接抄作业的完整配置示例3.1 Node.js 后端项目的基础配置后端项目目标明确编译成 CommonJS 在 Node 里跑需要接入 Jest 测试需要支持路径别名。{ compilerOptions: { target: ES2022, module: commonjs, moduleResolution: node, rootDir: src, outDir: dist, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, sourceMap: true, declaration: false, noUnusedLocals: true, noUnusedParameters: true, noFallthroughCasesInSwitch: true, baseUrl: ., paths: { /*: [src/*] } }, include: [src], exclude: [node_modules, dist, test] }逐个说明关键选择。target 选 ES2022 是因为现代 Node 版本对 ES2022 语法支持已经很完整async/await、class 新特性都能直接跑。module 选 commonjs 是兼容性最稳的方案所有 Node 版本都认识。rootDir 配成 src、outDir 配成 dist编译产物就是 dist/index.js没有多余层级。strict 全家桶是后端的底线毕竟服务端代码出 bug 直接挂业务。esModuleInterop 开启后express、lodash 这类 CJS 模块可以通过 default import 正常引入。noUnusedLocals 和 noUnusedParameters 会拦截掉所有“定义了没用”的变量和参数对团队协作很重要能逼着成员清理死代码。paths 配了 /*实际运行时有两个地方要处理第一tsc 编译产物里的路径是原样保留的而 Node 不认识 别名所以如果用 tsc 产物跑生产还需要 tsconfig-paths 或者 tsc-alias 这类工具处理第二Jest 的 moduleNameMapper 也要同步配置否则测试环境找不到模块。这是后端项目里很容易漏掉的一环。3.2 前端 React Vite 项目配置Vite 项目比较特殊TS 只做类型检查不负责产物的语法转换所以 noEmit 是必须的。{ compilerOptions: { target: ES2020, useDefineForClassFields: true, lib: [ES2020, DOM, DOM.Iterable], module: ESNext, moduleResolution: bundler, jsx: react-jsx, strict: true, noEmit: true, isolatedModules: true, esModuleInterop: true, skipLibCheck: true, resolveJsonModule: true, baseUrl: ., paths: { /*: [src/*] } }, include: [src] }module 选 ESNext、moduleResolution 选 bundler是 Vite 生态的标准搭配。moduleResolution: bundler 是 TS5.0 之后专门为打包器场景设计的它允许不带扩展名的导入、允许目录导入和 Vite 的处理逻辑对齐。如果这里配成 node遇到某些模块会报解析错误。lib 里必须要有 DOM因为前端代码大量使用 window、document、localStorage 这些 API。noEmit true 意味着你运行 tsc 只是做类型检查Vite 启动时用 esbuild 做转译不经过 tsc所以两者互不干扰。这是前端工程里“类型检查”和“构建转译”分离的典型实践。paths 配了 /* 之后记得在 vite.config.ts 的 resolve.alias 里同步配置import path from path; import { defineConfig } from vite; export default defineConfig({ resolve: { alias: { : path.resolve(__dirname, src), }, }, });两边不一致的话编辑器里路径没问题一跑 dev server 就报模块找不到这个问题我排查过好多次基本都是忘了同步 alias。3.3 发布 npm 包时的库配置做开源库和组件库时配置思路又不一样。核心变化是要输出类型声明文件并且要考虑使用者可能用 CJS 也可能用 ESM。{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: bundler, declaration: true, declarationMap: true, sourceMap: true, rootDir: src, outDir: dist, strict: true, esModuleInterop: true, skipLibCheck: true, isolatedModules: true }, include: [src] }库项目里 declaration 必须开否则使用方拿不到类型提示。declarationMap 我也建议开它把 .d.ts 文件映射回源码使用方可以点击跳转到你源码的实现位置这对提升开发体验帮助很大。不过库构建比应用项目复杂得多通常不会只用 tsc 一把梭。很多库会同时产出 ESM 和 CJS 两套产物tsc 配置需要拆成多份比如 tsconfig.esm.json 输出 .jstsconfig.cjs.json 输出 .cjs。package.json 里 exports 字段也要配置好指向不同的产物。如果你在做一个被多个项目依赖的库建议花时间把构建链路搭完整tsconfig 只是其中一环。4. 项目实战里的高级用法extends 与 references4.1 extends把公共配置抽出来避免三份配置改到怀疑人生做 monorepo 或者一个仓库里有多个 TS 子项目时最朴素的需求是“这些项目的编译器选项大部分一样”。最直接的做法是复制粘贴但维护起来会崩溃改一个选项要同步好几个文件。tsconfig 的 extends 就是解决这个问题的。根目录建一个 tsconfig.base.json把公共的 compilerOptions 放进去子项目只需要 extends 它再覆盖差异项// tsconfig.base.json { compilerOptions: { target: ES2020, strict: true, esModuleInterop: true, skipLibCheck: true } }// packages/server/tsconfig.json { extends: ../../tsconfig.base.json, compilerOptions: { module: commonjs, outDir: dist }, include: [src] }这里有一个关键坑位extends 里的相对路径是相对于被继承的配置文件去解析的。上面例子中../../tsconfig.base.json 是相对于 packages/server 这个目录的路径。很多人在更深的目录里配 extends 时写错相对路径导致怎么配都不生效优先检查这里的路径解析。另一个需要注意的点是数组类型的配置项在继承时是整体替换不是合并。比如子项目配了 include 数组就不会继承父级 includecompilerOptions 里的 paths 同样如此。所以公共配置一般不要放 include、exclude、paths 这类“项目强相关”的配置只放通用的编译选项。4.2 references 与 project referencesmonorepo 里依赖顺序编排当 monorepo 规模变大一个仓库里有工具库、组件库、应用等多个 TS 项目并且它们之间有依赖关系时references 才真正派上用场。它允许你声明项目间的引用关系让 TypeScript 知道“A 项目依赖 B 项目”构建时自动按依赖顺序先构建依赖。配置 references 需要配套几个前提被引用的项目必须打开 composite: true这会强制它的 declaration 也是 true因为项目引用要依赖生成的 .d.ts 文件建立类型连接然后在根 tsconfig.json 里列出 references{ files: [], references: [ { path: ./packages/utils }, { path: ./packages/components }, { path: ./apps/website } ] }配合 tsc -b 或 tsc --build 模式TypeScript 会自动根据 references 图构建依赖并且只重新构建发生变更的项目构建性能提升非常明显。我在一个十多个子包的仓库里用过 project references冷编译从 40 秒以上降到 20 秒左右增量构建基本上毫秒级。但 project references 也有不低的配置成本。它要求所有被引用项目都有独立的 tsconfig、正确的 declaration 输出且依赖路径必须准确匹配。小项目或者依赖关系不复杂的仓库建议先忍着不用等体量真的大到编不动了再迁移否则前期收益不足以覆盖折腾成本。4.3 与打包器协作时的边界划分在现代前端工程里tsc 通常不是唯一的构建工具。Vite 用 esbuild 转译单文件Webpack 用 babel-loader 或 swc-loader这些工具只负责语法转换不管类型检查。于是类型检查被单独拆出来成为一条命令tsc --noEmit。这其实是把 TS 的职责收窄到“类型安全守门员”的角色构建效率和类型安全两不误。这种情况下你要理清三套配置tsconfig 控制 TS 的解析规则打包器的 alias/resolve 控制运行时模块解析测试框架如 Jest、Vitest有自己的模块映射。三套配置并不是自动同步的任何一处不一致都会产生“编辑器不报错但运行时报错”的诡异现象。我的经验是把 tsconfig 的 paths 视为唯一事实来源然后每次修改它时强制自己同步检查打包器 alias 和测试配置。这个检查动作可以做成一个 checklist或者写个脚本校验一致性能省掉大量只发生一次、但排查时间极长的坑。5. 高频坑位排查与避坑经验5.1 paths 配了但编译不生效一个初看完全没道理的情况tsconfig 里配了 paths编辑器里路径提示一切正常但 tsc 编译时报“Cannot find module”。最常见的原因是 TS5 之前paths 必须要配合 baseUrl 一起写而部分旧教程给出的示例是{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }如果你的 tsconfig 里写了 baseUrl但值是错的或者 paths 里的相对路径相对于错误位置解析就会出问题。TS5 之后 baseUrl 可以省略直接写{ compilerOptions: { paths: { /*: [./src/*] } } }路径相对 tsconfig.json 所在目录解析。改成这种写法之后绝大多数 baseUrl 相关的问题都能消除。另外还有一种情况是路径别名本身配对了但打包器那边没有同步典型的就是 Vite 项目里改完 tsconfig 忘了改 vite.config.ts。这种情况 tsc 完全没事一跑 dev server 就报模块找不到排查方向要转向打包器配置。5.2 esModuleInterop 引发的默认导入报错报错信息大概是Module can only be default-imported using the esModuleInterop flag。你明明照着官方文档写了 import React from react结果 TS 不让过。这个报错的原因在 TS 对 ES Module 和 CommonJS 互操作的处理规则。没开 esModuleInterop 时import default from 一个 CJS 模块会被视为不合法TS 要求你用 import * as React from react 这种形式。但要命的是某些包比如 React在自己的 .d.ts 里有 export 语法import * as React 也只是碰巧能用不是所有 CJS 包都适用。我的处理方式是一律开启 esModuleInterop同时开启 allowSyntheticDefaultImports 作为类型层面的兜底。前者同时影响类型检查和运行时包装后者只影响类型检查。两个一起开绝大多数 default import 的报错都能解决。注意如果运行时用的是 esbuild 或 Babel它们的 interop 行为和 tsc 有细微差别但一般不会造成实际影响。5.3 isolatedModules 与 type-only export 的冲突开了 isolatedModules 之后在某些文件里会出现这样的报错Re-exporting a type when the --isolatedModules flag is provided requires using export type。原因是 isolatedModules 要求每个文件都能被单文件转译工具独立处理。当你写 export { SomeType } from ./types 时Babel 单独处理这个文件时无法判断 SomeType 到底是类型还是值它必须假设是值于是你必须在语法层面显式告诉它这是类型。改成 export type { SomeType } from ./types 就可以了。这个坑在从纯 tsc 构建迁移到 Vite/esbuild 构建时几乎是必踩的。解决方案很机械但回报反射到 TypeScript 文件里把所有 re-export 的类型声明加上 type 关键字。新项目干脆从一开始就养成习惯类型导出统一用 export type。5.4 skipLibCheck 是偷懒还是必要skipLibCheck 跳过所有 .d.ts 文件的类型检查。很多人理解成“这是个偷懒选项别开”但实际工程里这个选项几乎是必备的。原因在于第三方包的 .d.ts 文件之间经常存在类型冲突这些冲突不是你写的代码引起的也不能通过升级版本解决只能靠 skipLibCheck 绕过去。在大型项目里不开 skipLibCheck 的编译时间可能是开了之后的两三倍因为编译器要遍历 node_modules 里海量的声明文件做类型检查。同时也必须诚实skipLibCheck 开了之后某些第三方包自身类型定义的问题会从编译器眼皮底下溜过去可能导致 IDE 提示和实际运行时行为不一致。但这属于成本收益权衡问题我建议所有实际项目都开不开的代价远大于收益。5.5 两个能救命的小命令最后分享两个我经常用的命令一个是 npx tsc --showConfig它会打印出 tsconfig 最终生效的完整配置包括 extends 合并后的结果。当你怀疑配置没生效或者继承了错误选项时先跑这个看一眼比逐个人肉对比文件高效得多。另一个是 npx tsc --init它会生成一个带详细注释的默认 tsconfig.json。虽然它生成的配置不一定贴合你的项目但注释内容本身就是一份很完整的官方文档导读适合刚开始学 TS 配置的人逐条阅读。我一开始就是靠着这两条命令把编译器选项的“为什么”一个个搞明白的。tsconfig 配置这件事本质上不是背选项而是理解你的项目运行在什么环境、由什么工具链构建、产物给谁消费。这三个问题想清楚了配置里的每个选项都有合理解释改起来也不会心虚。我自己的习惯是新项目初始化时先把 target、module、strict 定下来再根据工程类型配文件范围和输出项最后用一次 tsc --showConfig 确认最终结果。配置写成之后不是一劳永逸的随着项目从后端切换到前端、从 Webpack 迁移到 Vite、从 CJS 改成 ESMmoduleResolution 和 target 几乎一定会动这时候当初记下来的配置理由就是你最快找到新方案的路标。