真正能把 TypeScript 配置与工程化讲清楚的内容并不多。这章看起来最简单,无非是一个 tsconfig.json,加上几个脚本命令,但实际它决定了一个项目在后续一年里的编译速度、类型可靠程度、跨模块协作方式。很多项目初期跑得飞快,等到几千个文件、多人并行开发、依赖一多,才开始暴露出配置层面的问题。所以这一篇就把 TypeScript 配置与工程化放在一起讲,适合正在维护中型以上项目、或者准备给团队定一套 TypeScript 规范的人参考。
1. 先把 tsconfig 当成一份工程契约,而不是“能编译就行”的开关
1.1 target、module、lib 三个字段看着像同一个概念,其实是三套系统
很多初学者容易把 target、module、lib 混为一谈,觉得它们都是“决定代码最终长什么样”的选项。实际上,它们分别管三件不同的事:
- target 决定语法要降级到什么版本,比如把新语法转成 ES2017 还是 ES2020;
- module 决定产物里的模块系统,是 CommonJS 风格,还是 ES Module 风格;
- lib 决定类型检查器默认能识别的内置 API 类型,比如 Set、Promise、Array.at 这些方法是否存在。
它们三者的联动关系,比大多数人想象的要复杂。target 改了,生成代码里的 async/await 或者可选链可能被转换;lib 如果没跟上,代码里调用的某些新 API 就会被判定为“类型不存在”。反过来说,lib 里声明了很多 API,但 target 太低,转译器也不会凭空给你补 polyfill,最后只是“类型上允许、运行时未必支持”。
我见过一个很典型的场景:有人在代码里用了数组的.at()方法,本地 TypeScript 版本没报错,因为编辑器恰好加载了更完整的 lib;但 CI 环境里的 tsconfig 把 target 定在 ES2019,又没有显式配置 lib,结果类型检查直接失败。这类问题不看配置很难定位,因为你代码本身没问题,问题是“配置里声明的能力边界”跟不上代码。
这里我的建议是:把 target、module、lib 当成三套独立的决策来做。target 看你的运行环境要求,lib 看你要用哪些新 API 类型,module 看你的产物给谁消费。不要把 ES2022 这种值同时填进三个字段就以为万事大吉。
一个相对稳的配置长这样:
{ "compilerOptions": { "target": "ES2020", "module": "Node16", "lib": ["ES2021", "DOM"] } }这只是一个示例。真实项目里,浏览器端和 Node 端的 lib 取舍完全不同。但核心原则是一致的:显式写出 lib,不要隐式依赖 target 自动附带的那套。
1.2 strict 的代价很大,但多数项目应该在第一天就把它打开
strict 不是一个单选项,它更像是一整组规则的汇总,包括 noImplicitAny、strictNullChecks、strictFunctionTypes、strictPropertyInitialization、strictBindCallApply 等若干条。打开 strict 的代价很明显:代码里所有隐式 any、可能的空值、未初始化属性都会被查出来;对于没接触过严格类型的开发者来说,第一天可能就会收到几十个报错。
但代价换回来的收益更明显。举一个最简单的例子:
function prefix(name: string | null) { return 'user:' + name; }如果没开 strictNullChecks,这段代码编译不会报错,运行结果却可能变成user:null。开了 strict 以后,你会被逼着先处理空值:
function prefix(name: string | null) { return 'user:' + (name ?? 'unknown'); }这种“逼着”在工程化里非常重要。因为类型系统的价值不是在你写代码这一刻体现,而是在三个月后、别人接手时、或你自己在调试某个诡异线上问题时体现。打开 strict 的项目,很多问题在编译阶段就被拦下来了,运行阶段反而清爽很多。
老项目如果一开始没开 strict,不要试图一天晚上把它全部打开,那只会让团队产生对抗情绪。更现实的做法是:新项目从一开始就开;老项目按目录分批开放,每次开放只处理一批文件,同时把“未开放 strict 的目录”记录成技术债清单。最怕的是永远不开,因为每拖延一天,新增代码都在继续制造新的隐式 any,后面的迁移成本只会越来越高。
1.3 include 和 exclude 只能缩小入口,挡不住“被 import 进范围内的文件”
tsconfig 里的 include 和 exclude 看起来很容易理解,但常见误区很多。include 是一个入口白名单,exclude 是从白名单里剔除某些路径。可很多开发者把 exclude 当成“万能屏蔽”,想把某些有问题的第三方目录排除掉,却发现根本不起作用。
原因在于:TypeScript 做类型检查时,不只看入口文件,还会沿着所有 import 关系把依赖文件拉进类型推导过程。如果某个问题文件在你的 src 里被 import 了,即使它不在 include 范围内,也可能因为类型依赖链条而参与检查。
另一个常见坑是“把构建产物也放进检查路径”。有些项目在根目录 include 写得很宽,比如["src", "dist"],结果上一轮构建生成的.d.ts或.js文件,下一轮又把它们当输入文件重新检查,既慢又容易产生迷惑性报错。正确做法是让 include 只精确指向源码目录:
{ "include": ["src"], "exclude": ["node_modules", "dist", "coverage"] }严格来说,exclude 里的 node_modules 在多数情况下是多余的,因为默认就会排除它。但写出来有一个好处:让读配置的人明确知道“哪些目录是被有意排除的”。真正需要避免的是靠 exclude 去解决“某个第三方包类型有问题”这种场景——那种问题应该靠类型声明或模块解析配置解决,而不是在入口处把它藏起来。
2. 提速第一步:把类型检查和代码转译拆成两条赛道
2.1 类型检查为什么往往成为团队协作里的第一块减速带
很多项目的构建流程,是把“类型检查”和“把 TS 代码转成 JS”绑在一起做的。项目小的时候无所谓;等到文件上千,你会明显感觉到:每次按保存,编辑器要重新检查整个依赖图;每次跑构建,又要把整套类型检查从头过一遍。
原因在于,类型检查本质上需要追踪类型流向。简单说,它要做“类型推导”和“类型验证”,这比单纯的“语法去掉”要重得多。转译只需要处理单个文件或少量文件,类型检查却必须沿着 import 图把关联的类型全走一遍。
工程化里最值得做的第一件事,就是把这两件事拆开。比如用tsc --noEmit做类型检查,它只负责验证,不输出 JS 文件;而在开发调试或构建阶段,用另一套只做语法转换的流程,把 TS 语法剥掉、输出 JS。转译不查类型,所以速度会快很多;类型检查就放到提交前、CI 门禁里去做。
这里的核心思路是:保留“类型检查必须存在”的底线,但不要让它成为每次保存、每次热更新都要付出的代价。打个比方:快递员打包发货时不需要每次都复核整个仓库的商品账目,账目复核应该放在日常盘点或出货前质检环节。
2.2 保存、提交、发布三个阶段,检查密度应该不一样
不同类型的代码变动,需要不同的检查深度。我的习惯是把检查分为三层:
- 保存阶段:只做快速转译,让开发调试尽快看到效果。这里的报错尽可能聚焦在当前文件,不要让全项目的存量错误干扰新代码调试。
- 提交阶段:做增量类型检查和静态检查。只检查本次变更涉及的文件及相关依赖,保证提交进去的代码是基本健康的。
- 发布/合并阶段:做一次全量类型检查。因为增量检查可能漏掉跨文件相互影响的问题,全量检查才有最终说服力。
对应的 package.json scripts 大致是这样:
{ "scripts": { "typecheck": "tsc --noEmit", "typecheck:ci": "tsc --noEmit --force" } }这里的--force只是示意,意思是 CI 环境里不要信任本地增量缓存。如果你用的是自身支持增量模式的编译流程,本地可以在 tsconfig 里开incremental: true,生成.tsbuildinfo缓存文件;但要注意,这个缓存文件必须在版本管理里被忽略,同时 CI 环境最好不要复用它,否则容易出现在 CI 上“怎么都报不出错”、本地却一堆错的反常情况。
三层检查密度的设计,本质上是在“反馈速度”和“检查可靠性”之间找平衡。开发者最反感的是每次保存都卡三秒;但只要在提交和合并阶段严格把关,项目质量并不会因此失控。
2.3 sourceMap、declaration 和 outDir 是给协作双方看的配置
如果你只写业务代码,sourceMap 可能只是“断点调试能用”的印象;但如果你是写公共库或者多包仓库,sourceMap、declaration 和 outDir 这三项配置,直接决定了下游使用者能不能顺畅地定位问题。
先看 declaration。它控制是否生成.d.ts声明文件。库源码经过转译后,JS 文件里的类型信息基本都丢掉了,如果不下发声明文件,下游消费方完全拿不到提示,只能看到一堆any。所以凡是需要对外发布的包,declaration 几乎都必须开。
再看 declarationMap。它会把.d.ts文件里的位置映射回源.ts文件。有了它,下游开发者在 IDE 里跳转到某个声明时,可以直接回到你的源码,而不是停在一个抽象的类型签名上。这个对开源库和公司内部公共包体验提升非常明显,但很多人会忽略。
outDir 则是决定产物输出目录的。这里有一个容易被忽略的细节:产物目录最好不要和源码目录嵌套,比如 src 输出到 dist,就不要让 dist 出现在 src 的 include 范围内。否则下次全量检查时,旧产物文件可能会被当成源文件重复检查,既拖慢速度又污染报错信息。
这三个配置看着简单,但它们的组合使用方式,往往能看出一个团队是把 TS 当成“能跑就行”的工具,还是当成一套给上下游协作的工程规范。
3. 路径别名与多包仓库:模块解析里真正的三个暗坑
3.1 paths 只改了 TypeScript 的“视线”,运行时未必跟得上
路径别名是大多数项目都会用到的东西。为了避免写../../../utils/format这种深层相对路径,很多人会在 tsconfig 里配置:
{ "baseUrl": ".", "paths": { "@core/*": ["src/core/*"] } }配置之后,代码里写import { format } from '@core/format',TypeScript 编译阶段确实能找到文件,类型检查也能通过。但问题常常出现在运行阶段:你转译出来的 JS 产物里,模块路径仍然可能是@core/format,而运行时模块解析环境根本不知道@core对应哪个目录,于是一执行就报“模块找不到”。
这个坑要注意的关键点是:tsconfig 里的 paths 只是告诉你 TypeScript “允许这么写”,并没有告诉打包器、运行时、或 Node 的模块解析器也这么做。因此,只要项目里用了路径别名,就必须保证构建链里同步对齐这套映射方案。常见的做法是在打包配置里也配置对应的别名,或者通过 package.json 里的 imports 字段做一套统一映射,而不是让 tsconfig 成为唯一的信息源。
给一个可落地的检查清单:
- 确认转译后的产物里,路径别名是否被替换成真实相对路径;
- 确认构建工具配置里的别名和 tsconfig 里完全一致;
- 确认运行时如果直接加载产物,是否还需要额外的模块映射配置;
- 确认新增目录时,不会因为路径别名映射范围不够,导致编译通过却运行失败。
大部分“编译通过了、运行就崩”的问题,都出在这一层。
3.2 多包场景里的项目引用,先解决声明文件的产出顺序
在 monorepo 或者多包仓库里,一个包经常依赖另一个包。此时容易遇到两种情况:直接引用另一个包的源码目录,或者引用另一个包发布后的产物和声明文件。
引用源码目录的优点是调试方便、不需要先构建依赖方;缺点是每个包都要互相认识对方的源码结构,类型检查的跨包路径也容易变得越来越复杂。引用产物和声明文件则更接近最终运行状态,但要求被依赖的包必须先完成构建,至少要先生成声明文件,否则下游包的 TypeScript 只能在产物里看到一个“空壳”。
TypeScript 提供了一种工程级方案:项目引用。通过reference配置,一个项目可以明确声明自己依赖了哪些子项目,并让构建器按依赖顺序自动处理构建。使用项目引用的前提是每个被引用项目都开启composite: true,同时把declaration: true打开,因为项目引用本质上依赖声明文件来传递类型信息。
这套方案真正能帮上忙的场景,是大型仓库里需要反复跨包构建、并且依赖关系稳定明确的情况。但如果你的包之间存在循环依赖,项目引用也救不了你。工程化解决不了架构问题,循环依赖应该先在代码层消除,而不是靠配置去硬扛。
3.3 编译报错和运行报错要分开查,别只盯 import 拼写
接到“模块找不到”类型的报错,第一反应不应该是反复检查 import 拼写,而是先判断报错发生在“编译阶段”还是“运行阶段”。
如果是编译阶段报错,比如Cannot find module出现在你用 tsc 做类型检查的时候,问题大多数出在 tsconfig 的模块解析配置上。可能是 moduleResolution 和 module 不匹配,可能是 paths 没配全,也可能是某个包没有类型入口。这类问题改配置就能解决,代码本身可能没问题。
如果是运行阶段报错,比如转译后的 JS 在执行时报找不到模块,那就是产物实际加载路径的问题。这时候要去看产物文件结构:转译后文件到底在哪一层目录,模块写法是 CommonJS 的 require 还是 ESM 的 import,路径别名在产物里是否还被保留。两套排查思路完全不同,混在一起查,效率会非常低。
我一般会做一个简单表格来判断:
| 报错现象 | 优先怀疑层 | 处理位置 |
|---|---|---|
| 编译阶段无法解析模块 | tsconfig 模块解析 | 配置对齐 |
| 编译通过、运行报模块找不到 | 转译/打包产物 | 构建链路配置 |
| 包有产物但没有类型提示 | 声明文件缺失 | 补类型声明 |
4. 把团队规范焊死在配置里:格式化、静态检查与提交门禁
4.1 类型检查、静态检查、格式化三件事,职责别搞混
工程化到了团队协作阶段,光靠 tsconfig 是不够的,还要搭配静态检查、格式化和测试这些手段。但很多团队会把它们的职责混在一起,导致配置文件越看越复杂,问题却越查越乱。
TypeScript 的主要职责是类型安全:能不能赋值、参数对不对、有没有隐式 any。静态检查工具的主要职责是代码卫生:有没有未使用变量、有没有危险写法、命名是否符合规范。格式化的职责就更单纯:缩进、引号、逗号风格,统一排版。三件事互相有交集,但不要互相替代。
典型的反面做法是:在 tsconfig 里把 noUnusedLocals 关了,理由是“静态检查工具已经能查未使用变量”;同时又在静态检查工具里把某个没用的规则关掉,理由是“TS 配置已经管过了”。结果两边都觉得对方在管,实际上都没管。规则归属最好清晰统一:能交给 TS 的类型规则就交给 TS,代码风格和潜在逻辑问题交给静态检查,排版统一交给格式化工具。
团队里还要尽量做到“配置共享”。把 tsconfig、静态检查配置、格式化配置提升到项目共享层,而不是每个项目各写一份。多个项目各写一份,半年后一定会出现 A 项目开了严格检查、B 项目还能随便 any 的割裂现象。
4.2 提交前的四道门禁,顺序比数量更重要
我推荐团队在提交代码前设置四个阶段的门禁,顺序很重要:
- 格式化:先统一排版,避免后续 diff 里混入大量无关格式变更;
- 静态检查:检查当前变更文件的代码风格和潜在隐患;
- 类型检查:对变更相关范围做类型验证;
- 针对性测试:跑和本次变更相关的测试集,不是全量测试。
顺序之所以重要,是因为每一步失败的成本不一样。格式化失败最快修复,测试失败成本最高。如果一上来先跑全量测试,很可能连格式化还不统一的代码也被拉进测试,产生一堆无效失败。
提交钩子里的脚本大致长这样:
format --check lint tsc --noEmit test --related这里要特别提醒一点:本地钩子里的检查,可以被开发者的本地环境差异影响。比如某台机器装了不同版本的依赖、缓存没有清理、或者配置被跳过。所以本地检查只能作为第一道防线,真正有公信力的检查必须放在 CI 门禁里。CI 环境干净、可重复、不信任本地缓存,跑出来的结果才是团队可以依赖的“标准答案”。
4.3 门禁失败的信息,决定了开发者是修问题还是绕过问题
配置门禁容易,让开发者愿意配合门禁很难。我观察到一个规律:门禁失败信息写得越模糊,开发者越倾向于绕过门禁。要么直接--force提交,要么在配置里把某条规则临时注释掉,然后“忘了”恢复。
失败的提示至少要包含三样信息:
- 执行的是哪条命令;
- 报错的具体文件和规则位置;
- 修复方向或去哪个配置文件里看更多说明。
比如类型检查失败时,只输出Found 3 errors是不够的。更好的做法是让命令输出具体到文件、行列和错误类型,并且在钩子提示里写清楚“如果你需要临时跳过检查,请先确认不会合入主干,并在提交说明里注明原因”。
门禁的意义不在于制造障碍,而在于让每次提交都有可追溯的质量记录。一个写得好的门禁提示,本质上是一段面向开发者的即时辅导,比事后 review 的体验好得多。
5. 最后一公里:类型声明缺失、孤立模块与配置漂移
5.1 第三方依赖没有类型时,声明文件要补但也别补到失控
工程化最常见的临门一脚问题,是某个依赖没有官方类型声明,或者声明写得很烂。很多人第一时间想到的是把模块声明成 any,比如建一个全局声明文件,里面写declare module '*',结果整个项目的类型推断都被带崩了。
正确做法是给特定模块补一份最小声明。只声明确实用到的导出,不要试图完整还原整个模块的类型。比如:
declare module 'legacy-util' { export function calc(input: string): number; export const version: string; }这里用的是虚构模块名,实际项目里替换成你依赖的真实模块名。声明文件放在项目的 types 目录下,并在 tsconfig 里确认 typeRoots 或 include 能覆盖到它。
还有一个容易被忽略的点:手动补的声明要纳入版本管理,并且在 readme 或注释里记录为什么补、补到哪个版本、依赖方升级后是否需要重新评估。否则三个月后维护者已经说不清这个声明文件是“官方给的”还是“自己编的”,一升级依赖,破绽全出来了。
5.2 孤立转译模式下的配置项,往往才是跨环境报错的来源
有些构建链路在转译 TS 时,会把每个文件当成独立单元处理,不像 tsc 那样基于全项目类型信息工作。这种模式速度很快,但对代码书写有一定的约束。比如,你依赖了某个类型并用export { SomeType }这种写法把它导出,在孤立转译模式下,它可能因为无法判断这是类型还是值,导致产物里留下一个不该存在的导入,或反过来把该保留的导入删掉。
隔离文件模式下的典型配置项就是 isolatedModules。开启后,TypeScript 会提前暴露那些“在单文件视角下不合法”的写法,比如:
- 某些 const enum 的写法;
- 只含类型的再导出;
- 需要跨文件推导才能决定保留或删除的导入。
新项目直接把这些配置打开是比较稳妥的,因为现在越来越多人用按文件转译的方式进行构建。老项目如果长期依赖 tsc 的全量类型检查做转译,迁移到新型构建链路时要格外注意这类问题。报错不一定在项目里立刻出现,往往要等到产物真正跑起来、某个类型残留成运行时导入时才暴露。
5.3 TypeScript 版本升级带来的默认值变化,要用显式配置来对抗漂移
最后一个工程化里的隐藏问题,是 TypeScript 版本升级带来的“默认值漂移”。很多 tsconfig 配置只写了自己关心的字段,其他大量选项都依赖当前版本的默认值。于是在某个大版本升级之后,团队突然发现 CI 出现几百个新报错,明明自己一行代码都没改。
这不是代码变烂了,而是检查规则变严了。面对这种情况,不要第一反应去关规则。正确步骤是:先区分“新代码触发的错误”和“历史代码被新规则覆盖产生的错误”。新代码的错误尽快修复;历史代码的错误,可以单独列出来,分批处理,而不是一刀切降级配置。
我自己的做法是:在 tsconfig 里显式写出所有对项目行为有决定意义的字段,不依赖默认值。这样升级后,哪些行为是项目自己设定的、哪些是跟随版本变化的,一眼就能看清。同时,每一次 TypeScript 升级都要在 CI 里做一次全量类型检查,并且在一个独立的合并请求里完成升级,而不是混在业务改动里顺手带上去。
配置漂移的控制,说到底是“可追踪性”的问题。你不需要阻止工具版本前进,但你必须让每一次前进的后果都是可见的、可回滚的。
最后说一个我自己的习惯:每次接手一个 TypeScript 项目,我会先花半小时把 tsconfig 从头到尾读一遍,而不是急着翻业务代码。因为配置里藏着这个项目团队对类型安全的真实态度。配置写得很随意,代码再漂亮也维持不了多久。如果你现在正被某条“编译过了但运行就崩”的问题困住,优先去看 module、paths、declaration 这三个位置;很多时候,问题不在你的代码,而在你把代码交给谁去编译、用什么配置去编译。