tsconfig与工程化实践
很多人学 TypeScript 时,会把注意力几乎全部放在语法上:泛型会不会写、infer看不看得懂、工具类型会不会用。可真正在工程里决定 TypeScript 上限的,往往不是这些,而是tsconfig.json。因为它决定了编译器到底有多严格、如何解析模块、怎样理解你的项目边界,以及你愿不愿意在开发期就把风险暴露出来。
说得直接一点:很多团队 TypeScript 用得一般,不是因为不会语法,而是因为配置太松。
tsconfig.json本质上是在定义团队和编译器之间的契约
它不是一个“复制粘贴模板文件”,而是在回答这些问题:
- 我们允许多宽松的类型行为
- 我们的代码会被编译到什么运行环境
- 我们的模块如何解析
- 我们是否把类型检查和构建拆开
- 哪些文件参与编译,哪些不参与
所以,tsconfig本质上体现的是团队对类型安全的态度,而不仅仅是工具链配置。
一个实用的起点配置
{"compilerOptions":{"target":"ES2020","module":"ESNext","moduleResolution":"Bundler","strict":true,"noUncheckedIndexedAccess":true,"exactOptionalPropertyTypes":true,"skipLibCheck":true}}这不是放之四海而皆准的唯一答案,但它代表一种相对成熟的取向:尽量让问题在开发期暴露,而不是把模糊和侥幸留到运行时。
strict是真正开启 TypeScript 的开关
如果只记一个配置项,那应该是strict。
开启strict后,TypeScript 会开始认真处理:
- 隐式
any - 空值问题
- 不安全赋值
- 宽松的函数参数兼容
- 各类潜在类型漏洞
不开strict,你当然还是“在用 TypeScript”,但很多最有价值的保护其实都被你自己关掉了。
如果你在接手老项目,不一定能一夜之间把严格选项全开,因为历史包袱可能很重。但长期方向应该清晰:逐步收紧,而不是为了清静持续放松。
noUncheckedIndexedAccess能暴露很多被忽略的真实风险
看一个例子:
constmap:Record<string,number>={};constvalue=map["x"];不开noUncheckedIndexedAccess时,value可能会被推成number;开启后,它会变成number | undefined。后者更符合现实,因为你根本不能保证"x"一定存在。
这个选项非常值得强调,因为大量线上 bug 都来自类似思维:
- 我以为这个 key 一定有
- 我以为这个数组下标一定取得到
- 我以为这个映射表已经初始化了
而 TypeScript 如果不够严格,就会默许这些“我以为”。
exactOptionalPropertyTypes让可选属性语义更准确
很多人对可选属性的理解过于粗糙,以为它只是“可以不写”。但从业务上看,这通常至少有两层不同含义:
- 属性不存在
- 属性存在,但值是
undefined
开启exactOptionalPropertyTypes后,TypeScript 会更认真地区分这两种情况。这对这些场景尤其有价值:
- PATCH 更新接口
- 表单回填与提交
- 配置覆盖逻辑
- DTO 与领域对象转换
你会开始更明确地思考:这个字段是真的可缺失,还是只是值可能为空。
skipLibCheck为什么很多项目会开
skipLibCheck: true的含义是跳过对依赖库声明文件的完整类型检查。很多项目会打开它,原因通常是:
- 提升编译性能
- 避免被第三方库声明问题卡住
这在工程上是个务实选择,但你要知道它的代价:你对某些依赖声明问题的感知会下降。所以它适合做性能与稳定性的折中,不适合被理解成“这样更安全”。
target、module、moduleResolution不只是语法项,它们和工具链强相关
target
决定输出代码面向哪个 JavaScript 运行环境,比如ES2017、ES2020。
module
决定模块输出形式,例如ESNext、CommonJS。
moduleResolution
决定 TypeScript 如何解析模块路径和依赖,现代前端项目中常见Bundler或NodeNext。
这些配置不是单独存在的,它们通常要和你的构建工具、运行环境、测试工具一起考虑。也就是说,tsconfig不是纯 TypeScript 世界里的孤岛,而是整个工程工具链的一部分。
类型检查和打包构建是两回事
这是现代前端和 Node.js 项目里非常关键的一层认知。很多人以为运行tsc就等于项目构建,但在现在的工具链里,事情常常不是这样。
例如:
- Vite 可能负责打包
- Next.js 可能负责编译和路由构建
- esbuild 或 SWC 可能负责转译
tsc --noEmit只负责类型检查
所以你需要明确区分:
- 谁负责类型检查
- 谁负责代码转译
- 谁负责产物构建
否则遇到问题时很容易把责任归错地方。
一个成熟项目的 TypeScript 原则
真正成熟的 TypeScript 项目,通常会坚持几个原则:
- 对外暴露的 API 类型明确
- 核心领域对象类型稳定
any控制在极少数必要场景- 对外部不可信数据先做运行时校验,再进入类型系统
- 利用工具类型减少重复
- 配置保持尽可能严格
其中最容易被忽略的一条,是运行时校验。因为 TypeScript 再强,也只存在于编译阶段。一个接口只要返回了脏数据,单靠类型注解并不会自动拯救你。
运行时校验为什么必须和 TypeScript 配套看
很多团队学到后面会有一种错觉:类型已经写得这么完整了,系统应该很安全。实际上这只对“你自己写出来的静态结构”成立,对外部世界并不成立。
典型风险包括:
- 后端返回结构和定义不一致
- 本地存储中的 JSON 被污染
- URL 参数缺失或格式不对
- 第三方库返回非预期数据
这时,像 Zod、Valibot 这类运行时校验库就很重要。它们不是和 TypeScript 重复,而是在补足 TypeScript 无法覆盖的那一半现实世界。
老项目怎么逐步变严格
如果你现在面对的是一个历史包袱很重的项目,不必一口气把所有配置拉满。更务实的方式是:
- 先开启
strict - 清理最危险的隐式
any - 逐步引入更精确的空值和索引访问检查
- 对新增模块要求更高,对存量模块渐进改造
类型治理本身就是工程治理,不可能永远靠一次性重写完成。
本文小结
TypeScript 的工程质量,不只由你会不会写语法决定,更由配置和团队边界决定。tsconfig.json不是一个“项目启动时顺手拷来的配置文件”,它实际上定义了整个代码库对风险的容忍度和对类型安全的投资力度。
你可以把它理解成一种工程立场:到底是愿意在开发期多面对一些报错,还是愿意把更多不确定性留到运行时。成熟团队通常会选择前者。
练习
- 新建一个
tsconfig.json,手动解释target、module、strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes的含义。 - 找一个老项目,看看是否开启了
strict,并评估如果要逐步收紧配置,第一步最适合做什么。 - 思考:哪些运行时错误仅靠 TypeScript 不能解决?你会如何用运行时校验工具补上这一部分。
后记
2026年5月22日于上海。