Skip to content
Promises and Generic Result Types
step 1/6

Reading — step 1 of 6

Learn

~3 min readAsync TypeScript

TypeScript's type system shines for async code — Promise<T> makes the resolved type visible at every step, catching bugs that JS lets slip.

Typing a Promise

async function fetchUser(id: number): Promise<User> {
    const response = await fetch(`/api/users/${id}`);
    return response.json();   // Type checker doesn't know what shape this is — defaults to any
}

The return type Promise<User> documents the resolved value. Inside the function, callers see the type. async functions ALWAYS return a Promise — even async function f() { return 1; } returns Promise<number>.

Typing fetch responses

The built-in fetch returns Response whose .json() is typed as Promise<any>. To get types, use a generic helper:

async function fetchJson<T>(url: string): Promise<T> {
    const r = await fetch(url);
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.json() as Promise<T>;
}

const user = await fetchJson<User>('/api/users/42');
//    ^ User

This pattern (or libraries like Zod for runtime validation) gives end-to-end types for API calls.

Result types — explicit success/failure

For APIs that can fail in expected ways, model failure as a value rather than an exception:

type Result<T, E = Error> =
    | { ok: true; value: T }
    | { ok: false; error: E };

async function safeFetch<T>(url: string): Promise<Result<T>> {
    try {
        const data = await fetchJson<T>(url);
        return { ok: true, value: data };
    } catch (e) {
        return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
    }
}

const r = await safeFetch<User>('/api/me');
if (r.ok) {
    console.log(r.value.name);   // narrowed to User
} else {
    console.error(r.error.message);   // narrowed to Error
}

The discriminated union (ok: true | false) lets the compiler narrow inside each branch — no casts needed.

Promise.all and tuple types

const [user, posts]: [User, Post[]] = await Promise.all([
    fetchJson<User>('/api/users/42'),
    fetchJson<Post[]>('/api/users/42/posts'),
]);

TypeScript infers the tuple type from the array of differently-typed Promises. Each parallel call's resolved type is preserved.

Promise.allSettled — every outcome

type Settled<T> = { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown };

const results = await Promise.allSettled([
    fetchJson<User>('/api/a'),
    fetchJson<User>('/api/b'),
]);
// results: PromiseSettledResult<User>[]

for (const r of results) {
    if (r.status === 'fulfilled') {
        console.log(r.value.name);
    }
}

Useful when you want every result regardless of failure — analytics, batch jobs, etc.

Awaiting in loops vs Promise.all

// SLOW — sequential
for (const id of ids) {
    const user = await fetchUser(id);
    console.log(user);
}

// FAST — parallel
const users = await Promise.all(ids.map(fetchUser));
for (const user of users) console.log(user);

Same trap as in plain JS — if items don't depend on each other, kick them all off, then await.

Common mistakes

  • Forgetting that async always returns a Promise — TypeScript's type narrows it for you.
  • Awaiting in a loop when independent items could run in parallel — silently slow.
  • Catching errors as any — modern TS treats catch (e) as unknown. Narrow with instanceof Error or use a type guard.
  • Using .then().catch() chains in TS — async/await is cleaner and types better.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…