← thecodex.expert · The Codex Family of Knowledge
TypeScript

Typing Async Code

An async function that looks like it returns a User actually returns a Promise<User> — TypeScript adds that wrapper automatically, whether you write it explicitly or not.

TypeScript 6.0 Promise<T> Last verified:
Where this bites you

forEach doesn't know or care that its callback is async. It calls the callback once per item, throws away each returned Promise, and returns immediately. Code that does items.forEach(async item => await save(item)) and then announces it's done has finished starting the saves, not finishing them, and TypeScript types the whole thing as perfectly valid.

TypeScriptforeach_async_bug.ts
const saved: string[] = [];

async function save(item: string): Promise<void> {
    await new Promise((resolve) => setTimeout(resolve, 50));
    saved.push(item);
}

async function saveAll(items: string[]): Promise<void> {
    items.forEach(async (item) => {
        await save(item);
    });
    console.log("saveAll finished, saved:", saved.length);
}

await saveAll(["a", "b", "c"]);
setTimeout(() => console.log("100ms later, saved:", saved.length), 100);
// saveAll finished, saved: 0
// 100ms later, saved: 3
Canonical Definition

Any function marked async automatically returns a Promise wrapping its stated or inferred return type. If you annotate the return type explicitly, it must already be written as Promise<T> — async function f(): Promise<string> — because TypeScript treats async function f(): string as a compile error (TS1064: "the return type of an async function or method must be the global Promise<T> type"), not a shorthand for the same thing. Leaving the annotation off entirely is where the automatic wrapping shows up: TypeScript infers the Promise from the function's return statements on its own. Inside another async function, await unwraps that Promise back to its inner type, and TypeScript tracks this transformation precisely at every step, catching an accidental double-wrap or a missing await.

async functions always return a Promise

Writing the return type as Promise<User> or leaving the annotation off entirely produces the identical compiled behavior — TypeScript infers the Promise<User> wrapper automatically for any async function whose return type isn't annotated. What you can't do is annotate it as bare User; unlike a plain function, an async function's explicit return-type annotation must itself already be a Promise.

TypeScriptasync_basics.ts
interface User { name: string; }

async function fetchUser(): Promise<User> {
    const response = await fetch("/api/user");
    return response.json();   // TypeScript trusts json()'s declared type here
}

async function caller() {
    const user = await fetchUser();   // awaited: user is User, not Promise<User>
    console.log(user.name);
}

Typing Promise.all and concurrent operations

Promise.all's return type is inferred as a tuple matching each input promise's resolved type — TypeScript tracks each position individually, even when the promises resolve to different types.

TypeScriptpromise_all.ts
async function fetchUser(): Promise<{ name: string }> { /* ... */ return { name: "Alice" }; }
async function fetchPosts(): Promise<string[]> { /* ... */ return ["post1"]; }

async function loadDashboard() {
    const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
    // user: { name: string }, posts: string[] — each correctly typed, not "any"
}

The fix is the Promise.all pattern from the section above: map keeps every Promise instead of discarding it, and awaiting the combined Promise really waits for all of them (a for...of loop with await inside works too, one item at a time). A rejection from any save now also reaches saveAll's caller instead of becoming an unhandled rejection:

TypeScriptforeach_async_fix.ts
async function saveAll(items: string[]): Promise<void> {
    await Promise.all(items.map((item) => save(item)));
    console.log("saveAll finished, saved:", saved.length);
}

await saveAll(["a", "b", "c"]);
// saveAll finished, saved: 3

Errors: catch variables are unknown, not any

Since TypeScript 4.4, a caught error defaults to unknown rather than any under strict settings — forcing an explicit type check before accessing any property on it, since JavaScript allows throwing literally any value, not just Error objects.

TypeScriptasync_errors.ts
async function loadData() {
    try {
        return await fetchUser();
    } catch (err) {
        // err is unknown — must narrow before use
        if (err instanceof Error) {
            console.error(err.message);
        }
        throw err;
    }
}

Sources

1
Microsoft. TypeScript Handbook, "Async/Await" and TSConfig "useUnknownInCatchVariables", typescriptlang.org/docs/handbook.