Lesson 24 of 25
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.
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 any — user is User, posts is the annotated array type,
exactly matching what each individual promise resolves to.
Try it yourself
What to remember
- An async function's return type is always wrapped in
Promise<T>;awaitunwraps it back toT, both at runtime and in the type system. - Under strict mode, a caught error is typed
unknown, since JavaScript allows throwing any value — narrow withinstanceof Errorbefore using it. response.json()is typedanyby TypeScript's own DOM definitions — treat it asunknownand validate with a type guard rather than trusting it.Promise.allon 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
1.What is the return type of
async function getUser(): Promise<User> { return { name: "Amara" }; }?2.Inside an async function, what type does
await somePromiseproduce, givensomePromise: Promise<User>?3.Under a modern strict tsconfig, what is the type of
errorintry { ... } catch (error) { ... }?4.What is a realistic risk of writing
const data = await response.json();with no further type annotation or validation?
4 left to answer