发布于 2026-01-05 12 阅读
0

TypeScript 中的类型安全错误处理

TypeScript 中的类型安全错误处理

原文发布于我的博客

我们都遇到过这种情况。我们编写一个函数,需要处理一些特殊情况,并使用throw关键字来处理这种情况:

type ResponseData = {
  statusCode: number
  responseBody?: ResponseBody
}

const makeHttpRequest = async (url: string): Promise<ResponseData> => {
  if (!isUrl(url)) {
    throw new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    )
  }

  // ...
  // other business logic here
  // ...

  return { ... } // ResponseData
}
Enter fullscreen mode Exit fullscreen mode

现在想象一下一个月后,你在做项目的时候,你或者你的同事忘记在代码块makeHttpRequest内换行try / catch

这里会发生两件事:

  • 编译器不再能够告诉你你的代码是否安全,不会出现运行时错误。换句话说,使用throw`typeof` 不是类型安全的,而且和 `typeof` 一样危险any。它们都削弱了使用 TypeScript 的初衷。

  • 因为编译器和类型定义都没有明确告知你这种操作makeHttpRequest可能会失败(即抛出异常),最终你会遇到运行时错误。这对每个人来说都是时间、金钱和精力的浪费。人们开始质疑,既然编译器连添加代码块这样基本的操作都帮不上忙try / catch,那他们为什么还要使用 TypeScript 呢?

所以问题是:

我们如何将故障性编码到类型系统中?

首先,我们必须承认,这种方法throw并非类型安全。为了让 TypeScript 编译器支持我们,我们必须采用不同的方法。

如果我们有一个表示计算可能失败结果的“type或”符号,那会怎么样?interface

我们的类型代表两种简单的结果:

  • 成功情况:将返回/包含“成功值”(例如,ResponseData在以下情况下makeHttpRequest
  • 失败情况:这将返回/包含有关失败原因的有用信息

我们给这个类型取一个直观的名字,比如…… Result。我们把成功变体叫做“Success” Ok,把失败变体叫做“Failure” Err

因此,如果我们要将这种类型形式化为代码,它看起来会像这样:

type Result<T, E>
  = Ok<T, E> // contains a success value of type T
  | Err<T, E> // contains a failure value of type E
Enter fullscreen mode Exit fullscreen mode

回到我们的makeHttpRequest函数,我们希望将失败的可能性编码到类型系统中。

因此,makeHttpRequest其签名如下:

makeHttpRequest(url: string): Promise<Result<ResponseData, Error>>
Enter fullscreen mode Exit fullscreen mode

函数定义大致如下:

// utility functions to build Ok and Err instances
const ok = <T, E>(value: T): Result<T, E> => new Ok(value)

const err = <T, E>(error: E): Result<T, E> => new Err(error)

const makeHttpRequest = async (url: string): Promise<Result<ResponseData, Error>> => {
  if (!isUrl(url)) {
    return err(new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    ))
  }

  // ...
  // other business logic here
  // ...

  return ok({ ... }) // Ok(ResponseData)
}
Enter fullscreen mode Exit fullscreen mode

当然,err(new Error('...'))这听起来有点繁琐。但以下是一些你应该知道的事情:

  • 函数的参数err必须是类型,否则函数内部的类型与函数的返回类型E之间会出现编译错误(类型不匹配)(其中类型表示为实例)。errmakeHttpRequestEError

    • 另外,我只是为了简单起见才选择了Error这种类型……也就是说,你可以选择任何你想要的类型!稍后会详细说明!EE
  • 用户makeHttpRequest可以放心使用此函数,无需担心它会随机抛出错误。再也不会出现运行时错误了🚀

  • 函数的作者makeHttpRequest也不必担心每次出现新的极端情况导致函数抛出错误时都要编写和更新文档。函数的所有行为都编码在返回类型中。相应地,返回类型现在也起到了文档的作用:“makeHttpRequest是一个异步函数,它可以成功返回ResponseData一个值,也可以失败返回一个值Error。”

“等等,我该如何获取被包裹在某个元素内的T值呢?”EResult<T, E>

问得好。让我来演示一下。我们将使用我编写的一个包,它的名字很贴切neverthrow

> npm install neverthrow

import { ok, err, Result } from 'neverthrow'

// we'll keep this simple
type ResponseBody = {}

interface ResponseData {
  statusCode: number
  responseBody?: ResponseBody
}

const makeHttpRequest = async (
  url: string
): Promise<Result<ResponseData, Error>> => {
  if (!isUrl(url)) {
    return err(new Error(
      'Invalid string passed into `makeHttpRequest`. Expected a valid URL.'
    ))
  }

  // ...
  // other business logic here
  // ...

  return ok({ ... }) // Ok(ResponseData)
}
Enter fullscreen mode Exit fullscreen mode

所以,我们目前的情况和上一段代码片段的情况一样,只不过这次我们使用了该neverthrow软件包。

如果你仔细阅读neverthrow 文档,你会发现 aResult有一个.map方法,可以接收Ta 内部的值Result并将其转换为你想要的任何内容。

举个例子:

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result.map(responseData => {
    console.log(responseData)
  })
}

run()
Enter fullscreen mode Exit fullscreen mode

但是等等,如果结果变量包含一个E值呢?换句话说,它是一个Err而不是一个Ok

嗯,文档里也neverthrow说明了如何处理这种情况……直接用就行了mapErr

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result.mapErr(errorInstance => {
    console.log(errorInstance)
  })
}

run()
Enter fullscreen mode Exit fullscreen mode

Results最棒的地方在于它们可以链式调用!以下是一个更实际的示例:

import { makeHttpRequest } from './http-api.ts'

const run = async () => {
  // unwrap the Promise
  // at this point
  // we have a Result<ResponseData, Error>
  const result = await makeHttpRequest('https://jsonplaceholder.typicode.com/todos/1')

  result
    .map(responseData => {
      // do something with the success value
    })
    .mapErr(errorInstance => {
      // do something with the failure value
    })
}

run()
Enter fullscreen mode Exit fullscreen mode

类型还有很多其他功能Result(请查看文档),但maping 是 API 中最重要的部分。

让你的类型更直观

如果你开始Result在返回类型中大量使用 `T`,你可能会注意到两件事:

  • 字母“s”的含义Result不太明确。

    • 例如:Result数据库查询的表达式可能类似于这样,Promise<Result<T, DbError>>Result网络调用的表达式可能类似于这样Promise<Result<T, NetworkError>>
  • 这些类型描述非常冗长繁琐。例如,上面我们遇到的就是一个Promise<Result<ResponseData, Error>>……这真是一种令人望而生畏的类型!

要解决这两个问题,您可以利用类型别名

举个例子。与其让函数返回一个类型Result为 a 的DbError泛型E对象,为什么不将该类型别名化为更直观的类型呢?

type DbResult<T> = Result<T, DbError>
Enter fullscreen mode Exit fullscreen mode

现在你的函数可以直接返回了Promise<DbResult<T>>。这样简洁多了!

此外,你的类型现在编码了含义。上面的类型表明正在发生一些可能会失败的异步操作,我知道它与数据库有关。真棒!

以下是我某个项目中的实际案例:

handler: (req: Request, res: SessionManager) => DecodeResult<Promise<RouteResult<T>>>
Enter fullscreen mode Exit fullscreen mode

所以handler这是一个执行以下几项操作的函数:

  • 它对传入的数据进行一些解码/反序列化处理。
  • 然后它执行一些异步操作,生成一个RouteResult包含某些数据类型的对象。T

我只需阅读类型定义就能确切地知道将会发生什么。更妙的是,我永远不会遇到运行时错误,因为我的代码不会抛出任何异常(而且我依赖的所有第三方库也都已封装为返回Results)。

概括

  • throw尽量避免使用。

    • 你的 API 用户无需这样做catch(编译器不会强制执行)。这意味着你最终会遇到运行时错误……这只是时间问题。
    • 使用throw它迫使你维护文档,而这些文档最终会过时。
    • 即使你设法维护了文档,很多人也不会费心完整阅读,因此不会意识到你的函数在某些情况下会抛出异常。
  • Result使用s将潜在的失败风险编码到你的类型中。

    • 这些类型具有自我记录功能,不会变得“过时”。
    • 用户可以使用友好的 API 安全地处理可能失败的值(使用 ` mapand` mapErr)。
  • 有一个经过全面测试和类型检查的 npm 包可以实现这个功能,叫做neverthrow……试试看吧!

文章来源:https://dev.to/_gdelgado/type-safe-error-handling-in-typescript-1p4n