前端开发

TypeScript 实用类型设计

2026-08-10 #工程化#TypeScript#类型系统

用联合类型、泛型和类型收窄表达业务约束,减少无效状态。

核心思路

优先让类型从数据和函数返回值推导,公共边界再显式声明。使用判别联合表达状态机,避免多个布尔值产生不可能组合。

落地步骤

外部输入永远需要运行时校验,类型断言不能替代验证。复杂类型若难以解释,应回到更简单的数据模型。

验证清单

实施后不要只确认“能运行”,还应检查错误率、延迟、资源占用和异常路径。把验证命令、预期结果和回滚方式写进变更记录,后续维护会轻松很多。

小结

好的工程实践不是堆叠工具,而是用清晰边界、可观察结果和可逆变更降低长期成本。先解决最重要的问题,再逐步自动化。

可执行实践

用判别联合表达加载、成功和失败状态,用泛型约束容器而不是返回 any。API 边界通过运行时 schema 验证后再转换成领域类型。

验证与退出条件

开启 strict 并逐步减少断言。为非法状态写 never 穷尽检查,类型复杂到无法解释时优先简化数据模型而不是继续堆条件类型。

实践场景

以“为外部 API 响应建立可靠类型边界”为例。实施前先保存当前版本、配置和关键指标基线,写明成功条件与停止条件;涉及生产数据或外部写入时,先在隔离环境验证。

分步实施

unknown 数据先由运行时 schema 校验,再转换为领域类型;用判别联合表达状态,避免 any 和非空断言扩散。每完成一步都记录可观察结果,不把命令返回零等同于业务正确。

故障演练

构造缺字段、额外字段、旧版本和未知枚举值,确保错误在入口被分类而非在深层崩溃。演练应使用可清理的测试数据,并验证告警、日志和恢复路径,而不只是确认系统最终又能访问。

观测与回滚

观察校验失败和未知状态;兼容变更先扩读后改写,必要时恢复旧解码器。变更单中应写清负责人、观察窗口、阈值和回滚触发器,避免故障发生后再临时争论。

完成后把使用的版本、关键命令、观测窗口和结果写入变更记录。没有验证证据的“已完成”不能作为上线结论。

第二轮实践复核

可复用交付物: 运行时 schema、领域类型和边界失败样本。复用时先固定软件版本、依赖锁文件、测试数据规模和运行环境;文章中的命令包含占位符,必须替换成自己的非生产目标后再执行。

最低验证: npx tsc --noEmit && npm test

执行记录要保存命令、退出码、关键输出、观察窗口和回滚结果。若验证会产生写入,先使用隔离环境或 dry-run;任何失败都不能通过扩大权限、关闭校验或跳过测试来“解决”。

完整实战:让订单状态在编译期闭合

类型的价值不是把 JavaScript 改写成更多接口,而是让非法状态更难出现。订单查询可能返回加载中、成功、失败和空结果;如果用四个可选字段表达,调用方会遇到 loading=true 同时带 error 的矛盾组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Order = Readonly<{
id: string
totalCent: number
status: 'pending' | 'paid' | 'cancelled'
}>

type LoadState<T> =
| { kind: 'idle' }
| { kind: 'loading'; requestId: string }
| { kind: 'success'; data: readonly T[] }
| { kind: 'failure'; code: string; retryable: boolean }

function renderState(state: LoadState<Order>): string {
switch (state.kind) {
case 'idle': return '尚未查询'
case 'loading': return `加载中:${state.requestId}`
case 'success': return `共 ${state.data.length} 条`
case 'failure': return state.retryable ? '失败,可重试' : '失败,请检查输入'
default: return assertNever(state)
}
}

function assertNever(value: never): never {
throw new Error(`unhandled state: ${JSON.stringify(value)}`)
}

当以后新增 cancelled 分支,assertNever 会让漏改的消费方在编译时暴露。不要用 as LoadState<Order> 绕过构造错误;为每个分支提供显式工厂,或者让解析器返回判别联合。

外部输入仍需运行时校验

类型声明在编译后不会保护网络响应、环境变量和本地存储。下面的 unknown 迫使边界代码先缩窄类型:

1
2
3
4
5
6
7
function isOrder(value: unknown): value is Order {
if (typeof value !== 'object' || value === null) return false
const item = value as Record<string, unknown>
return typeof item.id === 'string'
&& Number.isInteger(item.totalCent)
&& ['pending', 'paid', 'cancelled'].includes(String(item.status))
}

大型项目通常用 schema 库生成解析器或从 OpenAPI 生成类型,但仍要测试“服务端新增字段、缺字段、错误枚举、超大数字和 null”这些边界。生成代码要固定工具版本,并在 CI 检查契约漂移。

味道 隐患 改进方向
大量 any 错误跨层扩散 边界用 unknown,验证后缩窄
到处 as 编译器被迫相信调用方 工厂函数、类型守卫或 schema
巨型全局类型 任一变化影响所有模块 按领域拥有类型,在边界映射
可选字段表达状态 合法组合不清晰 判别联合
enum 与后端漂移 新值导致错误分支 运行时验证和未知值策略

验收时运行严格类型检查、单元测试和契约样本;再搜索本次边界中新增的 any、非空断言和双重断言。回滚类型定义不能修复已经写入的数据,因此涉及序列化格式时要先保持新旧读写兼容,再分阶段删除旧分支。

一手资料

资料与版本边界复核日期:2026-08-22。工具行为可能演进,实施前以锁定版本对应的官方文档为准。

评论
分享

评论