Two Tiny Utils for the Result Pattern
Two Tiny Utils for the Result Pattern
两个用于 Result 模式的小工具
Photo by Sean Sinclair on Unsplash Likely because I developed my fair share of projects in Rust over the last few years, I’ve become quite a fan of using a Result pattern over multiplying try and catch blocks in TypeScript. Not every blog post needs to be about shipping rockets to outer space, so I thought I’d quickly share two small helpers I now bring with me everywhere. 图片由 Sean Sinclair 在 Unsplash 上提供。可能是因为过去几年我用 Rust 开发了不少项目,我非常喜欢在 TypeScript 中使用 Result 模式,而不是堆砌大量的 try-catch 代码块。并非每篇博客文章都需要讨论如何将火箭送入太空,所以我打算分享两个我现在随身携带的小工具。
Background: Rust Results
背景:Rust 的 Result
Unlike JavaScript, there is no such thing as throwing and catching exceptions in Rust. Instead, functions that can fail return a value that carries either the result or the error. Those values are typed as Result<T, E>, an enum which is part of the language and which contains two variants: Ok(T) and Err(E). The former being, I guess you got it, the success, and the latter the error.
与 JavaScript 不同,Rust 中没有抛出和捕获异常的概念。相反,可能失败的函数会返回一个值,该值要么包含结果,要么包含错误。这些值被类型化为 Result<T, E>,这是一个语言内置的枚举,包含两个变体:Ok(T) 和 Err(E)。前者代表成功,后者代表错误,我想你已经猜到了。
type Hello = String;
type UnknownVisitorError = String;
fn hi(name: &str) -> Result<Hello, UnknownVisitorError> {
if name != "david" {
return Err(String::from("UnknownVisitor"));
}
Ok(format!("Hello, {name}!"))
}
The caller has to deal with both variants - i.e. one cannot just discard or miss an error, everything is typed and expected. Classicaly, this can be achieved with a match:
调用者必须处理这两种变体——也就是说,你不能随意丢弃或忽略错误,一切都是类型化且可预期的。通常,这可以通过 match 来实现:
fn main() {
match hi("david") {
Ok(hello) => println!("{hello}"),
Err(err) => println!("Something went wrong: {err}"),
}
}
To bubble up errors and, I guess, keep the language a bit less verbose, there’s a ? operator you can use to propagate the error up the call stack. It’s a bit like try/catch was omitted.
为了向上抛出错误,并让代码看起来不那么冗长,你可以使用 ? 操作符将错误传播到调用栈上。这有点像省略了 try/catch。
fn greet(name: &str) -> Result<(), UnknownVisitorError> {
let hello = hi(name)?;
println!("{hello}");
Ok(())
}
How cute 🤗 With this in mind, let’s move to TypeScript. 真可爱 🤗 带着这个概念,让我们转向 TypeScript。
Result Pattern in TypeScript
TypeScript 中的 Result 模式
The TypeScript version I use isn’t quite that strict. I type the error as unknown rather than using a generic. One might argue that I lose a static guarantee about what went wrong, to which I’d say that I’d end up widening it to unknown anyway, because anything, anywhere, can always break in JavaScript. #trustnoone
我使用的 TypeScript 版本并没有那么严格。我将错误类型定义为 unknown 而不是使用泛型。有人可能会说我失去了关于“出了什么错”的静态保证,对此我会说,反正最终我还是会把它扩大为 unknown,因为在 JavaScript 中,任何地方的任何东西都可能随时崩溃。#谁都别信
type Result<T> = { status: "success"; result: T } | { status: "error"; err: unknown };
Since we don’t carry a specific error type, this is actually closer to how anyhow works in Rust. Instead of a precise Result<T, E>, anyhow::Resultanyhow 库的工作方式。anyhow::Result<T> 实际上是 Result<T, anyhow::Error>,它是一种以精度换取便利的通用错误类型,而不是精确的 Result<T, E>。
use anyhow::{anyhow, Result};
type Hello = String;
fn hi(name: &str) -> Result<Hello> {
if name != "david" {
return Err(anyhow!("UnknownVisitor"));
}
Ok(format!("Hello, {name}!"))
}
Application
应用
At this point you might wonder why and how I use this pattern. After all, handling errors just works, right? I guess I do it because, as mentioned above, I always assume something might break somewhere. Whether it’s a bug in my own code, in a library I depend on, a crashing API, a corrupted DB, or just as usual GitHub Actions the network having hiccups, something will eventually fail, and not handling that gracefully gives users a bad impression. 此时你可能会好奇我为什么要使用这种模式以及如何使用。毕竟,处理错误本来就很简单,对吧?我想我这样做是因为,正如上面提到的,我总是假设某处可能会出错。无论是我的代码中的 Bug、依赖库的问题、崩溃的 API、损坏的数据库,还是像往常一样 GitHub Actions 网络出现波动,最终总会有东西失败,而如果不优雅地处理这些失败,会给用户留下糟糕的印象。
Whether I’m building a frontend app, a backend server, or even a CLI, I tend to approach the solution in basically three layers: a presentation layer, some logic in between (services), and a core (communication with an API, the file system, etc.). 无论是在构建前端应用、后端服务器还是 CLI 工具,我倾向于将解决方案分为三个基本层:表现层、中间逻辑层(服务层)和核心层(与 API、文件系统等的通信)。
┌───────────────┐
│ Presentation │ handles Result, never try/catch
└───────┬───────┘
│ Result<T>
┌───────────────┐
│ Services │ catches everything, returns Result
└───────┬───────┘
│ throws
┌───────────────┐
│ Core │ API, DB, filesystem, throws freely
└───────────────┘
In this model, the presentation layer should only ever have to handle the result of something, never an exception. So I try to forbid myself from using try/catch there as much as possible, and push that responsibility down to the service layer instead, which is expected to catch everything early. That’s where the pattern comes in: services always return a Result, and the presentation layer never needs a try/catch, can never ignore an error. As for the core, I mostly let errors bubble up. Partly to avoid too much boilerplate, but mostly because the service layer sitting right above it is already the one place responsible for catching everything, so there’s no point duplicating that effort further down.
在这个模型中,表现层应该只处理结果,而不处理异常。因此,我尽量禁止自己在表现层使用 try/catch,并将该责任下放到服务层,服务层负责尽早捕获所有异常。这就是该模式的用武之地:服务层总是返回一个 Result,而表现层永远不需要 try/catch,也永远不会忽略错误。至于核心层,我通常让错误向上冒泡。部分原因是为了避免过多的样板代码,但主要是因为位于其上方的服务层已经负责捕获所有异常,因此在底层重复这项工作毫无意义。
Try/Catch
Try/Catch
If the service layer is the one responsible for catching everything, you can imagine it quickly gets overwhelmed with try/catch blocks everywhere. 如果服务层负责捕获所有异常,你可以想象它很快就会被到处都是的 try/catch 代码块淹没。
export const listPizzas = async (): Promise<Result<Pizza[]>> => {
try {
const pizzas = await api.list();
return { status: "success", result: pizzas };
} catch (err: unknown) {
return { status: "error", err };
}
};
export const getPizza = async (id: string): Promise<Result<Pizza>> => {
try {
const pizza = await api.get(id);
return { status: "success", result: pizza };
} catch (err: unknown) {
return { status: "error", err };
}
};
// Etc.
I guess I don’t really need to argue that this is quite redundant. That’s why one of the first utilities I created, and which I now use actively, is a helper that executes a function, in this case a promise, and wraps both the success and the error into a Result.
我想我不需要多说,这确实非常冗余。这就是为什么我创建的第一个工具(现在也在积极使用)是一个辅助函数,它执行一个函数(在本例中是一个 Promise),并将成功和错误都包装成一个 Result。
export const tryCatch = async <T>(fn: () => Promise<T>): Promise<Result<T>> => {
try {
const result = await fn();
return { status: "success", result };
} catch (err: unknown) {
return { status: "error", err };
}
};
That way I can just use tryCatch everywhere, way cleaner:
这样我就可以到处使用 tryCatch 了,代码干净多了:
export const listPizzas = (): Promise<Result<Pizza[]>> => tryCatch(api.list);
export const getPizza = (id: string): Promise<Result<Pizza>> =>
tryCatch(async () => {
return await api.get(id);
});
Definitely more compact. 确实更简洁了。
Safe Exec
安全执行
tryCatch covers the case where a function throws, good. But what about a service where I already implemented the Result pattern, one that’s supposed to always return a Result, but where I’m not entirely sure, I didn’t miss something, or that something unpredictable can’t still happen? Whatever the reason, sometimes I want a guarantee that a function I call does not throw. So, I created the following helper:
tryCatch 涵盖了函数抛出异常的情况,这很好。但如果某个服务我已经实现了 Result 模式,它本应总是返回一个 Result,但我又不确定是否遗漏了什么,或者担心发生不可预知的情况呢?无论出于什么原因,有时我希望确保我调用的函数不会抛出异常。因此,我创建了以下辅助函数:
export const