TypeScript · tsconfig 与构建链路

vite build 不查类型! 检查归 tsc --noEmit, 转译归 esbuild/swc — 两道工序分开管

.ts / .tsx 源码 类型标注 · 泛型 · interface 枚举 · 装饰器 · const 断言 tsconfig.json 决定两侧怎么处理它 tsc --noEmit 只做类型检查, 不出文件 全程序视角: 慢但完整 vite / esbuild / swc 只转译, 逐文件, 极快 不查类型! 报错只在运行时出现 类型结论: 通过/报错清单 CI 门禁: 不过不合并 产物: 剥掉类型的 JS bundle + chunk + sourcemap 常见误解 "我 vite build 成功 = 类型没问题" 错! 转译器把类型 整块剥掉就跑 类型错误照样 带着上线 // 现代应用 tsconfig 骨架 — 每一行都有理由 { "compilerOptions": { "strict": true, // 一揽子严格项, 新项目必开 "noEmit": true, // 只检查; 产物交给打包器 "target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext", "moduleResolution": "bundler", "isolatedModules": true, // 逐文件转译的兼容契约 "skipLibCheck": true, // 跳过第三方 .d.ts 检查, 提速 "paths": { "@/*": ["./src/*"] } // 别名: 打包器要同步配! } }

两道工序分离

  • • 检查: tsc --noEmit 全程序视角
  • • 转译: esbuild/swc 逐文件极速
  • • vite build 默认不做类型检查
  • • CI 里两条命令都要跑

strict 是全家桶

  • • strictNullChecks 是灵魂
  • • noImplicitAny 堵 any 暗门
  • • 老项目可分目录渐进开启
  • • isolatedModules 是转译契约

解析要三方一致

  • • moduleResolution 与打包器对齐
  • • paths 别名 tsconfig+bundler 双配
  • • target 决定语法降级与 helper
  • • exports 字段是包解析权威

💡 一句话理解

TypeScript 生态把"编译"拆成了两件事: 类型检查与代码转译。tsc 是全程序检查器(慢而完整), esbuild/swc 是逐文件转译器(快如闪电但不查类型)。Vite 默认用后者出产物 — 所以 vite build 通过只说明"能转译", 不说明"类型没问题"。正确的姿势是 CI 里两条腿: tsc --noEmit 管类型门禁, vite build 管产物。

tsconfig 里的每个开关都对应这条链路的某个环节: strict 家族管检查严度; target/lib 管语法降级与可用 API; module/moduleResolution 管模块怎么被解析(必须与打包器一致); isolatedModules 是"我的代码保证能被逐文件转译"的承诺。配置不是玄学, 是链路契约。

🧠 必知必会 必考 & 必会

noEmit + 检查专职
tsc 只出错误清单不出文件, 产物交给打包器 — 现代应用标准分工。
// package.json
{ "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build" } }
strict 家族
strictNullChecks/noImplicitAny/strictFunctionTypes/strictBindCallApply/alwaysStrict 五件套打包。
{ "strict": true }
// 等价于同时打开五个最严格的开关
target / lib
target 决定降级到哪代 JS(影响可选链/空值合并是否转译); lib 声明可用 API 集。
{ "target": "ES2022", "lib": ["ES2022", "DOM"] }
// 用新 API 而 lib 没声明 → "xxx 不存在"假报错
module / moduleResolution
产什么格式 + 按什么规则找模块; 应用配 bundler, 库配 NodeNext 与发布格式对齐。
{ "module": "ESNext",
  "moduleResolution": "bundler" }   // vite 应用
isolatedModules
承诺每个文件可被独立转译: 禁止跨文件类型重导出裸 import、const enum 等单文件看不全的写法。
// ✗ isolatedModules 下:
export { SomeType } from './x';
// ✓ 必须显式: export type { SomeType } from './x';
paths 与运行时
paths 只管 tsc 的类型解析, 打包器不知道; vite/webpack/tsx 要各配各的。
// vite.config.ts
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }
skipLibCheck
跳过所有 .d.ts 的完整检查(只查你引用到的形状), 大幅提速; 自己的 d.ts 也被跳, 发布前单独验。
{ "skipLibCheck": true }
// 副作用: 依赖类型包的错误被掩盖
include/exclude
限定检查范围; 但被别处 import 的文件仍会被拉入编译, exclude 不是硬边界。
{ "include": ["src"] }
// src 外的 legacy 被 src import → 照样全量检查
project references
monorepo 多包增量: 各包独立 tsconfig, composite: true, tsc -b 按依赖图构建。
{ "references": [{ "path": "./packages/core" }] }
// tsc -b --build --verbose  增量只编改动包
declaration 发布
库要产出 .d.ts + types 字段; declarationMap 让用户"点进去"看你的源码。
{ "declaration": true,
  "emitDeclarationOnly": true,
  "outDir": "dist/types" }
verbatimModuleSyntax
强制 import type 显式化, 转译器按字面删 import, 杜绝"类型导入被当值保留"的产物污染。
{ "verbatimModuleSyntax": true }
// import type { User } from ... 必须写 type
tsc -b 缓存
composite 开启 .tsbuildinfo 增量; CI 缓存 tsbuildinfo 让类型检查秒级。
{ "composite": true, "incremental": true }
# CI: actions/cache 存 *.tsbuildinfo

🏭 生产实战 real world

场景 1 · 上线后才炸的类型错误: 补 CI 双门禁

vite build 全绿, 上线后 optional chaining 用在 number 上崩了。根因: 从没人跑过 tsc:

# .github/workflows/ci.yml
- run: pnpm typecheck    # tsc --noEmit
- run: pnpm test
- run: pnpm build        # vite build
# 本地 husky pre-push 也跑 typecheck, 双保险

上线首周抓出 17 个被转译器吞掉的类型错误。

场景 2 · 老仓库渐进开 strict: 分目录双 tsconfig

12 万行旧代码开 strict 等于停摆三个月。新代码 strict, 旧代码维持, 边界渐进推进:

// tsconfig.json (基础, 不开 strict)
{ "extends": "./tsconfig.base.json", "include": ["src-old"] }
// tsconfig.strict.json
{ "extends": "./tsconfig.base.json",
  "compilerOptions": { "strict": true },
  "include": ["src-new"] }
// CI: 新目录强制, 旧目录每季度迁移 10%

场景 3 · monorepo 增量构建: project references

20 个包每次全量检查 6 分钟。references + composite 后只重查被改的包链:

// 根 tsconfig.json
{ "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/ui" },
    { "path": "apps/web" } ] }
# tsc -b  ← 按依赖拓扑增量构建

全量 6min → 增量 40s。

场景 4 · 别名 404: paths 与打包器双配

tsc 检查通过, vite dev 却报 cannot resolve '@/lib/x'。tsconfig paths 只对 tsc 生效:

// vite.config.ts 必须同步:
import { fileURLToPath } from 'node:url';
export default {
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
};
// jest 侧还要 moduleNameMapper — 每个工具各配一次

场景 5 · 库发布: d.ts 从源码生成而非手写

手写声明与实现漂移, 用户编译报错但运行正常, 售后噩梦。改为声明自动生成:

{ "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist" },
  "include": ["src"] }
// package.json: "types": "./dist/index.d.ts"
// CI: 消费侧 tsc 编译通过作为发布门槛

场景 6 · skipLibCheck 提速与风险并存

全量检查 90s, 其中 70s 在检查 node_modules 的 .d.ts。开启后 20s, 但要单独守护自家声明:

{ "skipLibCheck": true }
// 库项目额外跑一次不带 skip 的检查自家 d.ts:
// tsc --noEmit --skipLibCheck false dist/index.d.ts
// 锁定关键依赖的类型版本(package-lock)防漂移

场景 7 · isolatedModules 修正存量代码

开启后 barrel 里裸类型 re-export 全部报错。批量改成显式 type 导出:

// 错: export { User, Order } from './types';
// 对: export type { User, Order } from './types';
// const enum 同理被禁 → 换 as const 对象:
const Kind = { a: 'a' } as const;
type Kind = typeof Kind[keyof typeof Kind];

场景 8 · target 选型: 语法降级与 polyfill 边界

target ES5 导致 ?? 可选链全转译, 产物肥 30%; 但只调 target 不够, polyfill 是另一层:

{ "target": "ES2020" }   // 保留 ?? 与 ?., 现代浏览器直跑
// 老浏览器兼容交给 @vitejs/plugin-legacy / core-js:
// polyfill 由打包器注入, ts target 只管语法降级

产物 -28%, Lighthouse 脚本解析时间同步下降。

场景 9 · ESM/CJS 双构建的 tsconfig 组合

库要双格式输出, 用两个 tsconfig 各自 emit, exports 字段分流:

// tsconfig.esm.json: module ESNext, outDir dist/esm
// tsconfig.cjs.json: module CommonJS, outDir dist/cjs
# tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json
// package.json exports: { import: dist/esm, require: dist/cjs }

场景 10 · 类型检查内存爆炸: fork 化 + 缓存

大仓 tsc 峰值 6GB, IDE 卡死。方案: 分项目 references + CI 缓存 + 必要时 fork:

# webpack 项目: thread 化类型检查
ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
// vite 项目: vite-plugin-checker 同理, 不阻塞转译主进程
// NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 以为 build 会查类型 — vite/esbuild/swc 只转译, 类型错误带病上线. 正解: CI 加 tsc --noEmit 门禁。
"build": "tsc --noEmit && vite build"
坑 2 · paths 只配 tsconfig — 打包器/测试框架各有一套解析, 不配就 404. 正解: 每个工具同步映射。
// vite: resolve.alias · jest: moduleNameMapper · tsx: tsconfig
坑 3 · exclude 不是硬边界 — 被 include 的文件 import 后照样进编译. 正解: 断干净引用或干脆迁移, 别指望 exclude 隔离。
// legacy 排除了, 但 src import 了它 → 全量检查又回来了
坑 4 · strictNullChecks 关着 — null/undefined 随处赋值, 迁移时债爆炸. 正解: 新项目必开; 老项目分目录渐进。
{ "strict": true }   // 别只开一半
坑 5 · skipLibCheck 掩盖自家 d.ts — 发布的声明文件有错, 用户侧才炸. 正解: 发布前单独全查自家声明。
tsc --noEmit dist/index.d.ts   # 发布门槛
坑 6 · include 过宽全仓扫 — 检查 20 万行 node_modules 外文件, IDE 卡死. 正解: 精确 include + references 拆分。
{ "include": ["src", "test"] }   # 别写 "**/*"
坑 7 · isolatedModules 漏 type 前缀 — 裸 re-export 类型在逐文件转译下产物损坏. 正解: export type {} 显式化。
// 错: export { User } from './u';
// 对: export type { User } from './u';
坑 8 · const enum 被禁 — isolatedModules/目标 ESM 下 const enum 有坑. 正解: as const 对象 + keyof typeof 替代。
const S = { on: 'on' } as const;
type S = typeof S[keyof typeof S];
坑 9 · target 太老产物肥 — ES5 降级让 ?? / ?./class 全展开. 正解: target 现代化(ES2020+), 兼容交给 polyfill 层。
{ "target": "ES2020" }   # 产物 -30% 常见
坑 10 · lib 缺新 API — 用 Array.at 报"不存在", 其实是 lib 没声明. 正解: lib 与 target 匹配并按需加 DOM 迭代器等。
{ "lib": ["ES2022", "DOM", "DOM.Iterable"] }
坑 11 · moduleResolution 错配 — bundler 产物配了 node10 解析, exports 包解析失败. 正解: vite 应用配 bundler, 发布库配 NodeNext。
{ "moduleResolution": "bundler" }   # vite 应用
坑 12 · verbatim 漏改 import — 开启后值导入类型文件不写 type 被保留成运行时 import. 正解: 全量改成 import type。
import type { User } from './user';
坑 13 · allowJs 混编漂移 — js 文件被 JSDoc 推断出的类型与 ts 侧预期打架. 正解: checkJs 选择性开启, 边界处 d.ts 声明。
{ "allowJs": true, "checkJs": false }  # 先宽松
坑 14 · tsbuildinfo 不进缓存 — CI 每次全量, 类型检查 5 分钟. 正解: composite+incremental, CI 缓存 tsbuildinfo。
cache:
  paths: ['**/*.tsbuildinfo']
坑 15 · references 忘 composite — 子包没开 composite, tsc -b 直接报错. 正解: 每个被引用包 composite: true。
{ "composite": true }   # 子包 tsconfig 必配
坑 16 · d.ts 与实现脱节 — 手写声明落后实现两个版本. 正解: tsc 自动生成 declaration, 禁手写。
{ "emitDeclarationOnly": true }
坑 17 · ts-node 运行差异 — ts-node/tsx 的解析与产物构建不一致, 本地能跑 CI 挂. 正解: dev 用 tsx(走 esbuild), CI 用与产物同一套命令验证。
npx tsx src/main.ts   # 与 build 同源转译器
坑 18 · noUnusedLocals 噪音 — 一开 CI 满屏未用变量, 团队直接关掉 strict. 正解: 分开关渐进, lint 处理风格类问题。
{ "noUnusedLocals": true }  # 与 strict 分开评估
坑 19 · 多 tsconfig 漂移 — app/test/lib 各一份配置关键字段不一致. 正解: tsconfig.base.json 共享, 各自只写差异。
{ "extends": "./tsconfig.base.json", "include": ["test"] }
坑 20 · baseUrl 依赖 — 非相对导入依赖 baseUrl, 社区正在淘汰且行为反直觉. 正解: paths 显式映射。
{ "paths": { "@/*": ["./src/*"] } }  # 不配 baseUrl 也能用