Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~20 min· topic 3 of 6

Topic 13.3

Promises and async/await

In one line

A Promise<T> is a value that will arrive later. TypeScript tracks what it will hold, turns every async function's return into a Promise, unwraps with await and Awaited, and treats a caught error as unknown.

Think of it like this

A restaurant buzzer. You order, they give you a buzzer, and you go and sit down. The buzzer is not your food, it is a promise of food. Later it either lights up (your meal is ready) or flashes red (the kitchen ran out). You can't eat a buzzer. TypeScript's Promise<Meal> is that buzzer: it tells you what will come out, and it stops you from eating the buzzer.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Promise
An object standing for a result that will arrive later, or fail.
Pending / fulfilled / rejected
The three states: still waiting, finished with a value, finished with an error.
async function
A function that always returns a promise and may use await inside.
await
Pause until a promise settles, then give its value (or throw its error).
Awaited<T>
The type you get after awaiting T, unwrapping promises all the way down.
Promise.all
Waits for every promise. Gives an array of results in order, or fails as soon as one fails.
Promise.allSettled
Waits for every promise and reports each outcome, never rejecting itself.
unknown
The type of a value you know nothing about yet. You must check it before use.
useUnknownInCatchVariables
A strict flag (TypeScript 4.4) that types the variable in catch as unknown instead of any.

Step by step

01async means: returns a promise

This function returns an object, but TypeScript says it returns Promise<{ id: number; name: string }>. The async keyword wraps the result.

That is why getUser(1).name is an error: a promise has no name. The name is inside the promise.

index.tswhole filets
async function getUser(id: number) {
  return { id, name: "Asha" };
}
// getUser: (id: number) => Promise<{ id: number; name: string }>

02await unwraps one layer

await gives the T inside Promise<T>. Do it inside an async function, or at the top of a module (all examples here are modules, so top-level await works).

index.tswhole filets
const user = await getUser(1);
console.log(user.name); // Asha

03Promise.all keeps each type

A fixed list in gives a tuple out. The first slot is the first promise's type, and so on. That is why you can destructure it and still get exact types.

Promise.all keeps each typediagram
Rendering diagram…

04Catching: the error is unknown

Anything can be thrown, so err is unknown. Check it. err instanceof Error is the usual check (Topic 4.4).

index.tswhole filets
try {
  await risky();
} catch (err) {
  if (err instanceof Error) {
    console.log(err.message);
  } else {
    console.log("not an Error:", err);
  }
}

05The forgotten await

A promise is an object, so it is always truthy. if (isAdmin(2)) is true for every user. TypeScript reports this one as TS2801.

Try it yourself

  1. 1

    Predict the tuple

    In the second example, add a fourth promise later([1, 2], 5) to the list. Predict the type of results first, then check. Then change Promise.all to Promise.race and see what the type of the result becomes: one promise settles first, so you get the union string | number | boolean | number[], not a tuple.

  2. 2

    Throw something else

    In the third example, change throw new Error(...) to throw 42 inside risky. Which branch runs? Is there any type error? (No: TypeScript can't know what a function throws.)

  3. 3

    Fix the admin check

    In the last example, change if (isAdmin(2)) to if (await isAdmin(2)). The error goes away and the wrong message stops printing.

Code & diagrams

async functions return promises

getUser returns an object, yet the type says Promise of that object. pending is the buzzer, user is the food.

Console

(nothing was printed)

Expected console output

true
Asha
Promise.all gives a tuple, Awaited unwraps

Results come back in the order of the list, not the order they finish (the string finishes first). allSettled narrows on status.

Console

(nothing was printed)

Expected console output

2 TWO false
fulfilled ok
rejected boom
A caught error is unknown

JavaScript lets you throw a string, so TypeScript won't let you call err.message until you have checked. Both branches run in this example.

Console

(nothing was printed)

Expected console output

Error: network down
not an Error: just a string
Without the flag, err is any (and a crash waits) TypeScript 4.4+

This example turns useUnknownInCatchVariables off (strict also turns it on). The checker is silent, and the thrown string has no message.

Console

(nothing was printed)

Expected console output

Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The forgotten await

User 2 is not an admin, yet the first if lets them in: a promise is always truthy. The compiler warns on the first if and not on the one with await.

Console

(nothing was printed)

Expected console output

welcome, admin!

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget await

In the first example, change const user = await pending; to const user = pending;.

terminal
$ Types panel
── what you'll see ──
index.ts(17,18): error TS2339: Property 'name' does not exist on type 'Promise<{ id: number; name: string; }>'.

Myth vs fact

Myth

An async function returns the value I return.

Fact

It returns a promise of it. Its declared return type must be Promise<...>.

Myth

Promise.all returns an array of the union of the types.

Fact

For a fixed list it returns a tuple with one type per position.

Myth

catch (err) always gives an Error.

Fact

You can throw anything, and the type is unknown for that reason. Libraries also throw strings and plain objects.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Awaited<T> is defined in lib.es5.d.ts. The built-in Promise.all typings use a mapped tuple over Awaited, which is why they preserve each position: { -readonly [P in keyof T]: Awaited<T[P]> }. You'll write types like that in Phase 8.

  • ▸

    TypeScript cannot track what a function throws. There is no throws clause in the type, so error types are documentation only. This is why Topic 13.5 shows a Result<T, E> type, which puts failure in the return type where the checker can see it.

  • ▸

    A function returning Promise<void> can be passed where a () => void callback is expected, and its rejection then goes nowhere. The same goes for async callbacks given to array.forEach. Use for...of with await, or Promise.all(array.map(...)).

Remember this

  1. 1

    The problem this solves: code that waits for something (a network call, a timer) is easy to misuse, for example by using the waiting object as if it were the result. TypeScript tracks what a promise will hold and where you still have to wait for it. A promise is JavaScript's object for a result that isn't ready yet: a network request, a timer, a file read. It is in one of three states: pending (waiting), fulfilled (finished with a value) or rejected (failed with a reason). In TypeScript the type is **Promise<T>**, where T is the type of the value if it succeeds. Promise<string> will give you text; Promise<void> will give you nothing, only the news that it finished.

  2. 2

    You get the value out with await, inside an async function (or at the top level of a module). await promise pauses that function until the promise settles, then gives you the T. If it was rejected, await throws the reason instead. The types follow exactly: await on a Promise<number> is a number; await on a plain number is just that number.

  3. 3

    An **async function always returns a promise**, even if you write return 5. TypeScript knows this: async function f() { return 5; } has the return type Promise<number>. If you write the return type yourself it must be a promise (Promise<number>), never plain number. This is the number-one thing beginners trip on: calling an async function **without await gives you the buzzer, not the food**.

  4. 4

    **Awaited<T>** is a built-in helper (Topic 10.2) that answers "what do I get after awaiting T?" It unwraps a promise, and keeps unwrapping if the promise holds another promise. Awaited<Promise<string>> is string; Awaited<string> is also string. It is the right way to get a function's result type: Awaited<ReturnType<typeof getUser>>. TypeScript uses it internally for await and Promise.all.

  5. 5

    **Promise.all takes several promises and gives back one promise of an array of results, in the same order. TypeScript is clever here: when you pass a fixed list, the result is a tuple** (Topic 5.2), one type per position, so Promise.all([fetchUser(), fetchCount(), fetchOk()]) gives [User, number, boolean], not (User | number | boolean)[]. If any promise rejects, Promise.all rejects immediately. Promise.allSettled never rejects: it gives one result object per promise, with status: "fulfilled" and a value, or status: "rejected" and a reason. That is a discriminated union (Topic 4.5), so you narrow on status.

  6. 6

    **Errors in catch are unknown.** In JavaScript you can throw anything: an Error, a string, a number, even undefined. So the variable in catch (err) can't be safely assumed to be an Error. Since TypeScript 4.4 the flag useUnknownInCatchVariables (part of strict) types it as unknown, which forces you to check (err instanceof Error) before you read err.message. Before that flag it was any, which allowed err.message and crashed when the thrown value was a string. A rejected promise's reason is the same: when you await, it arrives in catch as unknown.

  7. 7

    Two real bugs are worth naming. A **forgotten await** (calling isAdmin(id) and using the promise as if it were the answer: a promise is always truthy). TypeScript catches some of these (if (promise) is error TS2801), and the lint rule no-floating-promises catches the rest. And unhandled rejection: a promise that fails with nobody listening crashes Node and logs a console error in browsers.

Explain it without notes

01

What type does an async function return, and why?

02

What does Awaited<T> do, and where is it useful?

03

Why does Promise.all([a, b]) have a tuple type, and how is Promise.allSettled different?

04

Why is the variable in catch typed unknown, and how do you use it safely?

Practice

01

Write async function loadAll(): Promise<[number, string]> using Promise.all on two helper promises and returning their results.

02

Write function errorMessage(err: unknown): string that returns err.message for an Error, the text for a string, and "unknown error" otherwise. Use it in a catch.

Trade-offs

  • ↔

    unknown in catch costs a check on every catch, and gives you code that doesn't crash on a thrown string. If you only call your own code that always throws Error, a tiny helper like errorMessage removes the repetition.

  • ↔

    Promise.all is fast but all-or-nothing; Promise.allSettled gives every outcome but you must handle each one.

Done when you can

  • I can explain why an async function's type is Promise<T>.

  • I can use await, Awaited and Promise.all and say what type each gives.

  • I can narrow Promise.allSettled results on status.

  • I can handle a caught unknown safely.

  • I can spot a forgotten await and say what the compiler and a linter each catch.