Learn TypeScript With Me in 14 Days Day 13

Posted on 13 October 2024 Reading time: 6 min read
Learn TypeScript With Me in 14 Days Day 13

Day 13: Working with Asynchronous Code in TypeScript

In this part of the series, we’ will learn how TypeScript enhances the handling of asynchronous code with tools like Promises and async/await. Understanding asynchronous programming is crucial when working with tasks such as fetching data from APIs or handling operations that take time to complete. By the end of this post, we will know how to manage asynchronous workflows, use TypeScript to handle errors effectively, and integrate async code into our Task Manager project.

What is Asynchronous Code?

In JavaScript and TypeScript, asynchronous code allows us to run operations that take time (e.g., fetching data from a server, reading files) without blocking the execution of the rest of the code. This ensures that the application remains responsive during long-running tasks.

TypeScript enhances this by providing type safety and clearer error handling mechanisms, helping you avoid common pitfalls when writing asynchronous code.

Promises in TypeScript

Promises in TypeScript (and JavaScript) are objects representing the eventual completion or failure of an asynchronous operation. They provide a way to handle asynchronous operations more elegantly than traditional callback methods. You can handle Promises using .then() for success or .catch() for errors.

Basic Structure of a Promise

const myPromise: Promise<string> = new Promise((resolve, reject) => {
  // Asynchronous operation here
  if (/* operation successful */) {
    resolve("Success!");
  } else {
    reject("Error!");
  }
});

States of a Promise

A promise can be in one of three states:

1. Pending: Initial state, neither fulfilled nor rejected.

2. Fulfilled: The operation completed successfully.

3. Rejected: The operation failed.

Example: Using Promises in TypeScript

function fetchData(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (url === 'valid-url') {
        resolve('Data fetched successfully!');
      } else {
        reject('Invalid URL!');
      }
    }, 1000);
  });
}

fetchData('valid-url')
  .then((data) => {
    console.log(data);  // Output: Data fetched successfully!
  })
  .catch((error) => {
    console.error(error);  // Handles errors if URL is invalid
  });

Explanation:

  • Promise: This function returns a Promise that resolves with a string if successful, or rejects with an error message.
  • Promises are ideal for handling asynchronous operations like data fetching or waiting for I/O operations.

Async/Await in TypeScript

Async/Await is a modern syntax built on top of Promises that allows you to write asynchronous code in a more readable, synchronous-like manner.

Example: Using Async/Await in TypeScript

async function fetchDataAsync(url: string): Promise<string> {
  if (url === 'valid-url') {
    return 'Data fetched successfully!';
  } else {
    throw new Error('Invalid URL!');
  }
}

async function main() {
  try {
    const data = await fetchDataAsync('valid-url');
    console.log(data);  // Output: Data fetched successfully!
  } catch (error) {
    console.error(error.message);  // Handles errors if URL is invalid
  }
}

main();

Explanation:

  • async function**: Declares that the function will return a Promise.
  • await: Pauses the execution of the function until the Promise resolves. It simplifies code structure compared to chaining .then() and .catch().

Error Handling in Asynchronous Code

Handling errors in asynchronous code is important to ensure your application can gracefully handle unexpected situations, like failed API requests or timeouts.

Example: Robust Error Handling with Async/Await

async function fetchDataWithErrorHandling(url: string): Promise<string> {
  try {
    const data = await fetchDataAsync(url);
    return data;
  } catch (error) {
    console.error('Error fetching data:', error.message);
    throw error;  // Re-throw the error to handle it elsewhere if needed
  }
}

async function mainWithErrorHandling() {
  try {
    const data = await fetchDataWithErrorHandling('invalid-url');
    console.log(data);
  } catch (error) {
    console.error('Failed to fetch data:', error.message);
  }
}

mainWithErrorHandling();

Explanation:

  • Try/Catch Blocks: Wrap your await calls in try/catch blocks to handle errors. This pattern makes it easy to catch and log any problems with asynchronous code.

Applying Async Functions to the Task Manager

Let’s see how we can integrate asynchronous code into the Task Manager project. Imagine we need to simulate an asynchronous operation like fetching tasks from an API.

Task Manager with Asynchronous Data Fetching

<template>
  <div>
    <h2>Task Manager</h2>
    <button @click="fetchTasks">Fetch Tasks</button>
    <p v-if="errorMessage" class="error">{{ errorMessage }}</p>
    <ul>
      <li v-for="task in tasks" :key="task.id">
        {{ task.title }} - {{ task.completed ? 'Completed' : 'Not Completed' }}
      </li>
    </ul>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export default defineComponent({
  setup() {
    const tasks = ref<Task[]>([]);
    const errorMessage = ref<string>('');

    const fetchTasks = async () => {
      try {
        // Simulate async API call
        const response = await new Promise<Task[]>((resolve, reject) => {
          setTimeout(() => {
            resolve([
              { id: 1, title: 'Learn TypeScript', completed: false },
              { id: 2, title: 'Build Task Manager', completed: true }
            ]);
          }, 1000);
        });

        tasks.value = response;
      } catch (error) {
        errorMessage.value = 'Failed to fetch tasks';
      }
    };

    return {
      tasks,
      errorMessage,
      fetchTasks
    };
  }
});
</script>

<style>
.error {
  color: red;
}
</style>

Explanation:

  • Asynchronous Fetching: The fetchTasks function simulates an asynchronous API call to fetch tasks.
  • Error Handling: If the fetch fails, an error message is displayed.
  • Reactivity: The tasks are stored in a reactive tasks array, and the UI updates once the tasks are fetched.

Best Practices for Asynchronous Code in TypeScript

Here are some best practices to follow when writing asynchronous code in TypeScript:

1. Always Handle Errors:

Never assume that asynchronous operations will always succeed. Use try/catch blocks around await calls to handle errors properly.

2. Explicitly Type Promises:

Always specify the return type of your async functions. For example, Promise ensures that the function returns a string wrapped in a Promise, making it easier to debug and reason about your code.

3. Avoid Deep Nesting:

Using async/await can help you avoid deeply nested callback hell that comes with traditional Promise chains. Keep your code structure simple and flat.

4. Parallelize Where Possible:

If multiple asynchronous operations can be run in parallel, use Promise.all() to improve performance.

const [result1, result2] = await Promise.all([fetchData(url1), fetchData(url2)]);

5. Gracefully Handle Cancellations:

For operations that might need to be canceled (e.g., a long API call), consider using AbortController to handle cancellations gracefully.

What’s Next?

Today, we explored how TypeScript helps you handle asynchronous code using Promises and async/await, as well as how to manage errors effectively. We applied these concepts to the Task Manager project, enhancing it with asynchronous data fetching.

Tomorrow, we’ll wrap up the series with a final review and discuss additional TypeScript features and resources to continue our learning journey.

Resources for Further Reading

TypeScript Handbook - Async Programming

JavaScript Promises: An Introduction

Async/Await in JavaScript