【译】TypeScript 中的`never`类型和错误处理

171 阅读8分钟

原文链接:oida.dev/typescript-…
原作者:oida.dev/about/

这篇文章指出了很多开发者对TypeScirpt中never类型的误解,并指出了正确用法以及如何处理errors。比如文中提到的穷尽性检查(exhaustiveness checks) 也是我一直在用的技巧,所以想让更多的人看到这篇文章。

本文最初使用Google直出翻译,奈何没眼看,比如link joice会直接翻译成“链接汁”。然后尝试使用通篇deepseek翻译+人工校对调整,效果还不错。

以下是译文。


我最近注意到一个现象:开发者们发现了never类型后,开始更频繁地使用它,特别是在尝试建模错误处理时。但往往他们并没有正确使用它,或者忽视了never的一些基本特性。这可能导致在生产环境中出现故障代码,因此我希望澄清疑问和误解,并向您展示never的真正用法。

我的第二本TypeScript著作The TypeScript Cookbook已在亚马逊上市!

never与错误处理

首先,不要责怪开发者的误解。官方文档推广了一个关于never和错误处理的孤立示例(单独看是正确的),但这并不是完整的真相。这个示例是:

// Function returning never must not have a reachable endpoint
function error(message: string): never {
  throw new Error(message);
}

这来自已弃用的旧文档。新文档做得更好,但这个示例仍然在许多地方出现,并被大量博客文章引用。

这是薛定谔式的示例:在你打开盒子并在比示例更复杂的场景中使用它之前,它既是正确的又是错误的。

让我们看看正确版本。示例说明返回never的函数必须没有可达终点。很好,那么如果我调用这个函数,创建的绑定将不可用对吗?

function error(message: string): never {
  throw new Error(message);
}

const a = error("What is happening?");
//    ^? const a: never

是的!a的类型是never,我无法对它进行任何操作。TypeScript为我们检查的是这个函数永远不会返回有效值。因此它正确地批准了never返回类型与抛出的错误匹配。

但你很少会在没有额外操作的情况下直接在单个函数中破坏代码。通常你要么返回正确值,要么抛出错误。

我看到人们这样做:

function divide(a: number, b: number): number | never {
  if (b == 0) {
    throw new Error("Division by Zero!");
  }
  return a / b;
}

const result = divide(2, 0);

if (typeof result === "number") {
  console.log("We have a value!");
} else {
  console.log("We have an error!");
}

您希望以这种方式建模函数:在"正常"情况下返回number类型值,并希望指示可能会返回错误。因此写成number | never

这个示例100%是虚假的、错误的,且完全不符合事实!如果您查看result的类型,会发现类型仅剩numbernever去哪了?

重申,我不责怪开发者。如果您查看描述never类型的原始示例,可能会得出这就是处理错误的方式的结论。

但必须点名批评某些博主,生产一堆「Medium 流水线水文」,内容漏洞百出连测试都懒得做,居然靠着 SEO 玄学冲上谷歌搜索榜一!虽然我很想贴出这些「标题党」的链接,但为了不给他们增加点击量 (译注:这里原文是:I won’t link the culprit to not give them any link juice,我翻译成不给他们增加点击量,还是算了——反正用对关键词一搜一个准。萌新们听劝:别学这套骚操作!不然你家 LLMs (译注:大型语言模型(Large Language Models),如 ChatGPT 等) 会被喂成人工智障,读者也得跟着掉坑里!

never发生了什么?

那么never类型去哪了?如果您理解never实际代表的含义及其在类型系统中的工作原理,就很容易理解。

TypeScript类型系统将类型表示为值的集合。类型检查器的目的是确保某个已知值属于某个值集合。如果您有个值为2的变量,它将属于number集合。boolean类型允许truefalse值。您可以通过创建联合类型来扩大集合,或通过交集缩小集合。

我在The TypeScript Cookbook中详细讨论了这一点,如果您想了解更多可以查阅。

never类型也代表一个值集合——空集合。没有值与never兼容。它表示永远不应该发生的情况,这被称为底部类型(bottom type)。

never消失的原因很简单:集合论。如果您创建number集合和空集合的联合,剩下的只有number。毕竟,在某个事物中添加空集,该事物仍然存在。

现实吞噬了never,您将无法指示这个函数可能返回错误。类型系统会直接忽略它。

请记住:不要使用never作为抛出Error的表示。

如何正确使用never处理错误

但这并不意味着never无用。在某些情况下,您可以用这个类型建模不可能的状态。

首先通过可辨识联合类型(discriminated unions)和穷尽性检查(exhaustiveness checks)。我在博客和书中都讨论过这个特性,以下是快速回顾。

考虑将模型表示为联合类型:

type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };

type Shape = Circle | Square | Rectangle;

注意我将kind属性设置为字面量类型。这就是可辨识联合类型。通常创建联合类型时,TypeScript会允许属于集合重叠区域的对象,这意味着像{ radius: 3, side: 4, width: 5 }这样的对象会被接受为Shape

但通过使用字面量类型,TypeScript可以区分不同类型,并只允许每个类型的正确属性。因为"circle" | "square" | "rectangle"之间没有重叠。

还要注意我们在这里使用字面量字符串作为类型。这不是值。"circle"是只接受单个值(名为"circle"的字面字符串)的类型。

通过这个可辨识联合类型,我们现在可以使用穷尽性检查来确保处理所有情况。

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ​** 2;
    case "square":
      return s.side ​** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      // tbd
  }
}

您甚至可以在编辑器中获得自动补全,TypeScript会告诉您需要处理哪些情况。

我们还没有处理default情况,但可以使用never来指示这种情况永远不应该发生。

function assertNever(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ​** 2;
    case "square":
      return s.side ​** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      return assertNever(s);
  }
}

这很有趣。我们有个永远不应该发生的default情况,因为类型不允许,并且我们使用never作为参数类型。这意味着我们传递了一个值,尽管never集合没有任何值。如果执行到这里,说明出现了严重错误!

当代码需要变更时,我们可以用这个让TypeScript帮助我们。让我们在不修改area函数的情况下为Shape添加新变体。

type Triangle = { kind: "triangle"; a: number; b: number; c: number };

type Shape = Circle | Square | Rectangle | Triangle;

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ​** 2;
    case "square":
      return s.side ​** 2;
    case "rectangle":
      return s.width * s.height;
    default:
      return assertNever(s);
    //                   ~
    // Argument of type 'Triangle' is not assignable
    // to parameter of type 'never'.
  }
}

看!TypeScript理解我们没有检查所有变体,代码会出现红色波浪线提示。是时候检查我们是否处理了所有情况了!

这就是never的妙用。它帮助您确保处理了所有值,如果没有,会通过红色波浪线告知。

错误类型

您现在了解了never的实际工作原理,但可能仍希望正确表达错误。

有个受函数式编程语言启发、并由Rust推广的方法:使用结果类型(result type)来表达函数可能失败。

这与使用Error类不同(它们本身就是独立话题),这里通过可辨识联合和穷尽性检查提供更好的错误状态。

具体方法:

  1. 定义带有错误消息且kind属性设为"error"ErrorT类型
  2. 定义带有值且kind属性设为"success"的泛型Success<T>类型
  3. 将两者组合成Result联合类型
  4. 定义创建这两种类型的errorsuccess函数

具体实现:

type ErrorT = { kind: "error"; error: string };
type Success<T> = { kind: "success"; value: T };

type Result<T> = ErrorT | Success<T>;

function error(msg: string): ErrorT {
  return { kind: "error", error: msg };
}

function success<T>(value: T): Success<T> {
  return { kind: "success", value };
}

让我们用这个Result类型重构之前的divide函数:

function divide(a: number, b: number): Result<number> {
  if (b === 0) {
    return error("Division by zero");
  }
  return success(a / b);
}

使用时需要检查kind属性并处理对应情况:

const result = divide(10, 0);

if (result.kind === "error") {
  // result is of type Error
  console.error(result.error);
} else {
  // result is of type Success<number>
  console.log(result.value);
}

关键是类型正确,且类型系统知晓所有可能状态。

您可以扩展这个模式。比如为可能抛出错误的函数创建safe包装器:

function safe<Args extends unknown[], R>(
  fn: (...args: Args) => R,
  ...args: Args
): Result<R> {
  try {
    return success(fn(...args));
  } catch (e: any) {
    return error("Error: " + e?.message ?? "unknown");
  }
}

function unsafeDivide(a: number, b: number): number {
  if (b == 0) {
    throw new Error("Division by Zero!");
  }
  return a / b;
}

const result = safe(unsafeDivide, 10, 0);

或者当您需要从Result中强制获取值时:

function fail<T>(fn: () => Result<T>): T {
  const result = fn();
  if (result.kind === "success") {
    return result.value;
  }
  throw new Error(result.error);
}

const a = fail(divide(10, 0));

虽然不完美,但您拥有清晰的状态、明确的类型,知晓集合可能包含的内容,并清楚何时确实没有可能的值。

结论

我最近看到一些用never表示抛出Error的代码,心想"文档肯定哪里弄错了"。深入探究后发现Medium上有人将其作为最佳实践推荐。最让我恼火的就是人们教错误的东西。因此写下本文以正视听。更多类似内容请访问我的博客,但更推荐The TypeScript Cookbook,其中包含100+个实用技巧。