keepcalculator

06 Numbers and Powers

How Promises Work in TypeScript: A Practical Guide

Card No. 06·53860 · Updated · Standard formula, shown below

Promises in TypeScript are objects representing the eventual completion or failure of an asynchronous operation, and they work exactly as they do in JavaScript, with the added benefit of static type checking on the resolved values and errors. You create a promise with new Promise<T>(executor), where T is the type of the value the promise will resolve to, and you consume it with await or .then(). This guide explains the mechanics, typing patterns, error handling, and common pitfalls you’ll encounter when working with promises in TypeScript.

Background: What Problem Do Promises Solve?

JavaScript (and therefore TypeScript) runs on a single-threaded event loop. Long-running operations like network requests, file reads, or timers would block the entire program if executed synchronously. Before promises, developers used callbacks, which quickly led to deeply nested code known as “callback hell” and made error handling inconsistent. Promises provide a standardized way to chain asynchronous operations, propagate errors, and avoid the pyramid of doom. TypeScript builds on this by letting you specify what type of data a promise will eventually deliver, so the compiler can catch mismatches before runtime.

How Promises Work Under the Hood

A promise is a state machine with three states: pending, fulfilled, or rejected. Once a promise settles (fulfilled or rejected), it cannot change state again. The executor function you pass to the constructor runs immediately and synchronously, but the resolution or rejection is typically asynchronous. The resolve and reject callbacks are provided by the promise implementation; you call them to settle the promise.

In TypeScript, the generic parameter T in Promise<T> represents the type of the resolved value. If you omit it, TypeScript infers unknown, which forces you to narrow the type before using the value. For example:

const p = new Promise<string>((resolve, reject) => {
  setTimeout(() => resolve("done"), 1000);
});
const result: string = await p; // result is typed as string

When you await a promise, the function containing await must be marked async. The await expression suspends the function without blocking the event loop, and the resolved value is unwrapped. If the promise rejects, the await throws the rejection reason, which you can catch with try/catch.

Typing Promises: Generics and Inference

TypeScript’s type system gives you several ways to work with promise types. The most common is Promise<T> as a return type for async functions. An async function always returns a promise, even if you don’t explicitly create one. The compiler infers the resolved type from the returned value:

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/users/${id}`);
  return res.json(); // must be of type User
}

You can also use the Awaited<T> utility type to extract the resolved type from a promise type. This is useful when you have a union of promises or when you’re writing generic helpers:

type Result = Awaited<Promise<number>>; // number

For function types, you can declare a callback that returns a promise: (value: T) => Promise<U>. This is common in array methods like map and filter when you want to process items asynchronously. However, note that Array.map does not await promises; it returns an array of promises. You would need Promise.all to wait for all of them.

Consuming Promises: then, catch, finally, and async/await

There are two main ways to consume a promise: the older .then()/.catch() chain and the newer async/await syntax. Both are fully supported in TypeScript, and the choice is largely stylistic, though async/await tends to be more readable for complex flows.

With .then(), the callback receives the resolved value and must return either a value or another promise. If it returns a value, it’s wrapped in a promise automatically. The .catch() method handles rejections, and .finally() runs regardless of the outcome, often for cleanup. TypeScript infers the types through the chain:

fetchUser(1)
  .then(user => user.name)
  .then(name => console.log(name.toUpperCase()))
  .catch(err => console.error(err));

With async/await, you write synchronous-looking code. The compiler transforms it into promise chains under the hood. Error handling uses try/catch, which can be more familiar to developers coming from synchronous languages. A common pattern is to wrap multiple awaits in a single try block:

async function loadData() {
  try {
    const user = await fetchUser(1);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (err) {
    console.error("Failed to load data", err);
    throw err;
  }
}

How to Choose Between then and async/await

There is no right answer for every situation, but here are some guidelines:

  • Use async/await when you have sequential steps that depend on each other, or when you want to avoid nesting. It also makes debugging easier because stack traces are more linear.
  • Use .then() when you have a simple one-off transformation or when you want to handle success and error in a more functional style. It can be more concise for short chains.
  • For parallel operations, use Promise.all or Promise.allSettled regardless of the consumption style. These take an array of promises and return a single promise.

One important caveat: async/await inside loops can be a performance trap. If you await inside a for loop, each iteration waits for the previous one, which is often slower than running them in parallel with Promise.all. Use Promise.all when operations are independent.

Common Problems and Pitfalls

Several issues trip up developers new to promises in TypeScript:

  • Forgetting to await: If you forget await, you get a Promise<T> instead of T. TypeScript will often warn you if you try to use it as a value, but not always. Always check that you’re awaiting.
  • Unhandled promise rejections: If a promise rejects and you don’t catch it, the error may go silently (or crash in Node.js). Always add a .catch() or wrap in try/catch.
  • Type mismatches: When you use fetch, the res.json() returns Promise<any>, which defeats type safety. Cast it to your expected type: await res.json() as User.
  • Promise.all fails fast: If any promise in Promise.all rejects, the whole thing rejects immediately, even if others would have succeeded. Use Promise.allSettled if you need all results regardless of failures.
  • Returning a promise in a non-async function: If you return a promise from a function that isn’t marked async, TypeScript will infer the return type as Promise<T>, which is fine, but you must be consistent.

Error Handling and Type Safety

Error handling in promises is straightforward but requires care. In TypeScript, the catch callback receives an any by default (or unknown if you enable useUnknownInCatchVariables in strict mode). You should narrow the type before using it:

try {
  await riskyOperation();
} catch (err) {
  if (err instanceof Error) {
    console.log(err.message);
  } else {
    console.log("Unknown error", err);
  }
}

For custom error types, you can define a class that extends Error and use instanceof to handle different cases. TypeScript also allows you to specify the rejection type in the executor: new Promise<string, MyError>((resolve, reject) => {}), but note that this is not widely used and the rejection type is not enforced by the promise methods; it’s more of a documentation aid.

Alternatives to Promises

While promises are the standard, there are alternatives you might encounter:

  • Callbacks: The original pattern, still used in some APIs. TypeScript can type them, but they don’t compose well.
  • Observables: From libraries like RxJS, they handle streams of values over time, not just a single future value. They are more powerful for complex event handling but have a steeper learning curve.
  • Async iterators: For handling sequences of asynchronous values, like reading a file line by line. They use for await...of syntax.

For most use cases, promises are the right choice because they are built into the language, have excellent TypeScript support, and are easy to reason about. Observables are only needed when you have multiple events over time or need to cancel operations.

Frequently Asked Questions

What is the difference between Promise<void> and Promise<undefined>?

Promise<void> means the promise resolves with no value (you can’t use the result), while Promise<undefined> means it resolves with the value undefined. In practice, they are similar, but void is more explicit that the value is not meant to be used. When you call resolve() without arguments, the promise resolves to undefined, so TypeScript infers Promise<void> for async functions that return nothing.

How do I type a function that returns a promise or a value?

You can use a union type: Promise<T> | T. This is common for functions that might return a cached value synchronously or fetch it asynchronously. However, this forces the caller to handle both cases. A cleaner approach is to always return a promise and use async even for synchronous returns, which wraps the value in a resolved promise.

Can I cancel a promise?

No, promises are not cancellable by design. Once created, they will run to completion. If you need cancellation, you can use an AbortController for fetch requests, or switch to observables which have cancellation built in. A common pattern is to use a flag to ignore the result after cancellation, but the underlying operation continues.

Why does TypeScript complain that ‘Promise<any>’ is not assignable to ‘number’?

This happens when you forget to await a promise. The variable you assigned is a promise, not the resolved value. Add await in front of the expression, or make the function async and use await. If you’re using a library that returns a promise but you expect a value, check the type definitions to confirm.

What is the best way to handle multiple promises with different types?

Use Promise.all with a tuple type. TypeScript will infer the resulting array as a tuple of the resolved types. For example, const [user, posts] = await Promise.all([fetchUser(1), fetchPosts(1)]) gives you user: User and posts: Post[] correctly.

Is there a performance difference between .then and async/await?

No significant difference in modern JavaScript engines. Both are compiled to similar promise chains. The choice should be based on readability and maintainability. Some argue that async/await is slightly slower in hot loops due to the extra function wrapping, but in practice it’s negligible.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →