Asynchronous Operations and Error Handling in TypeScript
A Deep Dive with Practical Examples and Strategies
Handling asynchronous code is very common in JavaScript(JS) and it's pretty safe to say that it is unavoidable when building real applications. By definition, asynchronous(async) programming allows us to perform non-blocking operations that might take some time to complete, such as requesting data from a server, reading a large file, or setting a timer. You will either receive a result or an error from the operation. To deliver a good user experience, it is very important that this entire process is handled well.
What does it mean the process should be handled well?
It means the user:
While JS provides the tools to handle these points, working in a team setting or on a complex application often means managing numerous asynchronous actions. This is where TypeScript(TS) can significantly improve maintainability. In this context, TS excels at managing input and output of asynchronous code, ensuring clear communication and expectations across the team.
What do you mean by "TypeScript excels at managing input and output of asynchronous code, ensuring clear communication and expectations across the team" ?
Yes, now you have the reason to read further, you're welcome.
Promises & Async/await with TypeScript
Earlier, we discussed the importance of async operations in building responsive web applications. In modern web development, we usually turn to either using Promises or Async/await when comes to handling asynchronous tasks. With TS it adds an extra layer of clarity and predictability to our code. Let's see how.
Promises
A Promise in JS is an object representing the eventual completion or failure of an async operation. Essentially, it's a placeholder for a value that will be known later — either a result of a successful operation or an error in case of failure.
Here's how we can visualize it:
+-------------------+
| Promise Created |
| (Pending) |
+-------------------+
|
| Executor function
|------------------------------|
| |
+--------v-------+ +-----------v---------+
| | | |
| Fulfilled | | Rejected |
| (Operation | | (Operation |
| Successful) | | Failed) |
| | | |
+----------------+ +---------------------+
In TS, we get the added benefit by specifying the type of value our Promise will resolve to or the type of error it might reject.
In the snippet above, taskResult is a Promise that will either resolve with a string or reject with an Error offering clear expectations for result type.
Async/await
On the other side, though it is a syntactic sugar on top of promises to offer simplicity in consuming the promise-based APIs, Async/await offers syntax that closely resembles how we think about synchronous operations.
+------------------------+
| Async Function Call |
| (Start) |
+------------------------+
|
| Awaits Promise
|
+------------v------------+
| Async Operation |
| (Pending) |
+------------------------+
|
| Promise Settles
|---------------------
| |
+------------v------------+ +------v-------+
| | | |
| Promise Fulfilled | | Promise |
| (Try Block Executes) | | Rejected |
| | | (Catch Block |
+-------------------------+ | Executes) |
| |
+--------------+
Like Promises, we can use TS type checking to ensure we handle the promises correctly.
In this case, when executing fetchData, we know the async function will return a string, as promised. This makes the intentions clear, not just for the TS compiler, but also for anyone reading the code.
What if you could take this a step further?
领英推荐
What if not only you can handle the async operations with greater precision but also provide clear definitions to the data and errors these operations might produce? As your application grows, it is very likely that you will bump into scenarios where a single async task could have multiple outcomes. For example, on a social media platform, a user might create different types of posts; shared links, images, or text posts. Meanwhile, a server request might return different data variants. In such circumstances, knowing if an operation succeeded or failed only is not enough; understanding and narrowing down the data types and errors becomes crucial to maintain a seamless user experience to your users.
This is where TS's type system excels. It allows you to not only be clear about the data type you might receive but also to handle the different shapes this data can take. Moreover, TS can also help you manage the different types of errors that may occur, providing a way to react accordingly in your code. Yes, you guessed it, generics and union types.
Generics and Discriminated Union in Asynchronous Operations
Generics are a feature that allows us to pass in different types of data and create reusable code to handle different inputs while maintaining type safety. Let's consider a common case where we need to write an async function to fetch a list of items from the server. Interestingly, each item comes with a key field that differentiates them. For such a case, pairing generics and discriminated union can be particularly powerful.
The getContent function here demonstrates the use of generics to achieve type safety at compile-time, making sure the content types are handled accordingly. This approach significantly reduces the likelihood of runtime errors.
Moreover, we leverage Exclude to ensure that getContent does not return an ErrorResponse as a response, which is an example of how the type system can prevent certain classes of runtime errors by design.
Despite the robust compile-time checks, some errors are inherently runtime and require explicit handling. We'll take a look at how custom error handling can act as a safety net for those errors that slip past the compile-time checks.
With custom error classes, we can handle exceptions in a granular way, complementing the compile-time type safety provided by generics. By combining these strategies, we create a resilient system that upholds type safety across both compile-time and runtime, providing a comprehensive safety net for our TS applications.
Error Handling with Result Types
Alternatively, you could make use of the Result or Either type pattern for the error handling. Though I personally prefer the custom error classes, but I found this functional approach serves as quite a structured alternative. This approach treats errors as data, encapsulating them within a result type that can be easily propagated through the async flows. It works well in the circumstances where you want to avoid throwing and catching exceptions, instead, you pass them as values for more explicit handling.
Structured Error Handling
Last but not least, I've found myself using this pattern a lot recently, centralizing the error handling with a higher-level abstraction. Similar to the functional one, this pattern focuses on encapsulating the result of an async operation in an object representing success or failure allowing the errors to be caught and dealt with upstream predictably.
In the snippet above, asyncHandleError function wraps async tasks by providing a unified way to handle the success and error without littering the try-catch blocks. Everything is handled upstream, as demonstrated in the handleOperation function. As a result, if status === 'success', I should get what was passed in as type, otherwise I should expect to see an error, much predictable.
Closing Thoughts
In this piece, we've gone through various ideas, or strategies if you will, on improving the code for async operations and error handling with TypeScript. The goal is to deliver code that is robust and maintainable enough for use in a team setting, dealing with a complex application, or both. With the examples that are close to real-world scenarios, I hope this has clarified what I meant by "TypeScript excels at managing input and output of asynchronous code, ensuring clear communication and expectations across the team".
See you on next topic ??
Senior Software Engineer
6 个月Great article! I really like to see error handling done right in TS.