To Throw or Not to Throw? Error Propagation in JavaScript and TypeScript
Exploring Error propagation principles
One of the most questionable concepts in JavaScript and TypeScript is error propagation. Part of the problem is the misunderstanding of the difference between an exception and an error.
The post aims to discover exception types and define error propagation principles. Even though the article emphasizes JavaScript and TypeScript, the same error propagation principles apply to many other languages.
Exceptions and errors
An error is an object containing information about what went wrong and where in code it happened. Exceptions are not errors; exceptions are anomalous or exceptional conditions requiring special processing. There are two types of such conditions: operational and non-operational.
The input validation errors are operational errors. A failed login attempt is an operational situation. These use cases are expected and handled accordingly. The application continues operating as usual whenever such scenarios happen.
A non-operational condition is when an application cannot automatically resolve an error and should be terminated. For example, an application should store data in a DB. Some of the application's functionality is lost when the connection to a DB is impossible. If this functionality is critical, the application is in a non-operational state. If it cannot automatically recover, it should be terminated.
Errors propagation
Everything starts from error propagation. The method with which an error is returned determines whether the application can continue functioning or it's better to stop. The error propagation method also defines how the error should be handled.
There are two ways to propagate an error in JavaScript and TypeScript:
- Throw an exception. It terminates the process if not handled. It should be used when the intention is to stop an application when something goes wrong.
- Return an error. It denotes an expected error case scenario and does not require an application termination.
The danger of using the throw mechanism to return an error is that it can terminate an application if not handled properly. Just returning errors will not stop an application. But ignoring such errors can bring an application into an unexpected and disordered state.
Another essential aspect when choosing an error propagation mechanism is documentation. When working with TypeScript, the language service infers types of input and output parameters of the functions. If a function returns a parameter of error type, it is visible in the code editor and reduces the risk of missing error handling. The situation differs when a function throws an exception — the language service cannot identify it. There's the throws tag defined in TSDoc, but it's not widely used. The only way to find out that the function throws an exception is to read its source code.
Exception Handling
Exception handling depends on how an error is returned and the type of function that returns an error. It's relatively easy for synchronous functions. Error checked with if clause in the case when it is returned by a function. If a function throws an exception, its call is usually guarded with try/catch.
Exception handling is more complex in the case of asynchronous functions. There are a few ways to handle asynchronous operations:
- Use callback functions
- Use promises with
.then
and.catch
- Use
await
to resolve promises
There are also generators, but I'll omit them.
Exception handling in callbacks is quite simple. Whenever an asynchronous operation is finished, the operation handler invokes a callback function and returns errors if there are some. By convention, the first parameter of the callback function is an error. If an error is not null, the callback function invocation stops. It makes the control flow easy to understand.
There are a few challenges here. It is unclear whether the error results from an operational or non-operational exception. Another challenge is processing results propagation. The need to pass one function's output to another's input created lots of code with nested callbacks, which is hard to read and maintain.
Promises solved the problem of orchestrating sequential invocations of functions (synchronous and asynchronous). A sequence of promises can be joined in one call with .then, and data can flow between calls. But promises use callbacks and inherit the same exception handling challenges.
Async/await removed the need to use callbacks and solved the orchestration issue. The result of the asynchronous function can now be awaited. It makes code cleaner and easier to read, a happy path at least. Try/catch replaced the callback error handling technique. It felt like a win at the beginning. In reality, new issues replaced old issues:
- When guarding an asynchronous function call with try/catch, it is still unclear whether the error results from an operational or non-operational exception.
- It is not rare to see multiple asynchronous function calls within one try/catch block. In this case, the catch clause handles exceptions from all functions. It is also not rare to see an intricate error processing logic in the catch block in an attempt to understand what function call is the source of error.
- Nested try/catch blocks. It looks even worse than nested callbacks.
So, how can we use the advantage of async/await in asynchronous call orchestration and minimise exception handling issues?
Error propagation and handling principles
- Return error for operational exceptions.
- Always check returned errors.
- Throw an error for non-operational exceptions.
- Try statement block should guard a single logical unit or a call.
- Avoid complex logic in a catch clause.
- Don't nest try/catch blocks.
- Wrap errors.
Wrapping errors means taking one error value and putting another error value inside it. It allows extending an error with additional information about where it came from or how it happened without losing the original value. There are a few libraries supporting error wrapping in JavaScript. Alternatively, you can adopt ECMAScript2022 which has built-in support of error.cause
.
The following sections show examples of error propagation and handling:
Try catch approach
It's easy to reason about the code when try/catch guards a single statement. Things get worse quickly when it's necessary to handle multiple asynchronous calls.
Wrapping try/catch approach
This approach hides try/catch within the function. The function declaration states that it can return an error or a value. It allows using an if statement to control logic flow.
The following example is the next step to generalise error handling. It uses TypeScript utility types to infer function parameters and output types.
The examples above use Deno read file API. To run them, use the following command:
$ FILE=hello.txt deno run --allow-env=FILE --allow-read main.ts
Thanks for reading.