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.