
TypeScript Pattern: Asynchronous Operations
Mastering Async Operations and Error Handling with Examples
Full Story Accessible for Medium Non-Members HERE!
Overview
Handling asynchronous code is a staple in JavaScript applications. TypeScript brings type safety to async operations, enhancing predictability and reducing runtime errors. This piece aims to explore the patterns that we could leverage to manage asynchronous operations and error handling effectively.
Async/Await: Clearer Code
Async/await syntax promotes cleaner, more readable code that closely resembles synchronous execution. TypeScript’s type inference aligns with this, ensuring variables and return types are checked at compile-time, reducing possible runtime errors.
async function fetchData(url: string): Promise<string> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error: unknown) {
// Error handling
// Preserving stack trace
throw error instanceof Error ? error : new Error("Unexpected error");
}
}Promises: Ensuring Type Safety in Asynchronous Operations
TypeScript improves Promises by enforcing types on both the resolved value and any errors that might occur. This compile-time type-checking leads to more predictable output in asynchronous operations, significantly reducing the risk of unexpected runtime errors.
const taskResult: Promise<string> = new Promise((resolve, reject) => {
const someCondition = true;
if (someCondition) {
resolve("Success!");
} else {
// TypeScript ensures this is an Error object
reject(new Error("Failure"));
}
});This example demonstrates TypeScript’s capability to ensure the type of the error object is checked, which allows for fine-grained and resilient error handling.
Enhanced Generics and Error Handling
Generics in TypeScript enhance function flexibility while maintaining type safety. Consider an async function that fetches different types of content. Generics allow this function to clearly define its return type, ensuring compile-time type safety.
If you’re not familiar with discriminated unions or need quick refresher, feel free to check out my previous post 👆, where I went into details about the advantages of using it. Understanding discriminated unions will be especially helpful as they play a crucial role in the type-safe mechanisms we’re about to explore.
enum ResponseKind {
Article = "article",
Comment = "comment",
Error = "error",
}
type ArticleResponse = {
kind: ResponseKind.Article;
title: string;
content: string;
};
type CommentResponse = {
kind: ResponseKind.Comment;
content: string;
};
type ErrorResponse = {
kind: ResponseKind.Error;
message: string;
};
// Using a discriminated union to define response types
type ContentResponse = ArticleResponse | CommentResponse | ErrorResponse;
async function getContent<T extends ContentResponse>(
contentId: string
): Promise<Exclude<T, ErrorResponse>> {
const response: ContentResponse = await fetchContent(contentId);
if (response.kind === ResponseKind.Error) {
throw new Error(response.message);
}
// At this point, with ErrorResponse eliminated from the possible types due to our runtime check,
// we can be confident that the response is either an ArticleResponse or a CommentResponse.
// A more accurate TS Intellisense and a more predictable type (^o^)丿
return response as Exclude<T, ErrorResponse>;
}
// Usage of the getContent function with type assertion,
// reinforcing our expected return type for more predictable behavior and type safety.
async function displayContent(contentId: string) {
try {
// Here we assert that the response will be of type ArticleResponse.
const article = await getContent<ArticleResponse>(contentId);
// Type-safe access to the 'title' property.
console.log(article.title);
} catch (error) {
console.error(error);
}
}The getContent function above illustrates the use of generics to achieve type safety at compile-time, ensuring that we handle various content types appropriately. This approach significantly reduces the likelihood of runtime errors.
Furthermore, we leverage Exclude to ensure that getContent does not return an ErrorResponse, which is an example of how TypeScript’s type system can prevent certain classes of runtime errors by design.
Despite TypeScript’s robust compile-time checks, some errors are inherently runtime and require explicit handling. Next, we’ll take a look at how custom error handling can act as a safety net for those errors that slip past compile-time checks.
Continuing from our type-safe data fetching practices, it’s vital to have a robust strategy for runtime errors. The introduction of custom error classes below provides a detailed method for differentiating and handling such errors effectively.
class BadRequestError extends Error {
public statusCode: number;
constructor(message: string, statusCode = 400) {
super(message);
this.name = "BadRequestError";
// Default to HTTP 400 status code for Bad Request
this.statusCode = statusCode;
}
}
type UserData = {
name: string;
};
async function submitUserData(userData: UserData): Promise<void> {
try {
validateUserData(userData);
// Handle data submission to an API...
} catch (error) {
if (error instanceof BadRequestError) {
console.error(`Validation failed: ${error.message}`);
// Handle bad request error
} else {
console.error(`Unexpected error: ${error.message}`);
// Handle unexpected errors
}
// Re-throw the error if you want to access the error from the higher-level calling function
throw error;
}
}
function validateUserData<T extends UserData>(data: T): void {
if (!data.name) {
throw new BadRequestError("Name is required");
}
// Some other validations...
}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 TypeScript applications.
Alternative Error Handling with Result Types
When dealing with asynchronous operations, a functional programming style can be particularly useful. The Result or Either type pattern offers a structured alternative to traditional error handling. This approach treats errors as data, encapsulating them within a result type that can be easily propagated through asynchronous flows.
type Success<T> = { kind: 'success', value: T };
type Failure<E> = { kind: 'failure', error: E };
type Result<T, E = Error> = Success<T> | Failure<E>;
type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
async function asyncComplexOperation(): AsyncResult<number> {
try {
// Asynchronous logic here...
const value = await someAsyncTask();
return { kind: 'success', value };
} catch (error) {
return {
kind: 'failure',
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}Structured Error Handling in Asynchronous Operations
For more complex asynchronous applications, you might want to employ error boundary types to handle errors at a higher level. This pattern is designed to work well with async/await syntax, allowing errors to be caught and dealt with upstream cleanly and predictably.
type ErrorBoundary<T, E extends Error> = {
status: 'success';
data: T;
} | {
status: 'error';
error: E;
};
async function asyncHandleError<T>(
fn: () => Promise<T>,
createError: (message?: string) => Error
): Promise<ErrorBoundary<T, Error>> {
try {
const data = await fn();
return { status: 'success', data };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
status: 'error',
error: createError(errorMessage)
};
}
}
async function riskyAsyncOperation(): Promise<string> {
const someCondition = false;
if (someCondition) {
throw new Error('Failure');
}
return 'Success';
}
async function handleOperation() {
const result = await asyncHandleError(riskyAsyncOperation, (message) => new Error(message));
if (result.status === 'success') {
console.log(result.data); // Outputs 'Success'
} else {
console.error(result.error.message); // Outputs the error message, if any
}
}
// Execute the operation
handleOperation();In the async adaptation of the ErrorBoundary pattern, the asyncHandleError function takes an asynchronous function and returns a promise that resolves to either a success or an error object. This ensures that asynchronous errors are handled in a structured and type-safe manner, promoting better error management practices in your TypeScript code.
Closing Thoughts
This piece laid out strategies to improve asynchronous operations and error handling with TypeScript, enhancing code robustness and maintainability. We’ve illustrated these patterns with practical examples, underlining their applicability in real-world scenarios. So, again, take these patterns, extend them, and see how they can help you to improve your TypeScript project in terms of maintainability. Stay tuned for our next topic; catch you then!

Alvis Ng — Senior Software Engineer at YOPESO. Having transitioned from a solid foundation in product management to my current passion in front-end development, I strive to intertwine design with functionality, convert user stories into values. Beyond the code, my guiding principle is CI/CD: Continuous Improvement & Continuous Development.






