TypeScript 代数数据类型实战:用类型系统消灭 90% 的运行时错误

深入解析 TypeScript 中的代数数据类型(ADT),涵盖可辨识联合类型、模式匹配、穷尽性检查、Result 类型等核心模式,附完整代码与真实生产场景案例,帮你用类型系统在编译期捕获 Bug。

前端开发 2026-06-09 18 分钟

根据 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 的 interfacetuple 就是积类型。
  • 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 数据结构是否符合预期

📚 相关文章