译:在 TypeScript 中像 Go 和 Rust 那样处理错误?没有 Try/Catch

那么,让我们先从我的一些背景开始。我是一名十年左右经验的软件开发者。最初使用 PHP,后来转到了 JavaScript。

我开始在 5 年前使用 TypeScript,从那时起,我便再也没有回到过 JavaScript。在我使用 TS 的那一刻,我认为它是世界上最好的编程语言。每个人都喜欢它;每个人都会用它……它就是最好的,不是吗?是这样吗?真的是这样的吗?

在那之后我开始学习其它更现代的语言。首先是 Go,然后我慢慢地把 Rust 也加了进来(感谢你,Prime)。

当你不知道有其他事物存在时,就很难错过它们。

这是在说什么呢?Go 和 Rust 的共同点是什么?错误处理。这对令我印象深刻。具体来说,是这些语言的处理方式。

JavaScript 通过抛出异常来处理错误,而 Go 和 Rust 则把错误当成值。你或许觉得这没有什么……但,孩子,这可能听上去微不足道,但,确实改变了游戏规则。

让我来看一下。我们不会深入研究每个语言,只想知道一般的处理方式。

先让我们从 JavaScript/TypeScript 和一个小游戏开始。

请给自己 5 秒钟来审查下面的代码,然后回答为什么要把代码放在 try/catch 里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
// handle response
} catch (e) {
// handle error
return;
}

所以,我假设大部分人已经猜到即使我们检查了 response.okfetch 方法仍然会抛出错误。response.ok 仅“捕获” 4xx 或者 5xx 的错误。但当网络本身发生错误时,就会被抛出。

但我想知道,又有多少人猜到 JSON.stringify 也会抛出错误。原因就是在请求对象中包含了 bigint(2n) 的变量。这会让 JSON 不知道如何字符串化。

所以,就我个人而言的第一个问题是,我相信也是 JavaScript 有史以来最大的问题,即:我们不知道什么会抛出错误。从 JavsScript 的角度看,和下面这个错误是一样的。

1
2
3
4
5
try {
let data = “Hello”;
} catch (err) {
console.error(err);
}

JavaScript 不知道;JavaScript 也不在乎。但你应该知道。

第二件事就是,这是一段可以运行的代码。

1
2
3
4
5
6
7
8
9
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}

没有错误,没有提示,即便如此它仍可以破坏你的程序。

现在,在我的脑海中可以听到“这有什么问题?只要我们用 try/catch 处理就可以了。” 于是便有了第三个问题:我们不知道抛出错误的是哪一个。当然,我们可以通过错误信息来推测,但对于那些有很多地方可能抛出错误的复杂服务/功能呢?你确定只用一个 try/catch 就能合适地处理吗?

好吧,是时候停止对 JS 的挑刺了。让我们从这段 Go 代码开始:

1
2
3
4
5
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f

我们正在尝试打开一个文件,它会返回文件内容或者一个错误。你经常会看到这种代码,因为我们知道哪些方法会返回错误。你不会错过任何一个。这个是将错误作为返回值的第一个例子。你可以指定哪个方法会返回它们,你返回它们,然后赋给变量,你对它们检查然后使用。

这并没有丰富多彩,并且这也是 Go 被诟病的地方之一——“错误检查“,其中 if err != nil 有时会比其他代码占用更多行数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if err != nil {

if err != nil {

if err != nil {

}
}
}
if err != nil {

}

if err != nil {

}

但相信我,这很值得。

最后再看一下 Rust:

1
2
3
4
5
let greeting_file_result = File::open(“hello.txt”);
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};

这是三个中最冗长的一个,但讽刺的是也是最好的一个。首先,Rust 通过它惊人的枚举类型来处理错误(它们和 TypeScript 的枚举不同!)。先不深入细节,这里最重要的就是名为 Result 的枚举,包含两个变量 OkErr。和你想的一样,Ok 包含返回值,Err 包含……错误信息。:D

还有多种方式能更方便地处理它们,以缓解 Go 的问题。其中最知名的是 ? 运算符。

1
let greeting_file_result = File::open(“hello.txt”)?;

在这里总结一下,Go 和 Rust 总能知道哪里会出现错误。并且会迫使你在它(大部分)出现的地方处理它。没有隐藏、没有猜测,没有令人惊讶的破坏。

并且这种方式更好。更好一点。

好吧,是时候说实话了;我撒了一点慌。我们并不能让 TypeScript 的错误像 Go/Rust 那样。这是因为语言本身的限制;它并没有合适的工具来做到这一点。

但我们可以尽可能地让它相似,并让它变得简单。

从这个例子:

1
2
3
4
5
6
7
8
9
export type Safe<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};

这里并没什么特别之处,只是一个简单的泛型。但这个小可爱会完全改变代码。正如你注意到的,这里最大的区别是我们要么返回数据,要么返回错误。是不是有点耳熟?

另外……第二个谎言是,我们确实学药一些 try/catch。好消息是,我们只需要 2 个,而非 100,000 个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
export function safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: string): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: string,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}

async function safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}

function safeSync<T>(
func: () => T,
err?: string
): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}

“哇哦,真是个天才。他封装了 try/catch。“ 是的,如你所见。这只是一层封装,将 Safe 类型作为返回结果。但有时你需要的只是简单的东西。让我将它与前面的例子结合起来。

旧代码(16 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
// handle network error
return;
}
// handle response
} catch (e) {
// handle error
return;
}

新代码(20 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const request = { name: “test”, value: 2n };
const body = safe(
() => JSON.stringify(request),
Failed to serialize request”,
);
if (!body.success) {
// handle error (body.error)
return;
}
const response = await safe(
fetch("https://example.com", {
method: “POST”,
body: body.data,
}),
);
if (!response.success) {
// handle error (response.error)
return;
}
if (!response.data.ok) {
// handle network error
return;
}
// handle response (body.data)

所以是的,新的方法更长但性能更好。原因如下:

  • 没有 try/catch
  • 我们处理了每一个错误
  • 我们可以为特定的方法指定特定的错误信息
  • 我们有一个很好自上而下的逻辑,所有错误都在上面,返回值在下面

关键点来了。如果我们忘记检查会发生什么?

1
2
3
4
if (!body.success) {
// handle error (body.error)
return;
}

问题是……我们并不能。是的,我们必须要做检查。如果不这么做,那么 body.data 将不会存在。语法检测会通过抛出“属性 ‘data’ 在 ‘Safe‘ 上不存在“的错误来提醒我们。这都要感谢我们之前创建的简单的 Safe 类型。同时它也适用于处理错误信息。在处理 !body.success 之前,我们将无法访问 body.error

在这里我们要感谢 TypeScript 以及它是如何改变了 JavsScript 的世界。

下面的内容也一样:

1
2
3
4
if (!response.success) {
// handle error (response.error)
return;
}

我们不能删除 !response.success,否则 response.data 也将不存在。

当然,我们的方式也并非没有问题。最重要的一点是,你必须要用我们的 safe 方法类包装那些会抛错的异步方法。这种“必知“来自语言的限制,并非我们所能克服。

这听上去有些困难,但事实并非如此。你很快就会意识到,代码中几乎所有的异步方法都可能会抛错,同步方法也会,你知道它们,但可能并不多。

不过,你仍可能会问,这么做值得吗?我们认为是的,并且在我们的团队中工作得很好 :)。当你查看一个复杂的业务逻辑时,没有 try/catch,每个错误都在出现的地方被处理,并且有一个良好的逻辑流程……看起来很棒。

这是一个使用 SvelteKit FormAction 的实际例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
export const actions = {
createEmail: async ({ locals, request }) => {
const end = perf(“CreateEmail”);
const form = await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema = z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata = createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response = await new Promise<Safe<Email__Output>>((res) => {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;

这里需要指出几点:

  • 我们自定义了 grpcSafe 来帮助处理 gRPC 的回调。
  • createMetadata 在其内部返回了 Safe 因此不需要再对其封装。
  • zod 库使用了相同的模式 :)。如果我们不对 schema.success 做处理,我们就无法访问 schema.data

是不是看起来很干净呢?不妨尝试一下吧!或许它也非常适合你 :)

感谢阅读。

PS:是不是很像?

1
2
3
4
5
f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
1
2
3
4
5
6
const response = await safe(fetch(“https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// do something with the response.data

译:在 TypeScript 中像 Go 和 Rust 那样处理错误?没有 Try/Catch
https://konta9.github.io/2024/05/10/2024/translate-ts-with-go-and-rust-errors/
作者
Konata
发布于
2024年5月10日
许可协议