あなたは TypeScript プロジェクトに取り組んでいます。コードはクリーンでよく設計されている、あなたはそれを誇りに思っています。ある日、エラーが表示されます。そのスタック トレースは平均的な npm インストールよりも長く、処理できなかった無数のレイヤーを介して湧き出ています。コードが機能しません。どこから修正すればよいのか見当もつきません。すべての試みが不器用なパッチのように感じられます。あなたのアーキテクチャはもうそれほどきれいではありません。あなたは自分のプロジェクトが嫌いです。あなたは PC を閉じて、金曜日を楽しみに行きます。
JavaScript の エラー管理 は、Rust、Zig、Go などの最新言語が提供する表現力や開発者エクスペリエンスには及ばない。その動的な性質とガードレールの欠如により、開発者は、より厳格なプラットフォームが提供する強固な基盤と保証がなければ、不確実性を乗り越えることが多くなります。
ソフトウェア エンジニアリングのこの重要な柱は、言語の文化やエコシステムにはあまり反映されておらず、最も人気のある npm ライブラリの一部はドキュメントで例外についてさえ言及していません。
標準の欠如により、開発者は例外がめったに発生しないという誤解を助長します。結果として、この偏った視点は、コミュニティ内でそのような標準を確立することへの関心の欠如につながります。
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 の Result 型に似た一連の抽象化とユーティリティを提供しています。
function mayFail(): Result<string> { if (condition) { return err("failed"); } return ok("value"); }
もう 1 つの際立ったアプローチは、Effect
のアプローチです。この強力なツールキットは、エラー管理に正面から取り組むだけでなく、非同期操作やリソース管理などを処理するための包括的なユーティリティ スイートも提供します。
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));
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.
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:
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; }
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.
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 ✌️.
Robust and Type-Safe Errors Management Conventions with Typescript
ブログ投稿をまだ読んでいませんか?このプロジェクトの背後にある設計と推論について詳しく知るには、ここで見つけることができます。開始するための簡単なスナップショットを次に示します:
JavaScript の エラー管理設計 は、Rust、Zig、Go などの現代言語に比べて遅れています。言語設計は難しく、ECMAScript 委員会や TypeScript 委員会へのほとんどの提案は拒否されるか、非常に遅い反復プロセスを経ることになります。
この分野のほとんどのライブラリとユーザーランド ソリューションでは、赤/青関数問題に該当する抽象化が導入されており、コードベースの完全な採用が必要となり、テクノロジのロックインが発生します。
このプロジェクトの目標は、JavaScript でのエラー処理の限界を押し広げ、抽象化よりも規約を優先し、ネイティブ構造を最大限に活用することです。私たちは、将来の言語の改善と…
以上がTypeScript: エラー管理の新たなフロンティアの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。