avatarAlvis Ng

Summary

The provided content discusses TypeScript patterns for managing asynchronous operations and error handling, emphasizing type safety and predictability in code execution.

Abstract

The article delves into the nuances of handling asynchronous code in TypeScript applications, highlighting the benefits of async/await syntax for cleaner, more readable code that resembles synchronous execution. It underscores TypeScript's type inference and compile-time checks as mechanisms for reducing runtime errors, particularly when dealing with Promises and generics. The use of discriminated unions and custom error classes is presented as a means to enhance error handling, ensuring that errors are managed both at compile-time and runtime. Additionally, the article explores functional programming patterns such as Result types and Error Boundaries to encapsulate and propagate errors in a structured manner, promoting better error management practices in TypeScript.

Opinions

  • The author advocates for the use of TypeScript's type safety features to enhance the predictability and robustness of asynchronous operations.
  • There is a strong emphasis on the importance of proper error handling, suggesting that a combination of TypeScript's type system and custom error classes can provide a comprehensive safety net.
  • The article suggests that leveraging functional programming concepts like Result types can offer a more structured alternative to traditional error handling in asynchronous flows.
  • The author posits that the Error Boundary pattern, adapted for asynchronous operations, can help developers manage errors in a structured and type-safe manner, which is crucial for complex applications.
  • The author believes that by adopting these patterns and practices, developers can significantly improve the maintainability and reliability of their TypeScript projects.

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.

Typescript
Programming
Web Development
Software Development
Business
Recommended from ReadMedium