TypeScript Async Patterns
Master asynchronous programming patterns in TypeScript, including Promises, async/await, error handling, async iterators, and advanced patterns for building robust async applications.
Promises and async/await
Basic Promise Creation
// Creating a Promise function delay(ms: number): Promise<void> { return new Promise((resolve) => { setTimeout(resolve, ms); }); }
// Promise with value function fetchUserData(userId: string): Promise<User> { return new Promise((resolve, reject) => { // Simulated API call setTimeout(() => { if (userId) { resolve({ id: userId, name: 'John Doe' }); } else { reject(new Error('Invalid user ID')); } }, 1000); }); }
// Using the Promise fetchUserData('123') .then((user) => { console.log(user.name); }) .catch((error) => { console.error('Error:', error.message); });
async/await Syntax
interface User { id: string; name: string; email: string; }
interface Post { id: string; userId: string; title: string; content: string; }
// Async function declaration async function getUserPosts(userId: string): Promise<Post[]> { try { const user = await fetchUserData(userId); const posts = await fetchPostsByUser(user.id); return posts; } catch (error) { console.error('Failed to fetch user posts:', error); throw error; } }
// Async arrow function const getUserProfile = async (userId: string): Promise<User> => { const user = await fetchUserData(userId); return user; };
// Using async/await
async function main() {
const posts = await getUserPosts('123');
console.log(Found ${posts.length} posts);
}
Type-Safe Promise Wrappers
type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E };
async function safeAsync<T>( promise: Promise<T> ): Promise<Result<T>> { try { const data = await promise; return { success: true, data }; } catch (error) { return { success: false, error: error instanceof Error ? error : new Error(String(error)), }; } }
// Usage async function example() { const result = await safeAsync(fetchUserData('123'));
if (result.success) { console.log(result.data.name); } else { console.error(result.error.message); } }
Promise Chaining and Composition
Chaining Promises
interface ApiResponse<T> { data: T; status: number; }
function fetchData<T>(url: string): Promise<ApiResponse<T>> {
return fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(HTTP error! status: ${response.status});
}
return response.json();
})
.then((data) => ({
data,
status: 200,
}));
}
// Chaining multiple async operations
function processUserData(userId: string): Promise<string> {
return fetchUserData(userId)
.then((user) => fetchPostsByUser(user.id))
.then((posts) => posts.filter((post) => post.title.includes('TypeScript')))
.then((filteredPosts) => Found ${filteredPosts.length} TypeScript posts)
.catch((error) => {
console.error('Error in chain:', error);
return 'Failed to process user data';
});
}
Composing Async Functions
type AsyncFunction<T, R> = (input: T) => Promise<R>;
function pipe<T, A, B>( fn1: AsyncFunction<T, A>, fn2: AsyncFunction<A, B> ): AsyncFunction<T, B> { return async (input: T) => { const result1 = await fn1(input); return fn2(result1); }; }
// Usage const getUserId = async (username: string): Promise<string> => { // Look up user ID return '123'; };
const getUserData = async (userId: string): Promise<User> => { return fetchUserData(userId); };
const getUserByUsername = pipe(getUserId, getUserData);
// Use the composed function const user = await getUserByUsername('johndoe');
Error Handling in Async Code
Try-Catch with async/await
class ApiError extends Error { constructor( message: string, public statusCode: number, public response?: unknown ) { super(message); this.name = 'ApiError'; } }
async function fetchWithErrorHandling<T>(url: string): Promise<T> { try { const response = await fetch(url);
if (!response.ok) {
throw new ApiError(
`HTTP error! status: ${response.status}`,
response.status
);
}
const data = await response.json();
return data;
} catch (error) {
if (error instanceof ApiError) {
console.error(API Error ${error.statusCode}: ${error.message});
} else if (error instanceof TypeError) {
console.error('Network error:', error.message);
} else {
console.error('Unknown error:', error);
}
throw error;
}
}
Error Recovery Patterns
async function fetchWithFallback<T>( primaryUrl: string, fallbackUrl: string ): Promise<T> { try { return await fetchWithErrorHandling<T>(primaryUrl); } catch (error) { console.warn('Primary fetch failed, trying fallback'); return await fetchWithErrorHandling<T>(fallbackUrl); } }
// Multiple fallbacks async function fetchWithMultipleFallbacks<T>( urls: string[] ): Promise<T> { let lastError: Error | undefined;
for (const url of urls) {
try {
return await fetchWithErrorHandling<T>(url);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(Failed to fetch from ${url}, trying next...);
}
}
throw new Error(
All fetches failed. Last error: ${lastError?.message ?? 'Unknown'}
);
}
Typed Error Handling
class ValidationError extends Error { constructor(message: string, public field: string) { super(message); this.name = 'ValidationError'; } }
class NetworkError extends Error { constructor(message: string, public url: string) { super(message); this.name = 'NetworkError'; } }
type AppError = ValidationError | NetworkError | Error;
async function handleUserUpdate(userId: string, data: unknown): Promise<void> {
try {
await validateUserData(data);
await updateUser(userId, data);
} catch (error) {
if (error instanceof ValidationError) {
console.error(Validation error in field ${error.field}: ${error.message});
} else if (error instanceof NetworkError) {
console.error(Network error for ${error.url}: ${error.message});
} else if (error instanceof Error) {
console.error(Unexpected error: ${error.message});
}
throw error;
}
}
Promise Combinators
Promise.all
interface UserData { profile: User; posts: Post[]; comments: Comment[]; }
async function fetchUserDataParallel(userId: string): Promise<UserData> { const [profile, posts, comments] = await Promise.all([ fetchUserData(userId), fetchPostsByUser(userId), fetchCommentsByUser(userId), ]);
return { profile, posts, comments }; }
// Type-safe Promise.all with tuple async function fetchMultipleResources() { const [users, posts, settings] = await Promise.all([ fetchUsers(), // Promise<User[]> fetchPosts(), // Promise<Post[]> fetchSettings(), // Promise<Settings> ] as const);
// TypeScript infers correct types const firstUser: User = users[0]; const firstPost: Post = posts[0]; }
Promise.allSettled
interface SettledResult<T> { status: 'fulfilled' | 'rejected'; value?: T; reason?: Error; }
async function fetchAllUserData( userIds: string[] ): Promise<Array<SettledResult<User>>> { const results = await Promise.allSettled( userIds.map((id) => fetchUserData(id)) );
return results.map((result) => { if (result.status === 'fulfilled') { return { status: 'fulfilled', value: result.value }; } else { return { status: 'rejected', reason: result.reason }; } }); }
// Usage const results = await fetchAllUserData(['1', '2', '3']); const successful = results.filter((r) => r.status === 'fulfilled'); const failed = results.filter((r) => r.status === 'rejected');
console.log(${successful.length} succeeded, ${failed.length} failed);
Promise.race and Promise.any
// Promise.race - first to settle (fulfill or reject) async function fetchWithTimeout<T>( promise: Promise<T>, timeoutMs: number ): Promise<T> { const timeout = new Promise<never>((_, reject) => { setTimeout(() => reject(new Error('Operation timed out')), timeoutMs); });
return Promise.race([promise, timeout]); }
// Usage const data = await fetchWithTimeout(fetchUserData('123'), 5000);
// Promise.any - first to fulfill (ignores rejections) async function fetchFromFastestServer<T>( urls: string[] ): Promise<T> { const fetchPromises = urls.map((url) => fetchWithErrorHandling<T>(url));
try { return await Promise.any(fetchPromises); } catch (error) { throw new Error('All servers failed to respond'); } }
Async Iterators and Generators
Basic Async Iterators
interface AsyncIteratorResult<T> { value: T; done: boolean; }
async function* numberGenerator(max: number): AsyncGenerator<number> { for (let i = 0; i < max; i++) { await delay(100); yield i; } }
// Using async iterator async function consumeNumbers() { for await (const num of numberGenerator(5)) { console.log(num); } }
Async Iterable Data Streams
interface DataChunk { data: string; timestamp: number; }
class AsyncDataStream implements AsyncIterable<DataChunk> { constructor(private source: string[]) {}
async *Symbol.asyncIterator: AsyncGenerator<DataChunk> { for (const data of this.source) { await delay(100); yield { data, timestamp: Date.now(), }; } } }
// Usage async function processStream() { const stream = new AsyncDataStream(['chunk1', 'chunk2', 'chunk3']);
for await (const chunk of stream) {
console.log(Received at ${chunk.timestamp}: ${chunk.data});
}
}
Transforming Async Iterables
async function* mapAsync<T, R>( iterable: AsyncIterable<T>, mapper: (value: T) => Promise<R> | R ): AsyncGenerator<R> { for await (const value of iterable) { yield await mapper(value); } }
async function* filterAsync<T>( iterable: AsyncIterable<T>, predicate: (value: T) => Promise<boolean> | boolean ): AsyncGenerator<T> { for await (const value of iterable) { if (await predicate(value)) { yield value; } } }
// Usage async function transformData() { const stream = new AsyncDataStream(['1', '2', '3', '4', '5']);
const numbers = mapAsync(stream, (chunk) => parseInt(chunk.data)); const evenNumbers = filterAsync(numbers, (n) => n % 2 === 0);
for await (const num of evenNumbers) { console.log(num); // 2, 4 } }
Observable Patterns
Simple Observable Implementation
type Observer<T> = (value: T) => void; type Unsubscribe = () => void;
class Observable<T> { private observers: Set<Observer<T>> = new Set();
subscribe(observer: Observer<T>): Unsubscribe { this.observers.add(observer); return () => { this.observers.delete(observer); }; }
next(value: T): void { this.observers.forEach((observer) => observer(value)); }
pipe<R>(operator: (obs: Observable<T>) => Observable<R>): Observable<R> { return operator(this); } }
// Operator function map<T, R>(mapper: (value: T) => R) { return (source: Observable<T>): Observable<R> => { const result = new Observable<R>();
source.subscribe((value) => {
result.next(mapper(value));
});
return result;
}; }
// Usage const numbers = new Observable<number>(); const doubled = numbers.pipe(map((n) => n * 2));
doubled.subscribe((value) => console.log(value)); numbers.next(5); // 10
Async Observable
interface AsyncObserver<T> { next: (value: T) => Promise<void> | void; error?: (error: Error) => Promise<void> | void; complete?: () => Promise<void> | void; }
class AsyncObservable<T> { private observers: Set<AsyncObserver<T>> = new Set();
subscribe(observer: AsyncObserver<T>): Unsubscribe { this.observers.add(observer); return () => { this.observers.delete(observer); }; }
async next(value: T): Promise<void> { await Promise.all( Array.from(this.observers).map((obs) => obs.next(value)) ); }
async error(error: Error): Promise<void> { await Promise.all( Array.from(this.observers) .filter((obs) => obs.error) .map((obs) => obs.error!(error)) ); }
async complete(): Promise<void> { await Promise.all( Array.from(this.observers) .filter((obs) => obs.complete) .map((obs) => obs.complete!()) ); } }
Cancellation Patterns
AbortController for Cancellation
async function fetchWithCancellation( url: string, signal: AbortSignal ): Promise<Response> { const response = await fetch(url, { signal });
if (signal.aborted) { throw new Error('Request was cancelled'); }
return response; }
// Usage const controller = new AbortController();
// Start fetch const fetchPromise = fetchWithCancellation( 'https://api.example.com/data', controller.signal );
// Cancel after 5 seconds setTimeout(() => controller.abort(), 5000);
try { const response = await fetchPromise; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { console.log('Request was cancelled'); } }
Cancellable Promise Wrapper
interface CancellablePromise<T> extends Promise<T> { cancel: () => void; }
function makeCancellable<T>( promise: Promise<T> ): CancellablePromise<T> { let cancelled = false;
const wrappedPromise = new Promise<T>((resolve, reject) => { promise .then((value) => { if (!cancelled) { resolve(value); } }) .catch((error) => { if (!cancelled) { reject(error); } }); }) as CancellablePromise<T>;
wrappedPromise.cancel = () => { cancelled = true; };
return wrappedPromise; }
// Usage const cancellable = makeCancellable(fetchUserData('123'));
setTimeout(() => { cancellable.cancel(); }, 1000);
Retry and Timeout Patterns
Retry with Exponential Backoff
interface RetryOptions { maxAttempts: number; baseDelay: number; maxDelay: number; backoffMultiplier: number; }
async function retry<T>( fn: () => Promise<T>, options: RetryOptions ): Promise<T> { let lastError: Error | undefined;
for (let attempt = 0; attempt < options.maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < options.maxAttempts - 1) {
const delayMs = Math.min(
options.baseDelay * Math.pow(options.backoffMultiplier, attempt),
options.maxDelay
);
console.log(`Attempt ${attempt + 1} failed, retrying in ${delayMs}ms`);
await delay(delayMs);
}
}
}
throw new Error(
Failed after ${options.maxAttempts} attempts: ${lastError?.message ?? 'Unknown error'}
);
}
// Usage const data = await retry( () => fetchUserData('123'), { maxAttempts: 3, baseDelay: 1000, maxDelay: 5000, backoffMultiplier: 2, } );
Conditional Retry
type RetryPredicate = (error: Error, attempt: number) => boolean;
async function retryWhen<T>( fn: () => Promise<T>, shouldRetry: RetryPredicate, maxAttempts: number ): Promise<T> { let lastError: Error | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < maxAttempts - 1 && shouldRetry(lastError, attempt)) {
await delay(1000 * (attempt + 1));
} else {
throw lastError;
}
}
}
throw lastError!; }
// Usage: Only retry on network errors const data = await retryWhen( () => fetchUserData('123'), (error) => error instanceof NetworkError, 3 );
Timeout Pattern
class TimeoutError extends Error { constructor(message: string) { super(message); this.name = 'TimeoutError'; } }
async function withTimeout<T>( promise: Promise<T>, timeoutMs: number, message = 'Operation timed out' ): Promise<T> { let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => { timeoutId = setTimeout(() => { reject(new TimeoutError(message)); }, timeoutMs); });
try { return await Promise.race([promise, timeoutPromise]); } finally { clearTimeout(timeoutId!); } }
// Combined retry with timeout async function retryWithTimeout<T>( fn: () => Promise<T>, options: RetryOptions & { timeout: number } ): Promise<T> { return retry( () => withTimeout(fn(), options.timeout), options ); }
Async Type Inference
Inferring Promise Types
// Extract Promise type type Awaited<T> = T extends Promise<infer U> ? U : T;
// Usage type UserPromise = Promise<User>; type UserType = Awaited<UserPromise>; // User
// For functions type ReturnTypeAsync<T extends (...args: any) => any> = Awaited<ReturnType<T>>;
async function getUser(): Promise<User> { return { id: '1', name: 'John', email: 'john@example.com' }; }
type UserFromFunction = ReturnTypeAsync<typeof getUser>; // User
Typed Async Function Utilities
type AsyncFn<Args extends any[], R> = (...args: Args) => Promise<R>;
// Type-safe async pipe function composeAsync<A, B, C>( f: AsyncFn<[A], B>, g: AsyncFn<[B], C> ): AsyncFn<[A], C> { return async (a: A) => { const b = await f(a); return g(b); }; }
// Memoize async function function memoizeAsync<Args extends any[], R>( fn: AsyncFn<Args, R> ): AsyncFn<Args, R> { const cache = new Map<string, Promise<R>>();
return async (...args: Args) => { 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;
}
}; }
Best Practices
Always Handle Errors: Use try-catch with async/await or .catch() with Promises. Never let errors go unhandled in async code.
Avoid Mixing Paradigms: Choose either Promise chains or async/await for a given flow. Mixing them makes code harder to read and maintain.
Use Promise.all for Parallel Operations: When operations are independent, use Promise.all to run them in parallel rather than sequentially.
Type Promise Return Values: Always explicitly type the return value of async functions and Promises for better type safety and IDE support.
Handle Race Conditions: Be careful with shared state in async code. Use proper synchronization or immutable data structures.
Set Timeouts for Network Requests: Always add timeouts to prevent hanging requests. Use AbortController or Promise.race.
Implement Proper Cleanup: Use finally blocks or try/finally to ensure cleanup code runs regardless of success or failure.
Avoid Async in Constructors: Constructors cannot be async. Use factory functions or initialization methods instead.
Use AbortController for Cancellation: Prefer standard AbortController over custom cancellation for better browser/Node.js compatibility.
Document Async Behavior: Clearly document what async functions do, what they return, and what errors they might throw.
Common Pitfalls
Forgetting await: Forgetting await on async functions returns a Promise instead of the resolved value, causing type errors and bugs.
Sequential When Parallel Is Better: Using await in loops when operations could run in parallel leads to poor performance.
Unhandled Promise Rejections: Not catching errors in Promises or async functions can crash Node.js applications or cause silent failures.
Floating Promises: Not awaiting or handling Promises (fire-and-forget) can cause unhandled rejections and race conditions.
Promise Constructor Anti-pattern: Wrapping already-promisified functions in new Promise is unnecessary and adds complexity.
Async IIFE Mistakes: Forgetting to await or handle errors from immediately-invoked async functions causes silent failures.
Wrong Error Type Assumptions: Assuming all errors are Error instances. Use proper type checking or type guards.
Memory Leaks in Async Iterators: Not properly cleaning up async iterators can cause memory leaks, especially with infinite streams.
Ignoring Cancellation: Not implementing cancellation for long-running operations wastes resources and degrades user experience.
Over-Using Async: Making everything async when synchronous alternatives exist adds unnecessary complexity and performance overhead.
When to Use This Skill
Use TypeScript async patterns when you need to:
-
Make API calls or interact with external services
-
Perform I/O operations (file system, database, network)
-
Build real-time applications with streaming data
-
Handle user interactions that trigger async operations
-
Implement background tasks or scheduled jobs
-
Work with WebSockets or Server-Sent Events
-
Process large datasets asynchronously
-
Build responsive UIs that don't block the main thread
-
Implement retry logic for unreliable operations
-
Create composable async workflows
This skill is essential for full-stack developers, frontend engineers working with APIs, backend developers building services, and anyone building modern JavaScript/TypeScript applications.
Resources
Documentation
-
MDN Web Docs - Async/Await: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
-
TypeScript Handbook - Async Functions: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-1-7.html
-
JavaScript Promises - MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
Books and Articles
-
"You Don't Know JS: Async & Performance" by Kyle Simpson
-
"JavaScript: The Definitive Guide" by David Flanagan
-
"Async JavaScript" by Trevor Burnham
Libraries
-
RxJS: https://rxjs.dev/ - Reactive Extensions for JavaScript
-
p-limit: https://github.com/sindresorhus/p-limit - Promise concurrency control
-
p-retry: https://github.com/sindresorhus/p-retry - Retry failed promises
-
abort-controller: https://www.npmjs.com/package/abort-controller - AbortController polyfill
Tools
-
TypeScript Playground: https://www.typescriptlang.org/play
-
Chrome DevTools - Async Stack Traces
-
Node.js --trace-warnings flag for debugging unhandled rejections