AniUI Academy

Typing Asynchronous Code

How Promise<T> and async functions are typed, why a caught error is unknown, and typing an API response honestly instead of trusting fetch's built-in any.

10 min read

Asynchronous code — promises and async/await — is one of the places where an unchecked assumption is easiest to make and hardest to notice, because the wrong shape often doesn't surface until well after the network request that produced it. This lesson ties together Promise<T> (from the generics lessons) with unknown (from several lessons back) to type it properly.

Promise<T>, and what async actually returns

An async function's return type is always wrapped in Promise, regardless of what the function body itself returns:

interface User {
  name: string;
}
 
async function getUser(): Promise<User> {
  return { name: "Amara" }; // a plain object — the "Promise" wrapping is automatic
}

You could write the return statement as return Promise.resolve({ name: "Amara" }) and it would behave identically — async does that wrapping for you. TypeScript's inference reflects the same rule: even with no explicit return type annotation, an async function's inferred return type is always a Promise of whatever the body returns.

await unwraps the Promise, in the type system too

await is the runtime operation that pauses until a promise settles and gives back its resolved value. The type system mirrors this exactly:

async function printUser() {
  const user = await getUser(); // user: User, not Promise<User>
  console.log(user.name);
}

This is the same unwrapping Awaited<T> performs mechanically at the type level, covered in the utility types lessons — await is doing it for real, at runtime, and the compiler tracks the type through it correctly.

Handling rejection: why a caught error is unknown

JavaScript's throw accepts literally any value — not just Error instances, though throwing anything else is bad practice. Because of that, under modern strict settings (useUnknownInCatchVariable, part of the strict family covered last lesson), a caught error is typed unknown, not Error or any:

async function safeGetUser(): Promise<User | null> {
  try {
    return await getUser();
  } catch (error) {
    if (error instanceof Error) {
      console.error("Failed to get user:", error.message);
    } else {
      console.error("Something non-standard was thrown:", error);
    }
    return null;
  }
}

This is exactly the instanceof narrowing pattern from the narrowing lesson, applied to the one place it matters most: you cannot safely assume error.message exists without checking first, because nothing in the language guarantees it.

Handling fetch honestly

fetch's .json() method is typed Promise<any> in TypeScript's own DOM library definitions — there's no way for the type system to know what shape a network response actually has. Trusting it silently reintroduces exactly the problem any causes everywhere else:

async function getUserUnsafe(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json(); // data: any — nothing checks this
  return data.naem; // typo — compiles fine, "any" catches nothing
}

Treating the response as unknown and validating it with a type guard (covered in the unknown/any lesson) closes exactly this gap:

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    typeof (value as User).name === "string"
  );
}
 
async function getUserSafe(id: string): Promise<User | null> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();
  return isUser(data) ? data : null;
}

The extra step is real work, and it's tempting to skip for a "quick" fetch — but it's the difference between a shape mismatch surfacing as a compile error on data.naem immediately, versus surfacing as undefined somewhere downstream, possibly in production, long after the request that actually caused it.

Promise.all, typed

Promise.all preserves each promise's individual resolved type in the resulting array, as a tuple when given a fixed-length array literal:

async function loadDashboard() {
  const [user, posts] = await Promise.all([
    getUser(),          // Promise<User>
    fetch("/api/posts").then((r) => r.json() as Promise<{ title: string }[]>),
  ]);
 
  console.log(user.name, posts.length);
}

Each element keeps its own specific resolved type rather than collapsing to a union or to anyuser is User, posts is the annotated array type, exactly matching what each individual promise resolves to.

Try it yourself

Try it yourself
Loading playground...

What to remember

  • An async function's return type is always wrapped in Promise<T>; await unwraps it back to T, both at runtime and in the type system.
  • Under strict mode, a caught error is typed unknown, since JavaScript allows throwing any value — narrow with instanceof Error before using it.
  • response.json() is typed any by TypeScript's own DOM definitions — treat it as unknown and validate with a type guard rather than trusting it.
  • Promise.all on an array literal preserves each promise's own resolved type in the result, rather than collapsing them together.

Check yourself

4 questions · pass 3/4 to unlock Migrating JavaScript to TypeScript

up to 50
  1. 1.What is the return type of async function getUser(): Promise<User> { return { name: "Amara" }; }?

  2. 2.Inside an async function, what type does await somePromise produce, given somePromise: Promise<User>?

  3. 3.Under a modern strict tsconfig, what is the type of error in try { ... } catch (error) { ... }?

  4. 4.What is a realistic risk of writing const data = await response.json(); with no further type annotation or validation?

4 left to answer