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:
- Create a typed async queue that processes tasks sequentially with configurable concurrency limits.
- Build a retry mechanism with exponential backoff and jitter, properly typed for any async function.
- Implement an async cache with TTL (time-to-live) support and type-safe get/set methods.
- Create a typed polling system that repeatedly calls an async function until a condition is met or timeout occurs.
- 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.