TypeScript 类型安全的异步流程:Promise、并发与错误建模

HTMLPAGE 团队
17 分钟阅读

异步编程中的类型安全不只是给 Promise 加泛型参数。本文讨论 Promise 类型的展开规则、并发控制的类型签名设计、Result 模式的类型实现,以及如何在异步流程中精确建模成功和失败的路径。

#TypeScript #异步 #Promise #错误处理 #Result 模式 #类型安全

大多数 TypeScript 项目对异步类型的处理停留在 Promise<T> 的层面——知道 async 函数返回 Promise<T>,知道 await 会展开它。但实际项目里遇到的异步类型问题远比这个复杂:并发控制函数的类型签名怎么写?错误怎么建模?多个不同来源的异步结果怎么合并?

Promise 的类型展开规则

Promise<T> 是 TypeScript 中少数几个有"编译器感知"的泛型类型。await 表达式会自动展开一层 Promise:

async function example() {
  const p: Promise<Promise<string>> = Promise.resolve(Promise.resolve('hello'))
  const r = await p
  // r 的类型是 Promise<string>,而不是 string
  // await 只展开一层
}

await 只展开最外层 Promise。如果你需要递归展开,需要自己做类型体操:

type AwaitedDeep<T> = T extends Promise<infer U> ? AwaitedDeep<U> : T

type Deep = AwaitedDeep<Promise<Promise<Promise<string>>>>
// string

TypeScript 4.5 内置了 Awaited<T> 类型,但它只展开到 Promise 不再嵌套为止——和 await 的行为一致,不是递归展开所有包装器。

并发控制的类型签名

Promise.all 的类型处理

Promise.all 接受一个可迭代对象。TypeScript 对元组的处理比较精确:

const results = await Promise.all([
  fetchUser(),       // Promise<UserResponse>
  fetchPosts(),      // Promise<Post[]>
  fetchComments(),   // Promise<Comment[]>
])
// results: [UserResponse, Post[], Comment[]]

但如果传入的是数组而不是元组:

const promises: Promise<unknown>[] = [fetchUser(), fetchPosts()]
const results = await Promise.all(promises)
// results: unknown[] —— 信息丢失了

这是因为 TypeScript 无法确定动态数组中每个元素的类型。解决方法是显式标注元组类型:

const promises = [fetchUser(), fetchPosts()] as const
// 或者用 satisfies
const promises = [fetchUser(), fetchPosts()] satisfies [Promise<UserResponse>, Promise<Post[]>]

封装并发控制函数

自定义并发控制函数的类型签名需要保留每个 Promise 的类型:

async function parallelAll<T extends readonly Promise<any>[]>(
  promises: T
): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>

async function parallelAll<T extends readonly Promise<any>[]>(promises: T) {
  return Promise.all(promises) as Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>
}

// 使用示例
const [user, posts] = await parallelAll([
  fetchUser(),
  fetchPosts(),
] as const)
// user: UserResponse, posts: Post[]

这里的 as const 是必须的——否则 ['a', 'b'] 会被推断为 string[] 而不是元组。

Result 模式的类型实现

JavaScript 的错误处理依赖 try/catch,但 catch 里的错误类型是 unknown。这不是 TS 的限制,而是 JavaScript 的设计——任何值都可以 throw。Result 模式把成功和失败编码到返回值里,避免隐式的异常路径。

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E }

function wrap<T>(fn: () => T): Result<T> {
  try {
    return { ok: true, value: fn() }
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) }
  }
}

async function wrapAsync<T>(promise: Promise<T>): Promise<Result<T>> {
  try {
    const value = await promise
    return { ok: true, value }
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) }
  }
}

使用 Result 模式后,调用方必须显式检查 ok 字段:

const result = await wrapAsync(fetchUser())
if (result.ok) {
  // result.value: UserResponse
  console.log(result.value.name)
} else {
  // result.error: Error
  console.error(result.error.message)
}

可辨识联合在这里起作用——检查 result.ok 之后,success 和 error 分支被严格隔离。如果不检查,编译器会强制你处理两种情况。

带错误码的 Result

type ApiError = {
  code: 'NOT_FOUND' | 'UNAUTHORIZED' | 'RATE_LIMITED' | 'SERVER_ERROR'
  message: string
  retryAfter?: number
}

type ApiResult<T> = Result<T, ApiError>

function handleApiResult<T>(result: ApiResult<T>) {
  if (!result.ok) {
    switch (result.error.code) {
      case 'NOT_FOUND':
        return showNotFound()
      case 'UNAUTHORIZED':
        return redirectToLogin()
      case 'RATE_LIMITED':
        return scheduleRetry(result.error.retryAfter ?? 5000)
      case 'SERVER_ERROR':
        return showServerError()
    }
  }
  return renderData(result.value)
}

这里的 ApiError 不是简单字符串——每个错误码携带不同的额外信息。RATE_LIMITEDretryAfter,其他错误码可能没有。

多个异步操作的类型合并

当多个异步操作的结果类型需要合并时,经常用到 mapped types:

type AsyncMap<T extends Record<string, any>> = {
  [K in keyof T]: Promise<T[K]>
}

async function resolveAll<T extends Record<string, any>>(
  obj: AsyncMap<T>
): Promise<T> {
  const entries = Object.entries(obj)
  const resolved = await Promise.all(entries.map(([_, v]) => v))
  return Object.fromEntries(
    entries.map(([k], i) => [k, resolved[i]])
  ) as T
}

// 使用
const result = await resolveAll({
  user: fetchUser(),
  posts: fetchPosts(),
  meta: fetchMeta(),
})
// result: { user: UserResponse; posts: Post[]; meta: MetaData }

这个模式对比手动写多个 const x = await ... 的好处是:所有请求并发执行,且结果类型保持精确。

异步错误处理中的类型分级

不是所有错误都需要同样的处理策略。用联合类型可以建模不同级别的错误:

type AppError =
  | { level: 'info'; message: string }         // 可忽略
  | { level: 'warning'; message: string; retryFn: () => Promise<void> }
  | { level: 'critical'; message: string; fatal: true }

type AppResult<T> = Promise<Result<T, AppError>>

async function fetchData(): AppResult<Data> {
  try {
    const data = await api.get('/data')
    return { ok: true, value: data }
  } catch (e) {
    if (e instanceof RateLimitError) {
      return {
        ok: false,
        error: { level: 'warning', message: '限流,稍后重试', retryFn: () => fetchData() }
      }
    }
    return {
      ok: false,
      error: { level: 'critical', message: '服务不可用', fatal: true }
    }
  }
}

总结

场景类型手段注意点
单层 Promise 展开Awaited<T>await只展开一层
多层嵌套展开自定义递归 AwaitedDeep深度限制
并发结果保留类型元组 + mapped types配合 as const
错误建模可辨识联合 + Result 模式穷举检查 error 类型
异步 map 合并mapped types并发 + 类型保留

异步编程的类型安全核心不是 Promise 本身,而是如何把成功和失败编码到类型系统里让它不能忽略。Result 模式比 try/catch 更安全,因为编译器会强迫你检查——catch 里的错误类型永远是 unknown,而 Result 可以把错误建模成精确的联合类型。