TypeScript Promise [Guide to Asynchronous Programming]

To become an expert in TypeScript, you should know how to work with asynchronous code. We can do this using Promises. Promises in TypeScript handle asynchronous operations, making your code more readable and manageable. They represent a value that might not be available yet but will be at some point in the future, allowing your code to continue running while waiting for results. In this tutorial, I will explain everything about promises in TypeScript, from basic concepts to advanced implementations..

TypeScript promises help developers control the flow of asynchronous code by providing methods like then(), catch(), and finally() to handle successful outcomes, errors, and cleanup operations respectively.

TypeScript enhances JavaScript promises with static typing, giving developers additional confidence through compile-time checks that catch potential errors before code even runs. Whether fetching data from an API, reading files, or performing any task that takes time to complete, TypeScript promises offer a consistent and type-safe approach to managing asynchronous operations in modern web applications.

What Are Promises in TypeScript?

A promise in TypeScript is an object that holds the resolution state of a given asynchronous function and allows you to perform actions based on the resolution state. In simpler terms, promises help you handle operations that take time to complete, like fetching data from an API or reading a file.

Promises represent a value that may not be available yet but will be resolved at some point in the future. This makes them perfect for managing asynchronous operations in a more organized and understandable way compared to callback-based approaches.

Why Use Promises in TypeScript?

Let me give you a few points about why promises are so valuable in TypeScript:

  1. Improved readability – Promises create more readable code compared to nested callbacks
  2. Better error handling – They provide a standardized way to handle errors
  3. Chaining operations – You can easily sequence asynchronous operations
  4. Avoiding callback hell – They eliminate the “pyramid of doom” in nested callbacks

Basic Promise Syntax in TypeScript

Creating a new promise in TypeScript is straightforward. You use the new keyword followed by Promise. The Promise constructor takes a function with two parameters: resolve and reject.

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

Notice that I’ve used TypeScript’s generic notation Promise<string> to specify that this promise will resolve with a string value.

TypeScript Promise States

Every promise in TypeScript exists in one of three states:

StateDescription
PendingInitial state, neither fulfilled nor rejected
FulfilledOperation completed successfully
RejectedOperation failed

Read TypeScript Enums

Working with Promises in TypeSecript

Promises in TypeScript provide a powerful way to handle asynchronous operations with clear typing and structured error handling. They represent values that might not be available yet but will be resolved at some point in the future.

Create a Promise

In TypeScript, you can create a Promise using the Promise constructor. This constructor takes a function with two parameters: resolve and reject.

const myPromise = new Promise<string>((resolve, reject) => {
  // Async operation happens here
  if (/* operation successful */) {
    resolve("Success data");
  } else {
    reject("Error message");
  }
});

You can also create helper functions that return promises for common operations.

function fetchData(url: string): Promise<Response> {
  return fetch(url);
}

This pattern helps organize code that deals with asynchronous tasks like API calls, file operations, or delayed computations.

Promise States

Promises in TypeScript have three possible states that represent their execution status.

  1. Pending: The initial state when a promise is created and hasn’t been resolved or rejected yet.
  2. Fulfilled: The state when a promise successfully completes, and the resolve function has been called.
  3. Rejected: The state when a promise fails, and the reject function has been called.

Once a promise settles (either fulfilled or rejected), it cannot change its state. This immutability is key to predictable async behavior.

const promise = new Promise<number>((resolve, reject) => {
  setTimeout(() => resolve(42), 1000);
});

// The promise is pending for 1 second, then becomes fulfilled

Understanding these states helps when debugging and handling complex asynchronous flows.

Typing Promises

TypeScript enhances JavaScript promises with static typing. When creating a promise, you can specify the type of value it will resolve to.

// Promise that resolves to a string
const stringPromise: Promise<string> = new Promise((resolve) => {
  resolve("hello");
});

// Promise that resolves to a custom interface
interface User {
  id: number;
  name: string;
}

const userPromise: Promise<User> = new Promise((resolve) => {
  resolve({ id: 1, name: "John" });
});

Type annotations help catch errors when working with promise results.

stringPromise.then((result) => {
  // TypeScript knows 'result' is a string
  console.log(result.toUpperCase());
});

This typing system extends to promise chains and other async patterns, making code more robust.

Using Async/Await Syntax

The async/await syntax provides a cleaner way to work with promises, making asynchronous code look more like synchronous code.

To use async/await, declare a function with the async keyword. Inside this function, use the await keyword to pause execution until a promise resolves.

async function getData(): Promise<User> {
  try {
    const response = await fetch('https://api.example.com/users/1');
    const user: User = await response.json();
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error;
  }
}

Error handling becomes more straightforward with try/catch blocks.

The function marked with async always returns a Promise, even if you return a non-promise value. This consistency simplifies working with asynchronous code.

TypeScript Promise

Check out Get Distinct Values from an Array in TypeScript

Various TypeScript Promise Methods

Now, let me show you some useful promise methods in TypeScript.

Promise.then()

The then() method is used to handle the successful completion of a promise:

myPromise.then((result) => {
  console.log(result); // "Success!"
});

Promise.catch()

The catch() method handles any errors that occur during promise execution:

myPromise.catch((error) => {
  console.error(error); // "Error occurred"
});

Promise.finally()

The finally() method executes code regardless of whether the promise was fulfilled or rejected:

myPromise
  .finally(() => {
    console.log('This runs regardless of success or failure');
  });

Chaining Promises

One of the most powerful features of promises is the ability to chain them together. This allows you to execute multiple asynchronous operations in sequence:

fetchUserData(userId)
  .then(userData => fetchUserPosts(userData.id))
  .then(posts => fetchPostComments(posts[0].id))
  .then(comments => {
    console.log(comments);
  })
  .catch(error => {
    console.error('An error occurred:', error);
  });

This approach creates a clean, readable sequence of operations, where each step depends on the result of the previous one.

Check out Set Default Values in TypeScript Interfaces

Error Handling in TypeScript Promises

Proper error handling is crucial when working with promises. Unhandled promise rejections occur when a promise is rejected, but nothing deals with the rejection. This can lead to silent failures in your application.

Here’s how to properly handle errors:

function processData(data: string): Promise<string> {
  return new Promise((resolve, reject) => {
    if (data) {
      resolve(`Processed: ${data}`);
    } else {
      reject(new Error('No data provided'));
    }
  });
}

// Good practice with error handling
processData('')
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error('Caught an error:', error.message);
    // Handle the error appropriately
  });

Check out Check if a String is Empty in TypeScript

Static Promise Methods in TypeScript

TypeScript supports several static methods for working with promises:

Promise.all()

This method takes an array of promises and returns a new promise that resolves when all promises in the array have resolved:

const promise1 = fetchUser(1);
const promise2 = fetchUser(2);
const promise3 = fetchUser(3);

Promise.all([promise1, promise2, promise3])
  .then(([user1, user2, user3]) => {
    console.log('All users:', user1, user2, user3);
  })
  .catch(error => {
    console.error('At least one request failed:', error);
  });

Promise.race()

This method returns a promise that resolves or rejects as soon as one of the promises in the array resolves or rejects:

const promiseFast = new Promise(resolve => setTimeout(() => resolve('Fast!'), 100));
const promiseSlow = new Promise(resolve => setTimeout(() => resolve('Slow!'), 500));

Promise.race([promiseFast, promiseSlow])
  .then(result => {
    console.log(result); // "Fast!"
  });

Promise.allSettled()

This method returns a promise that resolves after all of the given promises have either resolved or rejected:

const promises = [
  fetch('/api/data1'),
  fetch('/api/data2'),
  fetch('/api/nonexistent')
];

Promise.allSettled(promises)
  .then(results => {
    results.forEach(result => {
      if (result.status === 'fulfilled') {
        console.log('Success:', result.value);
      } else {
        console.log('Failed:', result.reason);
      }
    });
  });

Promise.any()

This newer method returns a promise that resolves as soon as any of the promises in the array resolves:

const promises = [
  fetch('/api/slow-endpoint').then(() => 'slow'),
  fetch('/api/medium-endpoint').then(() => 'medium'),
  fetch('/api/fast-endpoint').then(() => 'fast')
];

Promise.any(promises)
  .then(firstResult => {
    console.log('First to complete:', firstResult);
  })
  .catch(error => {
    console.log('All promises rejected:', error);
  });

Read Typescript Date Format

Implement Your Own Promise in TypeScript

For a deeper understanding, let’s implement a simple promise in TypeScript:

function delay(ms: number): Promise<void> {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

// Usage
console.log('Starting');
delay(2000).then(() => {
  console.log('Two seconds have passed!');
});

Let’s create a more practical example—a function that simulates an API call:

interface User {
  id: number;
  name: string;
  email: string;
}

function fetchUserData(userId: number): Promise<User> {
  return new Promise((resolve, reject) => {
    // Simulate API request delay
    setTimeout(() => {
      // Imagine this is data from an API
      if (userId > 0) {
        resolve({
          id: userId,
          name: 'John Smith',
          email: 'john.smith@example.com'
        });
      } else {
        reject(new Error('Invalid user ID'));
      }
    }, 1000);
  });
}

// Usage
fetchUserData(123)
  .then(user => {
    console.log(`User found: ${user.name}`);
  })
  .catch(error => {
    console.error(error.message);
  });

Check out TypeScript for Loop with Examples

Async/Await with TypeScript Promises

While promises are powerful, TypeScript’s async/await syntax makes working with them even more intuitive. Async/await is built on top of promises and provides a cleaner way to work with asynchronous code:

async function getUserDetails(userId: number): Promise<any> {
  try {
    const user = await fetchUserData(userId);
    const posts = await fetchUserPosts(user.id);
    const comments = await fetchPostComments(posts[0].id);
    
    return {
      user,
      posts,
      comments
    };
  } catch (error) {
    console.error('Error fetching user details:', error);
    throw error;
  }
}

// Usage
getUserDetails(123)
  .then(details => {
    console.log('User details:', details);
  })
  .catch(error => {
    console.error('Failed to get user details:', error);
  });

The key benefits of using async/await include:

  • More readable, sequential-looking code
  • Automatic promise resolution
  • Better error handling with try/catch blocks
  • Easier debugging

Practical Examples with Promises

Let me show you some real-world applications that showcase how promises can make your code more efficient and readable in TypeScript.

Handle Timeouts with Promises

TypeScript allows you to wrap setTimeout in a promise for cleaner timeout handling. Instead of nesting callbacks, you can create a reusable delay function:

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Usage example
async function timedOperation(): Promise<string> {
  console.log("Starting operation...");
  await delay(2000);
  return "Operation completed after 2 seconds";
}

// With timeout protection
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Operation timed out')), ms);
  });
  return Promise.race([promise, timeout]);
}

This approach transforms callback-based setTimeout into promise-based code. The withTimeout function adds protection against operations that take too long.

IntegratePromises with APIs

Promises excel when working with APIs in TypeScript applications. Fetch API naturally returns promises, making HTTP requests cleaner:

interface User {
  id: number;
  name: string;
  email: string;
}

function fetchUsers(): Promise<User[]> {
  return fetch('https://api.example.com/users')
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }
      return response.json() as Promise<User[]>;
    });
}

// Usage with async/await
async function displayUsers() {
  try {
    const users = await fetchUsers();
    users.forEach(user => console.log(user.name));
  } catch (error) {
    console.error("Failed to fetch users:", error);
  }
}

This pattern provides type safety through interfaces while handling network errors gracefully. Promise chaining allows for sequential operations like fetching related data.

Examples of Promise-Based Libraries

Many TypeScript libraries leverage promises for asynchronous operations. Axios, a popular HTTP client, uses promises extensively:

import axios from 'axios';

// GET request
axios.get<User[]>('/users')
  .then(response => response.data)
  .catch(error => console.error(error));

// Multiple concurrent requests
Promise.all([
  axios.get('/users'),
  axios.get('/products')
])
  .then(([usersResponse, productsResponse]) => {
    // Both requests completed successfully
  });

Database libraries like Prisma also use promises:

// Prisma example
const user = await prisma.user.findUnique({
  where: { id: 1 }
});

Testing libraries often provide promise-based assertions for asynchronous code. These libraries make complex asynchronous workflows manageable by providing consistent promise-based APIs.

Check out TypeScript Switch Case Examples

TypeScript Type Declarations with Promises

One of TypeScript’s major advantages is its strong typing system. When working with promises, proper type declarations make your code more maintainable and bug-resistant.

Basic Promise Type Declaration

// Declaring a promise that resolves to a specific type
const numberPromise: Promise<number> = Promise.resolve(42);
const stringPromise: Promise<string> = Promise.resolve("Hello");
const userPromise: Promise<User> = fetchUserData(123);

Function Return Types

// Function that returns a promise
function getData(): Promise<{ id: number; name: string }> {
  return fetch('/api/data')
    .then(response => response.json());
}

// Async function automatically returns a Promise
async function fetchItems(): Promise<Item[]> {
  const response = await fetch('/api/items');
  if (!response.ok) {
    throw new Error('Failed to fetch items');
  }
  return response.json();
}

Type-Safe Promise Handling

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

async function fetchArticles(): Promise<ApiResponse<Article[]>> {
  const response = await fetch('/api/articles');
  const data = await response.json();
  return data as ApiResponse<Article[]>;
}

// Usage with type checking
fetchArticles().then(response => {
  // TypeScript knows that response.data is Article[]
  response.data.forEach(article => {
    console.log(article.title);
  });
});

Advanced Promise Patterns in TypeScript

Now that we’ve covered the basics, let’s explore some advanced patterns that will help you write more sophisticated asynchronous code.

Promise Cancellation

TypeScript doesn’t have built-in promise cancellation, but we can implement it using the AbortController API:

function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
  const controller = new AbortController();
  const { signal } = controller;
  
  // Set up timeout
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  
  return fetch(url, { signal })
    .finally(() => clearTimeout(timeoutId));
}

// Usage
fetchWithTimeout('https://api.example.com/data', 5000)
  .then(response => response.json())
  .then(data => console.log('Data received:', data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Request timed out');
    } else {
      console.error('Request failed:', error);
    }
  });

Retry Pattern

When working with external APIs, implementing a retry mechanism can improve reliability:

async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  retries: number = 3,
  delay: number = 1000
): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (retries <= 0) {
      throw error;
    }
    
    console.log(`Retrying after ${delay}ms, ${retries} attempts left`);
    await new Promise(resolve => setTimeout(resolve, delay));
    
    return fetchWithRetry(fn, retries - 1, delay * 2);
  }
}

// Usage
fetchWithRetry(
  () => fetch('https://api.example.com/data').then(r => r.json()),
  3,  // 3 retries
  1000  // 1 second initial delay with exponential backoff
)
  .then(data => console.log('Success:', data))
  .catch(error => console.error('All retries failed:', error));

Promise Queue for Rate Limiting

When you need to limit the number of concurrent operations (like API calls with rate limits):

class PromiseQueue {
  private queue: Array<() => Promise<any>> = [];
  private concurrentCount = 0;
  
  constructor(private maxConcurrent: number = 5) {}
  
  add<T>(promiseFactory: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push(() => {
        return promiseFactory()
          .then(resolve)
          .catch(reject)
          .finally(() => {
            this.concurrentCount--;
            this.processQueue();
          });
      });
      
      this.processQueue();
    });
  }
  
  private processQueue() {
    if (this.concurrentCount < this.maxConcurrent && this.queue.length > 0) {
      const nextPromise = this.queue.shift();
      if (nextPromise) {
        this.concurrentCount++;
        nextPromise();
      }
    }
  }
}

// Usage
const queue = new PromiseQueue(3); // Only 3 concurrent requests

const urls = ['url1', 'url2', 'url3', 'url4', 'url5', 'url6', 'url7'];

// Queue all requests but only 3 will run at a time
const promises = urls.map(url => {
  return queue.add(() => fetch(url).then(r => r.json()));
});

Promise.all(promises)
  .then(results => console.log('All requests completed:', results));

Read Filter Duplicates from an Array in TypeScript

Best Practices for Using Promises in TypeScript

Working with TypeScript promises requires careful attention to code organization and performance. The following practices will help you write cleaner, more maintainable asynchronous code while avoiding common pitfalls that lead to bugs and performance issues.

Coding Conventions

Always specify the return type of functions that return promises using the Promise<T> syntax. This improves type safety and helps catch errors during compilation.

// Good practice
function fetchData(): Promise<User[]> {
  return fetch('/api/users')
    .then(response => response.json());
}

// Avoid
function fetchData() {
  return fetch('/api/users')
    .then(response => response.json());
}

Chain promises using .then() instead of nesting them. Nested promises create “Promise Hell,” similar to callback hell, making code harder to read and maintain.

Use async/await for cleaner code when working with promises. It makes asynchronous code look more like synchronous code while maintaining the benefits of promises.

// Using async/await
async function getUserData(): Promise<UserData> {
  const response = await fetch('/api/user');
  return response.json();
}

Performance Considerations

Use Promise.all() when executing multiple independent promises. This runs operations in parallel rather than sequentially, significantly improving performance.

// Run multiple requests in parallel
const [users, products] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/products').then(r => r.json())
]);

Avoid creating unnecessary promises. If you already have a value, don’t wrap it in a promise unless needed.

Cache promise results when appropriate to prevent redundant API calls. This reduces network traffic and improves application responsiveness.

Consider streaming responses or pagination for large datasets rather than loading everything at once in a single promise. This prevents memory issues and improves initial load time.

Debugging Promises

Always include error handling with .catch() or try/catch blocks with async/await. Unhandled promise rejections can cause silent failures that are difficult to debug.

// Error handling with async/await
async function fetchData() {
  try {
    const response = await fetch('/api/data');
    return await response.json();
  } catch (error) {
    console.log('Error fetching data:', error);
    return null;
  }
}

Use descriptive error messages when rejecting promises. This makes debugging easier by providing context about what went wrong.

Add logging at key points in promise chains using console.log(). This helps track the flow of asynchronous operations during debugging.

Consider using TypeScript’s strict null checks to avoid dealing with undefined values in promise results. This forces proper error handling throughout the promise chain.

Conclusion

Promises in TypeScript are used to handle asynchronous operations. They provide a structured way to handle asynchronous operations, making code more readable and maintainable.

Promises in TypeScript follow the same pattern as in JavaScript – they represent operations that will complete (or fail) at some point in the future. They can be in one of three states: pending, fulfilled, or rejected.

Proper error handling with promises is essential. Using catch() or try/catch with async/await ensures that applications can gracefully handle failures.

I hope now you have a complete idea of using TypeScript promises.

You may also like:

Power Apps functions free pdf

30 Power Apps Functions

This free guide walks you through the 30 most-used Power Apps functions with real business examples, exact syntax, and results you can see.

Download User registration canvas app

DOWNLOAD USER REGISTRATION POWER APPS CANVAS APP

Download a fully functional Power Apps Canvas App (with Power Automate): User Registration App