您正在开发 TypeScript 项目。代码干净并且架构良好,你为此感到自豪。有一天,弹出错误。它的堆栈跟踪比平均 npm 安装更长,通过无数无法处理它的层向上冒泡。你的代码无法工作,你不知道从哪里开始修复它,每一次尝试都感觉像是笨拙的补丁。您的架构看起来不再那么干净了。你讨厌你的项目。您关闭电脑,去享受您的星期五。
错误管理荒原
JavaScript 的错误管理 缺乏 Rust、Zig 和 Go 等现代语言提供的表达能力和开发人员体验。它的动态特性和缺乏护栏常常让开发人员面临不确定性,而没有更严格的平台提供的坚实基础和保证。
软件工程的这一重要支柱在语言的文化和生态系统中没有得到很好的体现,一些最受欢迎的 npm 库甚至没有在其文档中提及异常。
缺乏标准会导致开发人员产生误解异常很少发生。因此,这种扭曲的观点导致社区对建立此类标准缺乏兴趣。
Try-Catch:隐式的成本
JavaScript try-catch 模型隐藏了不明显的含义。异常情况可能发生在任何地方,但预测它们却极具挑战性。这种看似简单的模式常常掩盖了日常代码中微妙的陷阱:
let value; try { value = mayThrow(); } catch (e) { // Handle the exception. }
代码片段中突出的第一个问题是范围扩展,需要在 try-catch 块之外声明变量以维持连续的控制流。这会导致更多冗长、难以跟踪代码,随着代码库复杂性的增加,可能会引入微妙的错误。
这种动态错误处理的隐式性质增加了开发人员的认知负担,要求他们在整个代码库中在心里跟踪异常源。相比之下,显式错误处理模型(例如 Go 中的模型)迫使开发人员承认并处理任何错误。
result, err := mayFail();
从长远来看,这是一个巨大的胜利,随着项目的发展,可以促进更顺畅、更安全的维护。
除了这些挑战之外,TypeScript 的 catch 子句在跟踪和严格键入可能抛出的错误的能力方面还存在不足,导致在最关键的地方失去类型安全性。 JavaScript 甚至允许抛出非错误值,这让我们几乎没有任何保护措施。像 Rust 这样的语言通过其错误处理设计展示了这种方法的强大功能和优雅:
match may_fail() { Ok(result) => println!("Success"), Err(Error::NotFound) => println!("Not found"), Err(Error::PermissionDenied) => println!("Permission denied"), }
各种提案已提交给 TypeScript 团队,旨在为更健壮和可预测的异常系统奠定基础。然而,这些提议经常被底层 JavaScript 平台中的限制所阻碍,该平台缺乏支持此类架构增强的必要原语。
同时,一些解决这些缺陷的提案也已提交给 TC39 (ECMAScript 标准化委员会),但仍处于早期考虑阶段。正如马特·波科克指出的那样,宇宙的热寂也在稳步进展。
寻求社区解决方案
当一种语言对创新产生摩擦时,开发者社区通常会以巧妙的库和用户态解决方案来回应。该领域当前的许多提案,例如特殊的 Neverthrow,都从 函数式编程
中汲取灵感,提供了一套类似于 Rust 的结果类型的抽象和实用程序来解决问题:
function mayFail(): Result<string> { if (condition) { return err("failed"); } return ok("value"); } </string>
另一种突出的方法是效果
。这个强大的工具包不仅可以解决错误管理问题,还提供了一套全面的实用程序来处理异步操作、资源管理等:
import { Effect } from "effect"; function divide(a: number, b: number): Effect.Effect<number error> { return b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b); } const result = Effect.runSync(divide(1, 2)); </number>
Outside the joy of a nerd like myself in digging into tech like this, adopting new technologies demands a careful cost-benefit analysis. The JavaScript ecosystem evolves at a breakneck pace, with libraries emerging and becoming obsolete in rapid succession.
Choosing the wrong abstraction can hold your code hostage, create friction in your development process, and demand blood, sweat, and tears to migrate away from. (Also, adding a new package is likely not gonna help with the 200mb bundle size of your React app.)
Error management is a pervasive concern that touches nearly every part of a codebase. Any abstraction that requires rethinking and rewriting such a vast expanse of code demands an enormous amount of trust—perhaps even faith—in its design.
Crafting a Path Forward
We've explored the limitations of user-land solutions, and life's too short to await commit approvals for new syntax proposals. Could there be a middle ground? What if we could push the boundaries of what's currently available in the language, creating something that aspires to be a new standard or part of the standard library, yet is written entirely in user-land and we can use it right now?
As we delve into this concept, let's consider some key principles that could shape our idea:
- Conventions over Abstractions: Minimize abstractions by leveraging existing language features to their fullest.
- Minimal API: Strive for simplicity without sacrificing functionality. Conciseness is often an indicator of robust and lasting design.
- Compatibility and Integrability: Our solution shouldn't depend on universal adoption, and must seamlessly consume and be consumed by code not written with the same principles in mind.
- Intuitive and Ergonomic: The patterns should be self-explanatory, allowing developers to grasp and implement them at a glance, minimizing the risk of misinterpretations that could result in anti-patterns or unexpected behaviors.
- Exploit TypeScript: Leverage TypeScript's type system to provide immediate feedback through IDE features like syntax highlighting, error detection, and auto-completion.
Now, let's dive into the heart of the matter by addressing our first key challenge. Let's introduce the term task for functions that may either succeed or encounter an error.
function task() { if (condition) { throw new Error("failed"); } return "value"; }
We need an error-handling approach that keeps control flow clean, keeps developers constantly aware of potential failures, and maintains type safety throughout. One idea worth exploring is the concept of returning errors instead of throwing them. Let's see how this might look:
function task() { if (condition) { // return instead of throwing. return new Error("failed"); } return "value"; }
By introducing Errors as values and assigning them specific meaning, we enhance the expressivity of a task's return value, which can now represents either successful or failing outcomes. TypeScript’s type system becomes particularly effective here, typing the result as string | Error, and flagging any attempt to use the result without first checking for errors. This ensures safer code practices. Once error checks are performed, type narrowing allows us to work with the success value free from the Error type.
const result: string | Error = task(); // Handle the error. if (result instanceof Error) { return; } result; // ?^ result: string
Managing multiple errors becomes reliable with TypeScript’s type checker, which guides the process through autocompletion and catches mistakes at compile time, ensuring a type-driven and dependable workflow.
function task() { if (condition1) return new CustomError1(); if (condition2) return new CustomError2(); return "value"; } // In another file... const result = task(); if (result instanceof CustomError1) { // Handle CustomError1. } else if (result instanceof CustomError2) { // Handle CustomError2. }
And since we're just working within plain JavaScript, we can seamlessly integrate existing libraries to enhance our error handling. For example, the powerful ts-pattern library synergize beautifully with this approach:
import { match } from "ts-pattern"; match(result) .with(P.instanceOf(CustomError1), () => { /* Handle CustomError1 */ }) .with(P.instanceOf(CustomError2), () => { /* Handle CustomError2 */ }) .otherwise(() => { /* Handle success case */ });
We now face 2 types of errors: those returned by tasks adopting our convention and those thrown. As established in our guiding principles, we can't assume every function will follow our convention. This assumption is not only necessary to make our pattern useful and usable, but it also reflects the reality of JavaScript code. Even without explicit throws, runtime errors like "cannot read properties of null" can still occur unexpectedly.
Within our convention, we can classify returned errors as "expected" — these are errors we can anticipate, handle, and recover from. On the other hand, thrown errors belong to the "unexpected" category — errors we can't predict or generally recover from. These are best addressed at the highest levels of our program, primarily for logging or general awareness. Similar distinctions are built into the syntax of some other languages. For example, in Rust:
// Recoverable error. Err("Task failed") // Unrecoverable error. panic!("Fatal error")
For third-party APIs whose errors we want to handle, we can wrap them in our own functions that conform to our error handling convention. This approach also gives us the opportunity to add additional context or transform the error into a more meaningful representation for our specific use case. Let's take fetch as an example, to demonstrate also how this pattern seamlessly extends to asynchronous functions:
async function $fetch(input: string, init?: RequestInit) { try { // Make the request. const response = await fetch(input, init); // Return the response if it's OK, otherwise an error. return response.ok ? response : new ResponseError(response); } catch (error) { // ?^ DOMException | TypeError | SyntaxError. // Any cause from request abortion to a network error. return new RequestError(error); } }
When fetch returns a response with a non-2XX status code, it's often considered an unexpected result from the client's perspective, as it falls outside the normal flow. We can wrap such responses in a custom exception type (ResponseError), while keeping other network or parsing issues in their own type (RequestError).
const response: Response | ResponseError | RequestError = await $fetch("/api");
This is an example of how we can wrap third-party APIs to enrich the expressiveness of their error handling. This approach also allows for progressive enhancement — whether you’re incrementally refactoring existing try/catch blocks or just starting to add proper error types in a codebase that’s never even heard of try/catch. (Yes, we know you’re out there.)
Another important aspect to consider is task composition, where we need to extract the results from multiple tasks, process them, and return a new value. In case any task returns an error, we simply stop the execution and propagate it back to the caller. This kind of task composition can look like this:
function task() { // Compute the result and exclude the error. const result1: number | Error1 = task1(); if (result1 instanceof Error1) return result1; // Compute the result and exclude the error. const result2: number | Error2 = task2(); if (result2 instanceof Error2) return result2; const result = result1 + result2; }
The return type of the task is correctly inferred as number | Error1 | Error2, and type narrowing allow removing the Error types from the return values. It works, but it's not very concise. To address this issue, languages like Zig have a dedicated operator:
pub fn task() !void { const value = try mayFail(); // ... }
We can achieve something similar in TypeScript with a few simple tricks. Our goal is to create a more concise and readable way of handling errors while maintaining type safety. Let's attempt to define a similar utility function which we'll call $try, it could look something like this:
function task() { const result1: number = $try(task1()); const result2: number = $try(task2()); return result1 + result2; }
This code looks definitely cleaner and more straightforward. Internally, the function could be implemented like this:
function $try<t>(result: T): Exclude<t error> { if (result instanceof Error) throw result; return result; } </t></t>
The $try function takes a result of type T, checks if it's an Error, and throws it if so. Otherwise, it returns the result, with TypeScript inferring the return type as Exclude
We've gained a lot in readability and clarity, but we've lost the ability to type expected errors, moving them to the unexpected category. This isn't ideal for many scenarios.
We need a native way to collect the errors types, perform type narrowing, and terminate execution if an error occurs, but we are running short on JavaScript constructs. Fortunately, Generators can come to our rescue. Though often overlooked, they can effectively handle complex control flow problems.
With some clever coding, we can use the yield keyword to extract the return type from our tasks. yield passes control to another process that determines whether to terminate execution based on whether an error is present. We’ll refer to this functionality as $macro, as if it extends the language itself:
// ?^ result: number | Error1 | Error2 const result = $macro(function* ($try) { const result1: number = yield* $try(task1()); const result2: number = yield* $try(task2()); return result1 + result2; });
We'll discuss the implementation details later. For now, we've achieved our compact syntax at the cost of introducing an utility. It accepts tasks following our convention and returns a result with the same convention: this ensures the abstraction remains confined to its intended scope, preventing it from leaking into other parts of the codebase — neither in the caller nor the callee.
As it's still possible to have the "vanilla" version with if statements, paying for slightly higher verbosity, we've struck a good balance between conciseness and keeping everything with no abstraction. Moreover, we've got a potential starting point to inspire new syntax or a new part of the standard library, but that's for another post and the ECMAScript committee will have to wait for now.
Wrapping Up
Our journey could end here: we've highlighted the limitations of current error management practices in JavaScript, introduced a convention that cleanly separates expected from unexpected errors, and tied everything together with strong type definitions.
As obvious as it may seems, the real strength of this approach lies in the fact that most JavaScript functions are just a particular case of this convention, that happens to return no expected error. This makes integrating with code written without this convention in mind as intuitive and seamless as possible.
One last enhancement we can introduce is simplifying the handling of unexpected errors, which up to now still requires the use of try/catch. The key is to clearly distinguish between the task result and unexpected errors. Taking inspiration from Go's error-handling pattern, we can achieve this using a utility like:
const [result, err] = $trycatch(task);
This utility adopts a Go-style tuple approach, where the first element is the task's result, and the second contains any unexpected error. Exactly one of these values will be present, while the other will be null.
But we can take it a step further. By leveraging TypeScript's type system, we can ensure that the task's return type remains unknown until the error is explicitly checked and handled. This prevents the accidental use of the result while an error is present:
const [result, err] = $trycatch(() => "succeed!"); // ?^ result: unknown // ?^ err: Error | null if (err !== null) { return; } result; // ?^ result: string
Due to JavaScript's dynamic nature, any type of value can be thrown. To avoid falsy values that can create and subtle bugs when checking for the presence of an error, err will be an Error object that encapsulates the thrown values and expose them through Error.cause.
To complete out utility, we can extend it to handle asynchronous functions and promises, allowing the same pattern to be applied to asynchronous operations:
// Async functions. const [result, err] = await $trycatch(async () => { ... }); // Or Promises. const [result, err] = await $trycatch(new Promise(...));
That's enough for today. I hope you’ve enjoyed the journey and that this work inspires new innovations in the Javascript and Typescript ecosystem.
How to implement the code in the articles, you ask? Well, of course there's a library! Jokes aside, the code is straightforward, but the real value lies in the design and thought process behind it. The repository serves as a foundation for ongoing discussions and improvements. Feel free to contribute or share your thoughts!
See you next time — peace ✌️.
ts-zen
/
trycatch
Robust and Type-Safe Errors Management Conventions with Typescript
Robust and Type-Safe Errors Management Conventions with Typescript
Philosophy
还没读过博文吗?您可以在这里找到它,深入了解该项目背后的设计和推理。以下是帮助您入门的快速快照:
JavaScript 的错误管理设计 落后于 Rust、Zig 和 Go 等现代语言。语言设计很困难,大多数提交给 ECMAScript 或 TypeScript 委员会的提案要么被拒绝,要么经历极其缓慢的迭代过程。
该领域的大多数库和用户态解决方案都引入了属于红/蓝函数问题的抽象,需要完整的代码库采用并导致技术锁定。
这个项目的目标是突破 JavaScript 中错误处理的界限,优先考虑约定而不是抽象并充分利用本机构造的潜力。我们提供了一套最小的实用程序来增强开发人员体验,希望能够激发未来的语言改进和......
以上是TypeScript:错误管理的新领域的详细内容。更多信息请关注PHP中文网其他相关文章!

引言我知道你可能会觉得奇怪,JavaScript、C 和浏览器之间到底有什么关系?它们之间看似毫无关联,但实际上,它们在现代网络开发中扮演着非常重要的角色。今天我们就来深入探讨一下这三者之间的紧密联系。通过这篇文章,你将了解到JavaScript如何在浏览器中运行,C 在浏览器引擎中的作用,以及它们如何共同推动网页的渲染和交互。JavaScript与浏览器的关系我们都知道,JavaScript是前端开发的核心语言,它直接在浏览器中运行,让网页变得生动有趣。你是否曾经想过,为什么JavaScr

Node.js擅长于高效I/O,这在很大程度上要归功于流。 流媒体汇总处理数据,避免内存过载 - 大型文件,网络任务和实时应用程序的理想。将流与打字稿的类型安全结合起来创建POWE

Python和JavaScript在性能和效率方面的差异主要体现在:1)Python作为解释型语言,运行速度较慢,但开发效率高,适合快速原型开发;2)JavaScript在浏览器中受限于单线程,但在Node.js中可利用多线程和异步I/O提升性能,两者在实际项目中各有优势。

JavaScript起源于1995年,由布兰登·艾克创造,实现语言为C语言。1.C语言为JavaScript提供了高性能和系统级编程能力。2.JavaScript的内存管理和性能优化依赖于C语言。3.C语言的跨平台特性帮助JavaScript在不同操作系统上高效运行。

JavaScript在浏览器和Node.js环境中运行,依赖JavaScript引擎解析和执行代码。1)解析阶段生成抽象语法树(AST);2)编译阶段将AST转换为字节码或机器码;3)执行阶段执行编译后的代码。

Python和JavaScript的未来趋势包括:1.Python将巩固在科学计算和AI领域的地位,2.JavaScript将推动Web技术发展,3.跨平台开发将成为热门,4.性能优化将是重点。两者都将继续在各自领域扩展应用场景,并在性能上有更多突破。

Python和JavaScript在开发环境上的选择都很重要。1)Python的开发环境包括PyCharm、JupyterNotebook和Anaconda,适合数据科学和快速原型开发。2)JavaScript的开发环境包括Node.js、VSCode和Webpack,适用于前端和后端开发。根据项目需求选择合适的工具可以提高开发效率和项目成功率。

是的,JavaScript的引擎核心是用C语言编写的。1)C语言提供了高效性能和底层控制,适合JavaScript引擎的开发。2)以V8引擎为例,其核心用C 编写,结合了C的效率和面向对象特性。3)JavaScript引擎的工作原理包括解析、编译和执行,C语言在这些过程中发挥关键作用。


热AI工具

Undresser.AI Undress
人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover
用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool
免费脱衣服图片

Clothoff.io
AI脱衣机

Video Face Swap
使用我们完全免费的人工智能换脸工具轻松在任何视频中换脸!

热门文章

热工具

SecLists
SecLists是最终安全测试人员的伙伴。它是一个包含各种类型列表的集合,这些列表在安全评估过程中经常使用,都在一个地方。SecLists通过方便地提供安全测试人员可能需要的所有列表,帮助提高安全测试的效率和生产力。列表类型包括用户名、密码、URL、模糊测试有效载荷、敏感数据模式、Web shell等等。测试人员只需将此存储库拉到新的测试机上,他就可以访问到所需的每种类型的列表。

适用于 Eclipse 的 SAP NetWeaver 服务器适配器
将Eclipse与SAP NetWeaver应用服务器集成。

Atom编辑器mac版下载
最流行的的开源编辑器

SublimeText3汉化版
中文版,非常好用

ZendStudio 13.5.1 Mac
功能强大的PHP集成开发环境