TypeScript

Asynchronous TypeScript

38 min Lesson 22 of 40

Asynchronous TypeScript

Asynchronous programming is essential for modern JavaScript applications. TypeScript enhances async code with strong typing, making it easier to catch errors at compile time and understand what your asynchronous operations return. In this lesson, we'll explore typed Promises, async/await with TypeScript, generic async functions, and robust error handling patterns for asynchronous code.

Understanding Typed Promises

In TypeScript, Promises are generic types that specify the type of value they resolve to:

// Basic Promise types const stringPromise: Promise<string> = Promise.resolve('hello'); const numberPromise: Promise<number> = Promise.resolve(42); const voidPromise: Promise<void> = Promise.resolve(); // Function returning a typed Promise function fetchUser(id: number): Promise<User> { return fetch(\`/api/users/${id}\`) .then(response => response.json()) .then(data => data as User); } // Promise with union types function getDataOrError(): Promise<string | Error> { return fetch('/api/data') .then(response => { if (response.ok) { return response.text(); } return new Error(\`HTTP ${response.status}\`); }); } // Creating Promises with proper typing function delay(ms: number): Promise<void> { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function waitForValue<T>(value: T, ms: number): Promise<T> { return new Promise((resolve) => { setTimeout(() => resolve(value), ms); }); } // Usage const result: string = await waitForValue('done', 1000);
Note: TypeScript infers Promise types automatically in most cases, but explicit typing helps document your API and catch errors early.

Async/Await with TypeScript

Async functions in TypeScript automatically return a Promise wrapping their return type:

// Async function returning Promise<string> async function fetchUsername(id: number): Promise<string> { const response = await fetch(\`/api/users/${id}\`); const user = await response.json(); return user.name; // TypeScript knows this is string } // Return type is inferred async function getTotal(items: number[]) { await delay(100); return items.reduce((sum, n) => sum + n, 0); } // Type: Promise<number> // Async function with union return type async function loadConfig(): Promise<Config | null> { try { const response = await fetch('/config.json'); if (!response.ok) return null; return await response.json(); } catch { return null; } } // Async functions can throw async function strictFetch(url: string): Promise<Response> { const response = await fetch(url); if (!response.ok) { throw new Error(\`HTTP ${response.status}: ${response.statusText}\`); } return response; } // Using await in expressions async function getDisplayName(id: number): Promise<string> { const name = await fetchUsername(id); return \`User: ${name}\`; }
Tip: TypeScript checks that you can only use await inside async functions or at the top level of ES modules, preventing common mistakes.

Generic Async Functions

Generics make async functions flexible and type-safe:

// Generic fetch wrapper async function fetchJSON<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { throw new Error(\`HTTP ${response.status}\`); } return await response.json() as T; } // Usage with type inference interface User { id: number; name: string; email: string; } interface Post { id: number; title: string; content: string; } const user = await fetchJSON<User>('/api/users/1'); const posts = await fetchJSON<Post[]>('/api/posts'); // Generic async transformation async function mapAsync<T, U>( items: T[], fn: (item: T) => Promise<U> ): Promise<U[]> { return Promise.all(items.map(fn)); } // Usage const userIds = [1, 2, 3]; const users = await mapAsync(userIds, id => fetchJSON<User>(\`/api/users/${id}\`)); // Generic retry logic async function retry<T>( fn: () => Promise<T>, attempts: number = 3, delayMs: number = 1000 ): Promise<T> { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (error) { if (i === attempts - 1) throw error; await delay(delayMs); } } throw new Error('Should never reach here'); } // Usage const data = await retry(() => fetchJSON<User>('/api/users/1'), 5, 2000); // Generic parallel execution with type safety async function parallel<T extends readonly unknown[]>( ...promises: { [K in keyof T]: Promise<T[K]> } ): Promise<T> { return Promise.all(promises) as Promise<T>; } // Usage with different types const [user, posts, comments] = await parallel( fetchJSON<User>('/api/users/1'), fetchJSON<Post[]>('/api/posts'), fetchJSON<Comment[]>('/api/comments') ); // user: User, posts: Post[], comments: Comment[]

Async Error Handling

TypeScript enables sophisticated error handling patterns for async code:

// Try-catch with typed errors async function safeFetch(url: string): Promise<Response> { try { return await fetch(url); } catch (error: unknown) { if (error instanceof TypeError) { console.log('Network error:', error.message); } else if (error instanceof Error) { console.log('Error:', error.message); } throw error; } } // Result type for async operations type AsyncResult<T, E = Error> = Promise<Result<T, E>>; type Result<T, E> = | { success: true; value: T } | { success: false; error: E }; async function fetchUserSafe(id: number): AsyncResult<User> { try { const response = await fetch(\`/api/users/${id}\`); if (!response.ok) { return { success: false, error: new Error(\`HTTP ${response.status}\`) }; } const user = await response.json(); return { success: true, value: user }; } catch (error) { return { success: false, error: error instanceof Error ? error : new Error(String(error)) }; } } // Using the safe fetch const result = await fetchUserSafe(123); if (result.success) { console.log('User:', result.value.name); } else { console.log('Error:', result.error.message); } // Typed error classes for async operations class NetworkError extends Error { constructor( message: string, public readonly statusCode?: number ) { super(message); this.name = 'NetworkError'; } } class ValidationError extends Error { constructor( message: string, public readonly fields: Record<string, string[]> ) { super(message); this.name = 'ValidationError'; } } async function createUser(data: unknown): Promise<User> { const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) }); if (response.status === 400) { const errors = await response.json(); throw new ValidationError('Validation failed', errors); } if (!response.ok) { throw new NetworkError(\`HTTP ${response.status}\`, response.status); } return await response.json(); } // Handling specific error types try { const user = await createUser({ name: 'John' }); } catch (error) { if (error instanceof ValidationError) { console.log('Validation errors:', error.fields); } else if (error instanceof NetworkError) { console.log(\`Network error: ${error.statusCode}\`); } else { console.log('Unknown error'); } }
Warning: Always type catch clause variables as unknown and use type guards. Never assume the error type without checking.

Promise Combinators

TypeScript provides excellent type inference for Promise combinators:

// Promise.all with tuple types const [user, posts, profile] = await Promise.all([ fetchJSON<User>('/api/users/1'), fetchJSON<Post[]>('/api/posts'), fetchJSON<Profile>('/api/profile') ]); // Types: [User, Post[], Profile] // Promise.race with union types const result = await Promise.race([ fetchJSON<User>('/api/users/1'), delay(5000).then(() => null) ]); // Type: User | null // Promise.allSettled with typed results const results = await Promise.allSettled([ fetchJSON<User>('/api/users/1'), fetchJSON<Post[]>('/api/posts'), fetchJSON<Comment[]>('/api/comments') ]); results.forEach((result, index) => { if (result.status === 'fulfilled') { console.log(\`Promise ${index} succeeded:\`, result.value); } else { console.log(\`Promise ${index} failed:\`, result.reason); } }); // Promise.any with proper typing try { const firstUser = await Promise.any([ fetchJSON<User>('/api/users/1'), fetchJSON<User>('/api/users/2'), fetchJSON<User>('/api/users/3') ]); // Type: User } catch (error) { if (error instanceof AggregateError) { console.log('All requests failed:', error.errors); } } // Custom combinator with proper typing async function firstSuccessful<T>( promises: Promise<T>[] ): Promise<T | null> { const results = await Promise.allSettled(promises); const fulfilled = results.find(r => r.status === 'fulfilled'); return fulfilled && fulfilled.status === 'fulfilled' ? fulfilled.value : null; }

Async Iterators and Generators

TypeScript supports async iterators with full type safety:

// Async iterator interface interface AsyncIterableIterator<T> { next(): Promise<IteratorResult<T>>; [Symbol.asyncIterator](): AsyncIterableIterator<T>; } // Async generator function async function* generateNumbers(count: number): AsyncGenerator<number> { for (let i = 0; i < count; i++) { await delay(100); yield i; } } // Using async iteration for await (const num of generateNumbers(5)) { console.log(num); // 0, 1, 2, 3, 4 } // Async generator for paginated data async function* fetchAllUsers(): AsyncGenerator<User> { let page = 1; let hasMore = true; while (hasMore) { const response = await fetch(\`/api/users?page=${page}\`); const data: { users: User[]; hasMore: boolean } = await response.json(); for (const user of data.users) { yield user; } hasMore = data.hasMore; page++; } } // Processing streamed data for await (const user of fetchAllUsers()) { console.log(user.name); } // Transforming async iterables async function* mapAsyncIterable<T, U>( iterable: AsyncIterable<T>, fn: (item: T) => U | Promise<U> ): AsyncGenerator<U> { for await (const item of iterable) { yield await fn(item); } } // Usage const userNames = mapAsyncIterable( fetchAllUsers(), user => user.name.toUpperCase() ); for await (const name of userNames) { console.log(name); } // Filtering async iterables async function* filterAsyncIterable<T>( iterable: AsyncIterable<T>, predicate: (item: T) => boolean | Promise<boolean> ): AsyncGenerator<T> { for await (const item of iterable) { if (await predicate(item)) { yield item; } } } // Get only active users const activeUsers = filterAsyncIterable( fetchAllUsers(), user => user.isActive );
Note: Async generators are perfect for streaming data, handling large datasets, or implementing pagination with a clean API.

Async Utilities

Building type-safe utility functions for common async patterns:

// Timeout wrapper async function withTimeout<T>( promise: Promise<T>, timeoutMs: number, timeoutError: Error = new Error('Operation timed out') ): Promise<T> { return Promise.race([ promise, delay(timeoutMs).then(() => Promise.reject(timeoutError)) ]); } // Usage const user = await withTimeout( fetchJSON<User>('/api/users/1'), 5000, new Error('User fetch timed out') ); // Debounced async function function debounceAsync<T extends unknown[], R>( fn: (...args: T) => Promise<R>, delayMs: number ): (...args: T) => Promise<R> { let timeoutId: NodeJS.Timeout | null = null; let latestPromise: Promise<R> | null = null; return (...args: T): Promise<R> => { if (timeoutId) { clearTimeout(timeoutId); } latestPromise = new Promise((resolve, reject) => { timeoutId = setTimeout(() => { fn(...args).then(resolve).catch(reject); }, delayMs); }); return latestPromise; }; } // Usage const debouncedSearch = debounceAsync( async (query: string) => fetchJSON<SearchResult[]>(\`/api/search?q=${query}\`), 300 ); // Batch processing async function batchProcess<T, R>( items: T[], processor: (item: T) => Promise<R>, batchSize: number = 10 ): Promise<R[]> { const results: R[] = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); const batchResults = await Promise.all(batch.map(processor)); results.push(...batchResults); } return results; } // Process 1000 items in batches of 50 const processedItems = await batchProcess( items, async (item) => processItem(item), 50 ); // Sequential execution helper async function sequential<T, R>( items: T[], fn: (item: T) => Promise<R> ): Promise<R[]> { const results: R[] = []; for (const item of items) { results.push(await fn(item)); } return results; } // Memoized async function function memoizeAsync<T extends unknown[], R>( fn: (...args: T) => Promise<R> ): (...args: T) => Promise<R> { const cache = new Map<string, Promise<R>>(); return async (...args: T): Promise<R> => { const key = JSON.stringify(args); if (cache.has(key)) { return cache.get(key)!; } const promise = fn(...args); cache.set(key, promise); try { return await promise; } catch (error) { cache.delete(key); throw error; } }; } const cachedFetch = memoizeAsync(fetchJSON<User>);
Exercise:
  1. Create a typed async queue that processes tasks sequentially with configurable concurrency limits.
  2. Build a retry mechanism with exponential backoff and jitter, properly typed for any async function.
  3. Implement an async cache with TTL (time-to-live) support and type-safe get/set methods.
  4. Create a typed polling system that repeatedly calls an async function until a condition is met or timeout occurs.
  5. Build an async pipeline that chains multiple transformations on data, maintaining type safety throughout.

Summary

TypeScript transforms asynchronous JavaScript programming with strong typing for Promises, async/await, and async iterators. By leveraging generic async functions, typed error handling patterns, and custom utilities, you can write async code that is both robust and maintainable. TypeScript's type system catches many async bugs at compile time, making your asynchronous code safer and more predictable.