第18章:设计精华 —— 写出可靠 TypeScript 的原则
约 8 分钟 · 更新于 2026-09-01
第18章:设计精华 —— 写出可靠 TypeScript 的原则
收尾章。把前十七章压缩成一套工程判断:让 JavaScript 运行时事实与 TypeScript 静态模型保持一致;让外部数据先验证、内部状态可穷尽、公共 API 可理解;让类型系统降低风险,而不是隐藏风险。
一、统一命题:类型必须对应事实
TypeScript 最大的风险不是“类型不够高级”,而是类型声明与运行时事实不一致。
| 场景 | 错误做法 | 可靠做法 |
|---|
| API 响应 | json as User | unknown → 运行时校验 → User |
| 状态管理 | 多个独立布尔值 | 判别联合 |
| 找不到数据 | 非空断言 ! | 显式处理 undefined |
| 第三方 JS | 整包声明为 any | 按真实 API 补 .d.ts |
| 通用函数 | 输入输出都是 any | 泛型保留关系 |
| 模块别名 | 只配 paths | 编译器、bundler、运行时一致配置 |
| 错误处理 | 假设 catch 是 Error | 从 unknown 收窄 |
| 更新模型 | Partial<Entity> | 按业务规则定义命令 |
二、TypeScript 的安全边界
TypeScript 可以在编译期证明一部分事实:
- 属性和参数是否类型兼容。
- 联合分支是否完整。
- 泛型关系是否保持。
- 模块和声明是否可解析。
它不能证明:
- 网络数据真实符合接口。
- 权限、金额和状态转换符合业务规则。
- 异步任务一定按期完成。
- 数据库与缓存一致。
- 程序没有性能、安全和逻辑问题。
可靠系统需要:
text
静态类型 + 运行时验证 + 行为测试 + 监控与错误策略
三、十一条核心原则
原则 1:先学 JavaScript,再理解 TypeScript
类型被擦除后,闭包、this、Promise、原型、事件循环仍按 JavaScript 运行。
原则 2:新项目默认严格
开启 strict,再按项目风险评估 noUncheckedIndexedAccess、exactOptionalPropertyTypes 等选项。关闭规则必须有明确原因。
原则 3:让推断处理局部,让标注表达边界
局部变量避免重复标注;函数参数、公共返回值、模块 API 和外部协议应清晰定义。
原则 4:不确定值使用 unknown
unknown 迫使调用者验证,any 则让错误扩散。把 any 视为有期限的迁移债务。
原则 5:类型断言不是校验
每个 as 都应回答:“我掌握了什么编译器不知道的事实?”如果答案只是“想消除报错”,应修复模型或增加运行时检查。
原则 6:用联合表达有限状态
判别联合比多个可选字段和布尔值更可靠,可与 switch、never 配合穷尽检查。
原则 7:泛型用于保存关系
若类型参数只出现一次,通常没有建立关系。泛型不是“更高级的 any”,也不是越多越好。
原则 8:从稳定源派生类型
使用 keyof、索引访问、映射和工具类型减少重复;更进一步,可从运行时 schema 或协议定义生成静态类型。
原则 9:模块配置服从运行时
tsconfig、package.json、Node 和 bundler 必须讲同一种模块语言。类型检查通过不代表运行时一定能加载。
原则 10:公共类型首先服务使用者
错误信息、补全体验和文档可读性比类型技巧更重要。复杂类型应封装并命名,不把实现细节暴露给调用者。
原则 11:类型系统也需要测试和性能预算
为工具类型编写正确/错误用例;为声明文件做消费测试;为复杂类型观察编译时间和编辑器响应。
原则 12:模块配置必须模拟真实宿主
先确定 Node、浏览器还是 bundler 负责加载代码,再选择 module、moduleResolution、扩展名和 package.json 策略。TypeScript 不是模块加载器。
原则 13:声明文件是运行时事实的合同
.d.ts、模块增强和全局声明必须与真实 JavaScript 一致,并通过 ESM/CJS 或 bundler 消费测试验证。
原则 14:JavaScript 可以渐进类型化
allowJs、checkJs 和 JSDoc 能在不立即改写全部源码的情况下建立反馈。迁移目标是缩小不可信边界,不是追求扩展名统一。
原则 15:浏览器资源也要设计生命周期
DOM 查询要处理可空与元素类型;事件监听器、计时器、Observer 和连接要明确清理;JSX 类型规则以具体框架声明为准。
原则 16:构建工具各司其职
tsc、Babel/SWC/esbuild、bundler 和测试工具解决不同问题。转译成功不等于类型检查成功,降低 target 也不等于提供运行时 polyfill。
原则 17:结构兼容不等于绝对安全
TypeScript 为 JavaScript 生态接受部分不健全规则。公共库、可变容器和回调边界要从真实调用方向审查,而不能只以“编译通过”为依据。
原则 18:版本升级属于工程迁移
Release Notes 应转化为配置、类型推断、模块行为、声明兼容和性能的检查清单。升级编译器后必须运行完整反馈链,不能用批量断言掩盖变化。
四、any 治理
可接受的临时场景:
- 大型 JS 项目迁移的局部边界。
- 第三方声明暂时缺失。
- 类型系统当前无法表达、且已有运行时保证的极小范围。
治理方式:
- 把 any 限制在边界模块。
- 立即转为 unknown 或领域类型。
- 添加原因、负责人和清理条件。
- CI 统计新增 any 或抑制指令。
- 不让 any 进入公共 API。
五、断言治理
风险从低到高大致为:
text
经过运行时检查后的局部断言
< DOM/框架不变量断言
< 非空断言
< 普通 as
< 双重断言 as unknown as T
优先替代方案:
- 类型守卫。
- 判别联合。
- 更准确的泛型约束。
- satisfies。
- schema 校验。
- 调整数据流让不变量可见。
六、API 设计
可靠 API 应回答:
- 输入是否可能为空或失败?
- 函数是否修改参数?
- 返回 Promise 是否支持取消?
- 错误是异常、Result 还是状态码?
- 泛型关系是否能由调用点推断?
- 类型是否泄露内部实现或第三方依赖?
- 命名是否表达同步/异步、创建/查询和副作用?
示例:
ts
function findUser(id: UserId): User | undefined;
function requireUser(id: UserId): User;
async function loadUser(id: UserId, signal?: AbortSignal): Promise<User>;
名字和返回类型共同表达不同失败语义。
七、领域建模
不要直接用原始字符串和数字承载所有概念:
ts
type UserId = Brand<string, "UserId">;
type AmountInCents = Brand<number, "AmountInCents">;
不要用一份巨型接口贯穿数据库、服务端、网络和 UI:
text
数据库模型 → 领域模型 → DTO → 视图模型
各层只包含所需字段,通过明确转换保持边界。
八、错误处理
建立错误分类:
- 验证错误:告诉调用者字段和原因。
- 业务拒绝:稳定错误码,可预期处理。
- 依赖失败:可重试、降级或上报。
- 程序错误:不变量破坏,需要修复。
不要把所有错误都变成字符串,也不要把所有异常都吞成 undefined。
九、异步可靠性
类型为 Promise<T> 只表示未来结果,不包含:
- 超时。
- 取消。
- 重试。
- 幂等。
- 并发上限。
- 顺序保证。
- 资源清理。
这些都要进入 API 和运行协议。AbortSignal、队列容量、finally 清理和错误分类比“返回类型写对”更重要。
十、性能原则
编译性能
- 避免深递归条件类型。
- 减少巨大联合和重复实例化。
- 用 Project References 切分项目。
- 使用诊断工具定位,而非关闭严格模式。
运行性能
- 类型大多被擦除,不要凭类型复杂度猜运行速度。
- 关注真实输出代码、bundle、渲染、I/O 和算法。
- 运行时校验有成本,但应与边界风险比较。
- 先测量再优化。
十一、常见反模式
- JSON.parse(...) as T。
- 到处使用非空断言 !。
- 公共函数接受或返回 any。
- 多个布尔字段表达互斥状态。
- 所有更新接口都用 Partial<T>。
- 对象类型全部写成宽泛 Record<string, any>。
- 高级类型一行数百字符且无命名。
- 把类型错误当障碍,用断言绕开。
- 只依赖 IDE,不在 CI 运行 tsc。
- 构建工具能出包就认为类型检查完成。
- 模块别名只配置一处。
- 发布库不测试生成的声明和消费方式。
- 类型与运行时 schema 各写一份并长期漂移。
- 把 JavaScript 行为误认为 TypeScript 会修正。
- 用框架类型污染领域核心。
- Node、bundler 和浏览器项目复制同一份模块配置。
- Babel 或 bundler 能转译就移除 tsc 检查。
- 发布声明文件但不测试 ESM/CJS 消费。
- DOM 查询和资源监听大量依赖非空断言。
- 用结构兼容性掩盖本应区分的领域身份。
十二、代码审查清单
类型与模型
- 是否存在可用判别联合消除的矛盾状态?
- 是否把外部数据直接断言为可信类型?
- any 和 ! 是否有充分理由?
- 公共 API 是否容易理解和推断?
运行时
- 校验、错误、超时和取消是否明确?
- 类、枚举、装饰器产生的运行时代码是否符合预期?
- 日期、BigInt、Map 等跨 JSON 边界是否正确转换?
工程
- 模块配置与运行环境是否一致?
- 声明文件是否与实现同步?
- 类型测试、行为测试和消费测试是否覆盖?
- 类型复杂度是否影响编译性能?
十三、后续学习路线
前端应用
- DOM 类型、React/Vue 类型模式、状态机、表单 schema、API Client。
Node.js 与后端
- NodeNext 模块、流、AbortSignal、运行时校验、数据库类型边界、错误协议。
库与工具链
- 声明文件、模块发布、SemVer、API Extractor、类型测试、代码生成。
类型系统深入
- Handbook 的 Everyday Types、Narrowing、Functions、Objects、Creating Types from Types、Declaration Files。
十四、全书结语
学习 TypeScript 的典型过程是:
text
给变量加类型
→ 理解联合与收窄
→ 用泛型保存关系
→ 从已有定义派生类型
→ 设计运行时边界
→ 用类型表达领域状态和模块契约
真正掌握 TypeScript,不是能写出最长的条件类型,而是能回答:
- 这个类型对应什么运行时事实?
- 数据从哪里进入可信区域?
- 哪些状态应被类型消除?
- 这个 as 为什么成立?
- 失败、为空、取消和超时是否被表达?
- 公共 API 是否让调用者自然写出正确代码?
- 模块、声明、构建和运行是否一致?
- 当前配置模拟的宿主究竟是谁?
- .d.ts 是否通过真实消费项目验证?
- DOM、事件、异步任务和其他资源由谁清理?
可靠 TypeScript 的最终原则可以压缩成一句话:让静态模型忠实描述运行时事实,并在事实尚未建立的边界执行验证。
十五、总结
- TypeScript 是 JavaScript 的静态分析层,不是独立运行时。
- 类型安全的基础是严格模式、正确建模和减少 any/断言。
- 判别联合、泛型和派生类型用于表达状态与关系。
- 外部数据必须经过运行时验证。
- 模块配置、声明文件与真实运行环境必须一致。
- 类型测试、行为测试、消费测试和性能诊断共同构成工程闭环。
- 模块系统由真实宿主决定,声明文件必须与运行时导出一致。
- JavaScript、DOM、JSX 和多构建工具场景都需要明确边界与生命周期。
- 结构兼容存在有意的不健全取舍,编译通过不是绝对安全证明。
- 类型复杂度应服务使用者和维护者,而不是展示技巧。
原始资料引用
- TypeScript 深度教程
- 第4章:类型建模
- 第6章:模块化工程
- 第7章:高级类型
- 第10章:综合实战
- 第11章:兼容性与推断
- 第12章:模块深潜
- 第13章:声明文件
- 第14章:JavaScript 渐进类型化
- 第15章:浏览器开发
- 第16章:构建与工具链
- 第17章:版本演进