Chapter 15

async/Promise Types: Inference and Error Typing

Promise<T> Basics

async functions always return Promise<T>, where T is the type of the final return value. TypeScript infers T automatically, but understanding the inference rules prevents surprises.

// TypeScript infers the return type of async functions
async function getUsername(id: string): Promise<string> {
  const user = await db.users.findById(id);
  return user.name; // string โ†’ Promise<string>
}

// Without an annotation โ€” inference still works
async function getCount() {
  return 42; // inferred as Promise<number>
}

// A trap to watch for:
async function risky() {
  if (Math.random() > 0.5) return "string";
  return 42;
}
// Inferred as Promise<string | number> โ€” likely unintended
// Better: async function risky(): Promise<string | number>

Awaited<T>: Unwrapping Nested Promises

Introduced in TypeScript 4.5, Awaited<T> recursively unwraps Promise wrappers. It is essential when writing generic async utilities.

type A = Awaited<Promise<string>>;           // string
type B = Awaited<Promise<Promise<number>>>;  // number (recursive unwrap)
type C = Awaited<string>;                    // string (non-Promise passthrough)

// Practical: extract the resolved type of an async function
async function fetchUser(): Promise<User> { ... }

type FetchResult = Awaited<ReturnType<typeof fetchUser>>; // User, not Promise<User>

// Generic utility โ€” T inferred correctly
async function withTimeout<T>(
  promise: Promise<T>,
  ms: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

const user = await withTimeout(fetchUser(), 5000); // user: User, not User | never

Promise.all: Tuple Inference vs Array Inference

Promise.all showcases TypeScript's overload-based inference โ€” pass a tuple, get a tuple; pass an array, get an array.

// Tuple input: each position has an exact type
const [user, posts, comments] = await Promise.all([
  getUser(userId),      // Promise<User>
  getPosts(userId),     // Promise<Post[]>
  getComments(userId),  // Promise<Comment[]>
]);
// user: User
// posts: Post[]
// comments: Comment[]

// This works because Promise.all has a tuple overload:
// all<T extends readonly unknown[]>(
//   values: [...{ [K in keyof T]: PromiseLike<T[K]> }]
// ): Promise<T>
// Array input: all elements typed as union
const promises: Promise<string>[] = [fetch1(), fetch2(), fetch3()];
const results = await Promise.all(promises);
// results: string[]

// The array-inference trap:
const mixed = [getUser(id), getPosts(id)];
// TypeScript infers: (Promise<User> | Promise<Post[]>)[]
const result = await Promise.all(mixed);
// result: (User | Post[])[] โ€” positional types lost!

// Fix: use as const to preserve the tuple type
const fixed = [getUser(id), getPosts(id)] as const;
const [u, p] = await Promise.all(fixed);
// u: User, p: Post[] โ€” correct

Promise.allSettled and PromiseSettledResult<T>

allSettled waits for all promises regardless of outcome, returning each one's status.

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

// Use case: parallel requests where partial failure is acceptable
const results = await Promise.allSettled([
  fetchUserProfile(id),    // Promise<UserProfile>
  fetchUserPosts(id),      // Promise<Post[]>
  fetchUserFollowers(id),  // Promise<User[]>
]);
// results: PromiseSettledResult<UserProfile | Post[] | User[]>[]
// (array input โ†’ union type โ€” positional types lost)

// With as const to preserve positional types:
const [profileResult, postsResult, followersResult] = await Promise.allSettled([
  fetchUserProfile(id),
  fetchUserPosts(id),
  fetchUserFollowers(id),
] as const);

// profileResult:   PromiseSettledResult<UserProfile>
// postsResult:     PromiseSettledResult<Post[]>
// followersResult: PromiseSettledResult<User[]>

// Filter to fulfilled results with a type predicate
const fulfilled = results.filter(
  (r): r is PromiseFulfilledResult<UserProfile | Post[] | User[]> =>
    r.status === "fulfilled"
);

Promise.race and Promise.any

// Promise.race: first to settle (success or failure) wins
async function fetchWithFallback(
  primary: Promise<User>,
  fallback: Promise<User>
): Promise<User> {
  return Promise.race([primary, fallback]); // Promise<User>
}

// race with different types โ€” result is the union
const raceResult = await Promise.race([
  getUser(id),   // Promise<User>
  getAdmin(id),  // Promise<Admin>
]);
// raceResult: User | Admin

// Promise.any (ES2021): first to succeed wins
// If all fail, throws AggregateError
async function tryMultipleSources(id: string): Promise<User> {
  return Promise.any([
    primaryDb.getUser(id),
    replicaDb.getUser(id),
    cacheDb.getUser(id),
  ]);
}

Async Generators: AsyncGenerator<T, TReturn, TNext>

Async generators combine asynchrony and iteration. The type has three parameters.

// AsyncGenerator<T, TReturn, TNext>
// T       โ€” type of yielded values
// TReturn โ€” type of the return value (default void)
// TNext   โ€” type passed into next() (default unknown)

async function* paginate<T>(
  fetchPage: (cursor: string | null) => Promise<{ data: T[]; nextCursor: string | null }>
): AsyncGenerator<T, void, unknown> {
  let cursor: string | null = null;

  while (true) {
    const { data, nextCursor } = await fetchPage(cursor);
    for (const item of data) {
      yield item; // yield type is T
    }
    if (!nextCursor) break;
    cursor = nextCursor;
  }
}

// Usage: for-await-of infers the element type automatically
async function processAllPosts(userId: string) {
  const generator = paginate<Post>(cursor =>
    fetch(`/api/posts?user=${userId}&cursor=${cursor ?? ""}`).then(r => r.json())
  );

  for await (const post of generator) {
    // post: Post โ€” correctly inferred
    await processPost(post);
  }
}

The Async Error Typing Problem

catch clauses are always unknown โ€” by design, because anything can be thrown.

// Anything is throwable
async function riskyOperation(): Promise<void> {
  throw "string error";   // string
  // throw 42;
  // throw { code: 404 };
  // throw new Error("standard");
}

async function main() {
  try {
    await riskyOperation();
  } catch (e) {
    // e: unknown โ€” TypeScript is correct, you genuinely don't know
    e.message; // Compile error: Object is of type 'unknown'

    // Must narrow before use
    if (e instanceof Error) {
      console.error(e.message); // string
    } else if (typeof e === "string") {
      console.error(e);
    }
  }
}

Pattern 1: Typed Error Wrapper

Define an error class hierarchy; use instanceof for narrowing.

class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string
  ) {
    super(message);
    this.name = "AppError";
  }
}

class NetworkError extends AppError {
  constructor(
    message: string,
    public readonly statusCode: number
  ) {
    super(message, "NETWORK_ERROR");
    this.name = "NetworkError";
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`, "NOT_FOUND");
    this.name = "NotFoundError";
  }
}

class ValidationError extends AppError {
  constructor(
    message: string,
    public readonly field: string
  ) {
    super(message, "VALIDATION_ERROR");
    this.name = "ValidationError";
  }
}

// Usage: instanceof dispatch after catch
async function handleRequest(id: string) {
  try {
    return await fetchUser(id);
  } catch (e) {
    if (e instanceof NetworkError) {
      if (e.statusCode === 401) return redirectToLogin();
      if (e.statusCode === 404) return show404();
      throw e; // other network errors bubble up
    }
    if (e instanceof ValidationError) {
      return showFieldError(e.field, e.message);
    }
    throw e; // unknown errors: re-throw
  }
}

Pattern 2: async + Result<T, E> Combined

Combining the Result pattern from Chapter 14 with async functions eliminates hidden exceptions.

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
const ok  = <T>(value: T): Result<T, never> => ({ ok: true,  value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

// Typed API error union
type ApiError =
  | { code: "NETWORK";     status: number }
  | { code: "NOT_FOUND";   resource: string }
  | { code: "UNAUTHORIZED" }
  | { code: "RATE_LIMITED"; retryAfter: number };

async function safeGet<T>(url: string): Promise<Result<T, ApiError>> {
  try {
    const res = await fetch(url);

    if (res.status === 401) return err({ code: "UNAUTHORIZED" });
    if (res.status === 404) return err({ code: "NOT_FOUND", resource: url });
    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
      return err({ code: "RATE_LIMITED", retryAfter });
    }
    if (!res.ok) return err({ code: "NETWORK", status: res.status });

    return ok(await res.json() as T);
  } catch (e) {
    return err({ code: "NETWORK", status: 0 }); // complete network failure
  }
}

// Parallel requests with typed error handling
async function loadDashboard(userId: string) {
  const [userResult, postsResult, analyticsResult] = await Promise.all([
    safeGet<User>(`/api/users/${userId}`),
    safeGet<Post[]>(`/api/posts?author=${userId}`),
    safeGet<Analytics>(`/api/analytics/${userId}`),
  ]);

  if (!userResult.ok) {
    // userResult.error: ApiError โ€” full type information
    if (userResult.error.code === "UNAUTHORIZED") return redirectToLogin();
    throw new Error(`Failed to load user: ${userResult.error.code}`);
  }

  // posts and analytics are optional โ€” degrade gracefully
  const posts     = postsResult.ok     ? postsResult.value     : [];
  const analytics = analyticsResult.ok ? analyticsResult.value : null;

  return {
    user:      userResult.value, // User โ€” type-safe
    posts,                        // Post[]
    analytics,                    // Analytics | null
  };
}

Real Example: Parallel Requests with Timeout and Retry

type FetchConfig = {
  timeoutMs: number;
  retries: number;
  retryDelayMs: number;
};

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function fetchWithRetry<T>(
  url: string,
  config: FetchConfig
): Promise<Result<T, ApiError>> {
  for (let attempt = 0; attempt <= config.retries; attempt++) {
    const controller = new AbortController();
    const timeoutId  = setTimeout(() => controller.abort(), config.timeoutMs);

    try {
      const res = await fetch(url, { signal: controller.signal });
      clearTimeout(timeoutId);

      if (res.ok) return ok(await res.json() as T);

      if (res.status === 429) {
        const retryAfter = Number(
          res.headers.get("Retry-After") ?? config.retryDelayMs / 1000
        );
        if (attempt < config.retries) {
          await delay(retryAfter * 1000);
          continue;
        }
        return err({ code: "RATE_LIMITED", retryAfter });
      }

      return err({ code: "NETWORK", status: res.status });
    } catch (e) {
      clearTimeout(timeoutId);
      if (attempt < config.retries) {
        await delay(config.retryDelayMs * Math.pow(2, attempt)); // exponential backoff
        continue;
      }
      return err({ code: "NETWORK", status: 0 });
    }
  }
  return err({ code: "NETWORK", status: 0 });
}

// Usage
const userResult = await fetchWithRetry<User>("/api/user/123", {
  timeoutMs:    5000,
  retries:      3,
  retryDelayMs: 1000,
});

if (!userResult.ok) {
  console.error("Fetch failed:", userResult.error.code);
} else {
  console.log("Got user:", userResult.value.name);
}

Antipattern: async () => any Loses All Type Information

// Antipattern: any contaminates the entire call chain
const fetchData = async (): Promise<any> => {
  return fetch("/api/data").then(r => r.json());
};

const data = await fetchData();  // any
data.user.profile.avatar;        // no error โ€” may crash at runtime
// Fix: explicit return type, or let inference work from a schema
interface ApiResponse {
  user: User;
  posts: Post[];
}

const fetchData = async (): Promise<ApiResponse> => {
  const raw = await fetch("/api/data").then(r => r.json());
  return ApiResponseSchema.parse(raw); // validate + narrow
};

const data = await fetchData(); // ApiResponse
data.user.profile.avatar;       // compiler checks every property access

Summary

Feature TS Version Key Point
Promise<T> basics 2.1+ async functions auto-wrap return value
Awaited<T> 4.5+ Recursively unwraps Promise โ€” essential for generic async
Promise.all tuple inference 3.9+ Input must be tuple literal or use as const
Promise.allSettled 4.1+ Returns PromiseSettledResult<T>[]
Promise.any 4.4+ Returns first success; throws AggregateError on all failure
AsyncGenerator<T,R,N> 3.6+ Three type params: yield / return / next
catch clause type 4.0+ Always unknown โ€” must narrow before use

What's Next

Chapter 16 goes deep into conditional types and type inference: the infer keyword, the distributive property of conditional types, and how type-level programming implements built-in utilities like ReturnType<F> and Parameters<F> from first principles.

Rate this chapter
4.8  / 5  (18 ratings)

๐Ÿ’ฌ Comments