根据 Sentry 2025 年度错误报告,TypeScript 项目中仍有 38% 的生产错误属于「类型相关的运行时异常」——Cannot read property of undefined、类型判断遗漏分支、API 响应结构不匹配等。这些问题的根源不是 TypeScript 类型系统不够强大,而是开发者没有充分利用它的核心武器:代数数据类型(Algebraic Data Types,ADT)。本文不会重复「什么是泛型、什么是联合类型」的基础内容,而是用真实生产场景教你如何用 ADT 模式把运行时错误转化为编译期错误。
🔐 一、ADT 核心概念:Sum Type 与 Product Type
1.1 为什么你需要关心代数数据类型
代数数据类型源自函数式编程语言(Haskell、OCaml、Rust),但在 TypeScript 中同样适用。它的核心思想是:用类型的组合来精确描述数据的所有可能状态,让编译器帮你检查是否遗漏了任何情况。
ADT 分为两种基本类型:
- Product Type(积类型):多个字段的组合,所有字段同时存在。TypeScript 的
interface和tuple就是积类型。 - Sum Type(和类型):多种可能中取其一,同一时刻只能是其中一种。TypeScript 的可辨识联合类型(Discriminated Union)就是和类型。
Product Type: A × B = 同时拥有 A 和 B
Sum Type: A + B = 要么是 A,要么是 B
📌 记住: 大多数 TypeScript 开发者每天都在使用 Product Type(interface/object),却很少主动使用 Sum Type。这导致大量本可以在编译期捕获的 Bug 逃逸到生产环境。
1.2 可辨识联合类型:TypeScript 的 Sum Type
可辨识联合类型(Discriminated Union)是 TypeScript 实现 Sum Type 的标准方式。它通过一个公共的字面量类型字段(discriminant)来区分不同的变体:
// ❌ 错误写法:用宽松类型描述 API 响应
interface ApiResponse {
status: string
data?: unknown
error?: string
}
// ✅ 正确写法:用可辨识联合类型精确描述所有可能状态
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string; code: number }
| { status: 'loading' }
// 使用时,TypeScript 知道每种状态下的可用字段
function renderResponse(response: ApiResponse<User>) {
switch (response.status) {
case 'success':
console.log(response.data.name) // ✅ data 一定存在
break
case 'error':
console.log(response.error) // ✅ error 一定存在
// console.log(response.data) // ❌ 编译错误:data 不存在
break
case 'loading':
console.log('加载中...')
break
}
}
⚡ 关键结论: 可辨识联合类型的核心价值不是「让代码更优雅」,而是让编译器强制你处理每一种可能的状态。当你新增一个状态变体时,TypeScript 会在所有未处理该变体的地方报错——这就是穷尽性检查(Exhaustiveness Checking)。
1.3 从 boolean 到 Sum Type:重新思考状态建模
大多数开发者习惯用 boolean 标志来表示状态,这是运行时 Bug 的温床:
// ❌ 错误写法:boolean 状态组合爆炸
interface FormState {
isLoading: boolean
isError: boolean
data: FormData | null
}
// 问题:isLoading=true 且 isError=true 同时为真,这是合法状态吗?
// 问题:data 有值但 isLoading=true,这意味着什么?
// 这种状态建模方式会导致大量无法到达的「死状态」
// ✅ 正确写法:Sum Type 精确建模
type FormState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T; timestamp: number }
| { kind: 'error'; error: Error; retryCount: number }
// 现在不可能出现「既 loading 又 error」的无效状态
// 编译器会强制你处理每种状态
这个模式直接对应了经典的有限状态机(FSM),是构建复杂 UI 状态的基石。
🚀 二、生产级 ADT 模式实战
2.1 Result 类型:用类型系统替代 try-catch
在生产代码中,异常(Exception)是最不可控的错误传播方式——调用者不知道函数可能抛出什么,也无法在类型层面强制处理错误。Rust 的 Result<T, E> 类型给出了更好的答案:
// Result.ts —— 生产级 Result 类型实现
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
// 辅助构造函数
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value }
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error }
}
// 用法示例:替代 try-catch 的安全函数
function parseJSON<T>(raw: string): Result<T, SyntaxError> {
try {
return Ok(JSON.parse(raw) as T)
} catch (e) {
return Err(e instanceof SyntaxError ? e : new SyntaxError(String(e)))
}
}
// 调用者必须处理错误——编译器强制
const result = parseJSON<{ name: string }>('{"name":"Alice"}')
if (result.ok) {
console.log(result.value.name) // ✅ 类型安全
} else {
console.error(result.error.message) // ✅ 错误也类型安全
}
// ❌ 编译错误:不能直接访问 value,必须先检查 ok
// console.log(result.value.name)
💡 提示: Result 类型最大的优势不是替代所有 try-catch,而是用于可预期的错误(如 JSON 解析失败、网络请求超时、用户输入非法)。对于真正不可预期的错误(如内存溢出),仍然应该使用异常。
对比传统写法和 Result 写法的差异:
| 维度 | try-catch | Result 类型 |
|---|---|---|
| 错误信息 | 任意类型,无约束 | 类型系统约束,精确到函数签名 |
| 强制处理 | ❌ 可以忽略 catch | ✅ 编译器强制检查 ok/error |
| 错误链 | 堆栈信息,难以编程处理 | 可组合、可 map、可链式传播 |
| 性能 | V8 对 try-catch 有优化,但仍有开销 | 零运行时开销 |
| 适用场景 | 不可预期的系统级错误 | 可预期的业务级错误 |
2.2 用 ts-pattern 实现真正的模式匹配
TypeScript 的 switch 语句虽然可以处理可辨识联合,但嵌套结构需要层层嵌套,代码可读性差。ts-pattern 库为 TypeScript 带来了函数式语言风格的模式匹配:
npm install ts-pattern
import { match, P } from 'ts-pattern'
// 定义一个复杂的状态类型
type AppEvent =
| { type: 'login'; user: { id: string; role: 'admin' | 'user' } }
| { type: 'logout' }
| { type: 'error'; code: number; message: string }
| { type: 'notification'; level: 'info' | 'warn' | 'error'; text: string }
function handleEvent(event: AppEvent): string {
return match(event)
.with({ type: 'login', user: { role: 'admin' } }, (e) =>
`欢迎管理员 ${e.user.id} 登录`
)
.with({ type: 'login' }, (e) =>
`欢迎用户 ${e.user.id} 登录`
)
.with({ type: 'error', code: P.select() }, (code) =>
`错误 ${code},请联系管理员`
)
.with({ type: 'notification', level: P.union('warn', 'error') }, (e) =>
`⚠️ ${e.level.toUpperCase()}: ${e.text}`
)
.with({ type: P.any }, () => '已处理')
.exhaustive() // 强制穷尽检查——如果遗漏变体,编译报错
}
// 使用示例
console.log(handleEvent({ type: 'login', user: { id: 'alice', role: 'admin' } }))
// 输出: "欢迎管理员 alice 登录"
ts-pattern 的核心价值在于 .exhaustive() 方法——它让你在编译期就确信所有可能的状态都已处理,不会有遗漏。
2.3 嵌套 ADT 与递归类型
当数据结构本身是递归的(如 JSON 值、AST 树、评论系统),ADT 同样可以精确描述:
// JSON 值的精确类型定义——用递归 Sum Type 描述所有可能
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[] // 递归:数组元素也是 JsonValue
| { [key: string]: JsonValue } // 递归:对象值也是 JsonValue
// 用 ts-pattern 处理递归结构
import { match, P } from 'ts-pattern'
function summarize(value: JsonValue): string {
return match(value)
.with(P.string, (s) => `字符串: "${s.slice(0, 50)}${s.length > 50 ? '...' : ''}"`)
.with(P.number, (n) => `数字: ${n}`)
.with(P.boolean, (b) => `布尔: ${b}`)
.with(null, () => 'null')
.with(P.array, (arr) => `数组(${arr.length}项)`)
.with(P.record, (obj) => {
const keys = Object.keys(obj)
return `对象(${keys.length}个字段: ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''})`
})
.exhaustive()
}
// 使用示例
const data: JsonValue = {
name: "Alice",
age: 30,
hobbies: ["reading", "coding"],
address: { city: "Beijing", zip: null }
}
console.log(summarize(data))
// 输出: "对象(4个字段: name, age, hobbies, address...)"
console.log(summarize(data.hobbies))
// 输出: "数组(2项)"
💡 三、ADT 在真实项目中的架构模式
3.1 类型安全的事件系统
用 ADT 构建前端事件系统,确保每个事件处理函数都类型安全:
// 事件定义——每个事件都是一个 Sum Type 变体
type DomainEvent =
| { kind: 'CartItemAdded'; productId: string; quantity: number; price: number }
| { kind: 'CartItemRemoved'; productId: string }
| { kind: 'CartCleared' }
| { kind: 'DiscountApplied'; code: string; percentage: number }
| { kind: 'CheckoutStarted'; address: string }
// 事件处理器类型——每个处理器对应一个事件变体
type EventHandler<E extends DomainEvent> = (event: E) => void
// 用 Extract 提取特定事件类型
type CartItemAddedEvent = Extract<DomainEvent, { kind: 'CartItemAdded' }>
const handleItemAdded: EventHandler<CartItemAddedEvent> = (event) => {
console.log(`添加商品 ${event.productId} x${event.quantity}`)
console.log(`小计: ¥${event.price * event.quantity}`)
// event.code // ❌ 编译错误:CartItemAdded 没有 code 字段
}
// 事件总线——类型安全的事件分发
class EventBus {
private handlers = new Map<string, Function[]>()
on<K extends DomainEvent['kind']>(
kind: K,
handler: EventHandler<Extract<DomainEvent, { kind: K }>>
): void {
const existing = this.handlers.get(kind) ?? []
this.handlers.set(kind, [...existing, handler])
}
emit(event: DomainEvent): void {
const handlers = this.handlers.get(event.kind) ?? []
handlers.forEach(h => h(event))
}
}
// 使用
const bus = new EventBus()
bus.on('CartItemAdded', (event) => {
console.log(event.productId) // ✅ 类型自动推断
console.log(event.quantity) // ✅ 所有字段类型安全
})
3.2 状态机驱动的 UI 组件
用 ADT 建模复杂 UI 组件的状态转换,彻底消灭非法状态:
// 文件上传组件的完整状态机
type UploadState =
| { phase: 'idle' }
| { phase: 'selecting'; accept: string[] }
| { phase: 'validating'; file: File; progress: 0 }
| { phase: 'uploading'; file: File; progress: number; startTime: number }
| { phase: 'success'; file: File; url: string; duration: number }
| { phase: 'error'; file: File; error: string; canRetry: boolean }
// 状态转换函数——每个函数返回新的合法状态
function startValidation(file: File, accept: string[]): UploadState {
// 校验文件类型
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!accept.includes(`.${ext}`)) {
return { phase: 'error', file, error: `不支持的文件类型: .${ext}`, canRetry: false }
}
// 校验文件大小
if (file.size > 10 * 1024 * 1024) {
return { phase: 'error', file, error: '文件大小不能超过 10MB', canRetry: false }
}
return { phase: 'validating', file, progress: 0 }
}
function startUpload(state: UploadState): UploadState {
if (state.phase !== 'validating') return state // 非法转换,保持原状态
return {
phase: 'uploading',
file: state.file,
progress: 0,
startTime: Date.now()
}
}
function updateProgress(state: UploadState, progress: number): UploadState {
if (state.phase !== 'uploading') return state
return { ...state, progress: Math.min(100, Math.max(0, progress)) }
}
function completeUpload(state: UploadState, url: string): UploadState {
if (state.phase !== 'uploading') return state
return {
phase: 'success',
file: state.file,
url,
duration: Date.now() - state.startTime
}
}
// 渲染函数——编译器强制处理每种状态
import { match } from 'ts-pattern'
function renderUpload(state: UploadState): string {
return match(state)
.with({ phase: 'idle' }, () => '点击选择文件')
.with({ phase: 'validating' }, () => '正在验证文件...')
.with({ phase: 'uploading' }, (s) => `上传中 ${s.progress}%`)
.with({ phase: 'success' }, (s) =>
`✅ 上传成功 (${((s.duration) / 1000).toFixed(1)}s) → ${s.url}`
)
.with({ phase: 'error' }, (s) =>
`❌ ${s.error}${s.canRetry ? ' [可重试]' : ''}`
)
.exhaustive()
}
⚠️ 警告: 状态机的关键约束是转换函数必须在入口处检查当前状态。如果你直接修改 state 对象的
phase字段而跳过检查,就会破坏类型安全。永远通过返回新状态对象来实现转换,不要 mutation。
3.3 类型安全的 API 客户端
用 ADT 统一处理 API 请求的所有可能结果:
// 定义 API 错误的精确类型
type ApiError =
| { kind: 'NetworkError'; message: string }
| { kind: 'TimeoutError'; elapsed: number }
| { kind: 'ServerError'; status: number; body: string }
| { kind: 'ClientError'; status: number; message: string }
| { kind: 'RateLimited'; retryAfter: number }
// 封装 fetch 为类型安全的 Result 模式
async function safeFetch<T>(
url: string,
options?: RequestInit
): Promise<Result<T, ApiError>> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
clearTimeout(timeout)
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') ?? '60')
return Err({ kind: 'RateLimited', retryAfter })
}
if (response.status >= 500) {
const body = await response.text()
return Err({ kind: 'ServerError', status: response.status, body })
}
if (response.status >= 400) {
const data = await response.json().catch(() => ({ message: 'Unknown error' }))
return Err({ kind: 'ClientError', status: response.status, message: data.message })
}
const data = await response.json() as T
return Ok(data)
} catch (e) {
clearTimeout(timeout)
if (e instanceof DOMException && e.name === 'AbortError') {
return Err({ kind: 'TimeoutError', elapsed: 30_000 })
}
return Err({ kind: 'NetworkError', message: String(e) })
}
}
// 使用:调用者必须处理所有错误类型
async function fetchUser(id: string) {
const result = await safeFetch<{ name: string; email: string }>(
`/api/users/${id}`
)
if (result.ok) {
return result.value // ✅ 类型: { name: string; email: string }
}
// 错误处理——按类型分别处理
switch (result.error.kind) {
case 'RateLimited':
console.log(`被限流,${result.error.retryAfter}秒后重试`)
break
case 'TimeoutError':
console.log(`请求超时 (${result.error.elapsed}ms)`)
break
case 'NetworkError':
console.log(`网络错误: ${result.error.message}`)
break
case 'ServerError':
console.log(`服务器错误 ${result.error.status}`)
break
case 'ClientError':
console.log(`客户端错误: ${result.error.message}`)
break
}
}
| 统一 catch 所有错误 | ADT Result 模式 |
|---|---|
| 无法在类型层面区分错误 | 每种错误类型精确描述 |
| 调用者可以忽略错误 | 编译器强制处理 |
| 错误信息无结构 | 错误是数据,可序列化、可聚合 |
| 难以针对不同错误做不同处理 | switch/match 精确分发 |
✅ 四、ADT 最佳实践与避坑指南
4.1 命名约定与组织方式
// ✅ 推荐:用 kind 或 _tag 作为判别字段
type Result<T, E> =
| { _tag: 'Ok'; value: T }
| { _tag: 'Err'; error: E }
// ✅ 推荐:每个变体用 PascalCase 命名
type UserAction =
| { kind: 'ClickButton'; buttonId: string }
| { kind: 'SubmitForm'; formData: Record<string, unknown> }
| { kind: 'NavigateTo'; path: string }
// ❌ 避免:用数字或模糊名称作为判别
type Status = { type: 0 } | { type: 1 } | { type: 2 } // 无法理解含义
4.2 常见反模式
// ❌ 反模式 1:在 ADT 中使用可选字段
type BadState =
| { kind: 'loaded'; data?: unknown } // data 到底在不在?
| { kind: 'error'; error?: string } // error 可能没有?
// ✅ 正确:每个变体的字段应该是确定的
type GoodState =
| { kind: 'loaded'; data: unknown }
| { kind: 'error'; error: string }
// ❌ 反模式 2:用 'other' 或 'default' 兜底
type BadEvent =
| { type: 'click' }
| { type: 'scroll' }
| { type: 'other'; payload: unknown } // 放弃了类型安全
// ✅ 正确:明确列出所有变体
type GoodEvent =
| { type: 'click'; x: number; y: number }
| { type: 'scroll'; deltaY: number }
| { type: 'keydown'; key: string; modifiers: string[] }
4.3 ADT 与外部数据的边界
从外部(API、localStorage、URL 参数)接收的数据不能直接信任为 ADT 类型。需要在边界处做运行时验证:
import { z } from 'zod'
// 用 Zod 定义运行时校验器——与 ADT 类型保持同步
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.object({ name: z.string() }) }),
z.object({ status: z.literal('error'), error: z.string(), code: z.number() }),
z.object({ status: z.literal('loading') }),
])
type ApiResponse = z.infer<typeof ApiResponseSchema>
// 在系统边界处校验
function parseResponse(raw: unknown): Result<ApiResponse, string> {
const result = ApiResponseSchema.safeParse(raw)
if (result.success) {
return Ok(result.data)
}
return Err(`Invalid API response: ${result.error.message}`)
}
⚠️ 警告: ADT 类型只在编译期生效。从外部接收的数据(API 响应、localStorage、JSON 文件)必须经过运行时校验才能安全使用。推荐在系统边界使用 Zod、Valibot 或 Arktype 做校验,内部代码全部走 ADT 类型推断。
📊 总结
代数数据类型不是学术概念,而是 TypeScript 中最实用但最被低估的类型系统特性。核心要点回顾:
- ✅ 可辨识联合类型是 TypeScript 的 Sum Type,用
kind/type/_tag字段区分变体 - ✅ 穷尽性检查让编译器强制你处理每种可能的状态,遗漏变体时直接报错
- ✅ Result 类型替代 try-catch 处理可预期错误,让错误信息成为类型签名的一部分
- ✅ 状态机模式用 ADT 建模 UI 状态转换,消灭「既 loading 又 error」的非法状态
- ✅ 外部数据边界必须用 Zod/Valibot 做运行时校验,内部代码走纯类型推断
⚡ 关键结论: 每当你发现自己在用
if (data && typeof data === 'object' && 'status' in data)这样的运行时检查时,就应该反思——这能不能用 ADT 类型在编译期解决?答案在大多数情况下是肯定的。
🔧 相关工具推荐
- 🔧 ts-pattern — TypeScript 原生模式匹配库,支持穷尽性检查
- 🔧 Zod — 运行时类型校验,可从 ADT 推断类型
- 🔧 neverthrow — 生产级 Result 类型实现,支持异步和组合
- 🔧 Effect — 全功能函数式 TypeScript 框架,内置 Result、Option 等 ADT
- 🔧 ArkType — 高性能运行时类型校验,语法接近 TypeScript 原生类型
- 🔧 jsjson.com JSON 格式化工具 — 快速验证 JSON 数据结构是否符合预期