原文链接: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的类型,会发现类型仅剩number。never去哪了?
重申,我不责怪开发者。如果您查看描述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类型允许true和false值。您可以通过创建联合类型来扩大集合,或通过交集缩小集合。
我在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类不同(它们本身就是独立话题),这里通过可辨识联合和穷尽性检查提供更好的错误状态。
具体方法:
- 定义带有错误消息且
kind属性设为"error"的ErrorT类型 - 定义带有值且
kind属性设为"success"的泛型Success<T>类型 - 将两者组合成
Result联合类型 - 定义创建这两种类型的
error和success函数
具体实现:
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+个实用技巧。