TypeScript Async Programming
TypeScript adds static types to JavaScript's asynchronous features. That makes async code easier to predict and maintain.
This lesson goes from basic async/await to more advanced patterns.
Note: This lesson assumes you already know the basics of JavaScript Promises and asynchronous programming. If you are new to those ideas, learn about JavaScript async first.
Promises in TypeScript
TypeScript makes Promises type-safe through generics.
A Promise<T> is an operation that will finish later with a value of type T, or fail with a reason of type any.
Key points:
Promise<T>— generic type whereTis the type of the resolved value.Promise<void>— for Promises that do not return a value.Promise<never>— for Promises that never resolve (rare).
Promise states and type flow: a Promise starts as pending and moves either to fulfilled (with a value of type T) or to rejected (with a reason of type any). TypeScript tracks these states through the type system, so you handle both the success and the error case.
The type parameter in Promise<T> tells TypeScript what the Promise will resolve to, which gives better type checking and editor support.
Basic Promise Example
fetchGreeting returns Promise<string>, so inside .then TypeScript knows greeting is a string. The Promise randomly resolves or rejects, so the output is either the upper-cased greeting or the error line.
fetchGreeting arrow function typed to return Promise<string>. Inside, build a Promise that waits one second with setTimeout, then randomly (using Math.random() > 0.5) either resolves with "Hello, TypeScript!" or rejects with an Error("Failed to fetch greeting").
Consume it with .then — logging the greeting in upper case, since TypeScript knows it is a string — and .catch with the error typed as Error, printing "Error:" and its message. The output is random: either the upper-cased greeting or the error line.
Output: TypeScript file and captured output
// Create a typed Promise that resolves to a string
const fetchGreeting = (): Promise<string> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("Hello, TypeScript!");
} else {
reject(new Error("Failed to fetch greeting"));
}
}, 1000);
});
};
// Using the Promise with proper type inference
fetchGreeting()
.then((greeting) => {
// TypeScript knows 'greeting' is a string
console.log(greeting.toUpperCase());
})
.catch((error: Error) => {
console.error("Error:", error.message);
});
Async/Await with TypeScript
The async/await syntax is a cleaner way to work with Promises. Asynchronous code reads like ordinary step-by-step code, and TypeScript keeps checking the types.
Key benefits of async/await:
- Readability: sequential code that is easier to follow.
- Error handling: use
try/catchfor both sync and async errors. - Debugging: stack traces look like synchronous ones.
- Type safety: full TypeScript inference and checking.
Async function return types. Every async function returns a Promise, and the declared return type must be wrapped in one: async function getString(): string { } is an error, while async function getString(): Promise<string> { } is correct.
Note: The type parameter of the returned Promise is the type you write after Promise in the return type.
Basic Async/Await Example
fetchUsers returns Promise<User[]>, so after await the users variable is a User[] and property access on each user is type-checked.
User interface (id, name, email, and role limited to 'admin' | 'user' | 'guest') and an async function fetchUsers(): Promise<User[]> that logs 'Fetching users...', awaits a one-second setTimeout to simulate an API call, then returns Alice (admin) and Bob (user).
Write async function processUsers() that awaits fetchUsers() inside try/catch, logs how many users were fetched, filters admins and maps to their emails, logs 'Admin emails:' with that array, and re-throws any error after logging it (checking error instanceof Error). Finally call processUsers() and log 'Processing complete' on success or 'Processing failed:' on failure.
Output: TypeScript file and captured output
// Define types for our API response
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
// Function that returns a Promise of User array
async function fetchUsers(): Promise<User[]> {
console.log('Fetching users...');
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
return [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' }
];
}
// Async function to process users
async function processUsers() {
try {
// TypeScript knows users is User[]
const users = await fetchUsers();
console.log(`Fetched ${users.length} users`);
// Type-safe property access
const adminEmails = users
.filter(user => user.role === 'admin')
.map(user => user.email);
console.log('Admin emails:', adminEmails);
return users;
} catch (error) {
if (error instanceof Error) {
console.error('Failed to process users:', error.message);
} else {
console.error('An unknown error occurred');
}
throw error; // Re-throw to let caller handle
}
}
// Execute the async function
processUsers()
.then(users => console.log('Processing complete'))
.catch(err => console.error('Processing failed:', err));
Parallel Execution with Promise.all
Run several async operations in parallel and wait for all of them to finish. The three products are fetched at the same time, and their random prices are summed.
Product interface (id, name, price) and an async function fetchProduct(id: number): Promise<Product> that logs Fetching product <id>..., waits a random time up to one second, and returns a product named Product <id> with a random whole-number price below 100.
Write fetchMultipleProducts() that starts fetchProduct(1), (2) and (3) in parallel with Promise.all, destructures the three results, sums their prices and logs Total price: $<total with two decimals>; log any error with 'Error fetching products:'. Call the function. The total is random.
Output: TypeScript file and captured output
interface Product {
id: number;
name: string;
price: number;
}
async function fetchProduct(id: number): Promise<Product> {
console.log(`Fetching product ${id}...`);
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
return { id, name: `Product ${id}`, price: Math.floor(Math.random() * 100) };
}
async function fetchMultipleProducts() {
try {
// Start all fetches in parallel
const [product1, product2, product3] = await Promise.all([
fetchProduct(1),
fetchProduct(2),
fetchProduct(3)
]);
const total = [product1, product2, product3]
.reduce((sum, product) => sum + product.price, 0);
console.log(`Total price: $${total.toFixed(2)}`);
} catch (error) {
console.error('Error fetching products:', error);
}
}
fetchMultipleProducts();
Typing Callbacks for Async Operations
For older callback-style asynchronous code, TypeScript helps you type the callback's parameters so the caller knows exactly what it receives.
Example
The FetchCallback type says the callback gets an Error or null, plus optional string data. Inside the callback TypeScript knows data is a string or undefined.
FetchCallback type alias for a callback (error: Error | null, data?: string) => void, and a fetchDataWithCallback(url: string, callback: FetchCallback): void function that simulates async work with a one-second setTimeout, calling the callback with null and "Response data" (or with an Error if something throws).
Call it with 'https://api.example.com' and a callback that logs 'Error:' and returns on error, and otherwise logs the data in upper case when it is defined.
Output: TypeScript file and captured output
// Define a type for the callback
type FetchCallback = (error: Error | null, data?: string) => void;
// Function that takes a typed callback
function fetchDataWithCallback(url: string, callback: FetchCallback): void {
// Simulate async operation
setTimeout(() => {
try {
// Simulate successful response
callback(null, "Response data");
} catch (error) {
callback(error instanceof Error ? error : new Error('Unknown error'));
}
}, 1000);
}
// Using the callback function
fetchDataWithCallback('https://api.example.com', (error, data) => {
if (error) {
console.error('Error:', error.message);
return;
}
// TypeScript knows data is a string (or undefined)
if (data) {
console.log(data.toUpperCase());
}
});
Promise Combinations
TypeScript gives you typed versions of the methods that combine several Promises. They help you run operations concurrently and handle the results safely.
Promise combination methods:
Promise.all()— waits for all promises to resolve.Promise.race()— returns the first settled promise.Promise.allSettled()— waits for all to settle (success or failure).Promise.any()— returns the first fulfilled promise.
Warning: When you pass promises of different types to Promise.all, TypeScript infers a tuple of the individual result types. If the inferred type is not precise enough, use a type assertion or define the expected structure yourself.
Promise.all - Parallel Execution
Run several promises in parallel and wait for all of them. It fails fast if any promise rejects. Here user, posts and stats each get their own precise type from the tuple result.
fetchUser(id) giving { id, name: "User <id>" }, fetchPosts(userId) giving two posts (Post 1, Post 2) with ids, and fetchStats(userId) giving { views: 100, likes: 25 }.
Write async function loadUserDashboard(userId: number) that awaits all three with Promise.all, logs User: <name>, Posts: <count> and Likes: <likes>, returns the three values in an object, and logs 'Failed to load dashboard:' before re-throwing on error. Call it with user id 1.
Output: TypeScript file and captured output
// Different types of promises
const fetchUser = (id: number): Promise<{ id: number; name: string }> =>
Promise.resolve({ id, name: `User ${id}` });
const fetchPosts = (userId: number): Promise<Array<{ id: number; title: string }>> =>
Promise.resolve([
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' }
]);
const fetchStats = (userId: number): Promise<{ views: number; likes: number }> =>
Promise.resolve({ views: 100, likes: 25 });
// Run all in parallel
async function loadUserDashboard(userId: number) {
try {
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchStats(userId)
]);
// TypeScript knows the types of user, posts, and stats
console.log(`User: ${user.name}`);
console.log(`Posts: ${posts.length}`);
console.log(`Likes: ${stats.likes}`);
return { user, posts, stats };
} catch (error) {
console.error('Failed to load dashboard:', error);
throw error;
}
}
// Execute with a user ID
loadUserDashboard(1);
Promise.race - First to Settle
Useful for timeouts, or for taking the first successful response from several sources. fetchWithTimeout races a promise against a rejecting timer. The functions are only defined, so nothing is printed.
timeout(ms: number): Promise<never> helper that rejects with Error("Timeout after <ms>ms") after ms milliseconds, and a generic async function fetchWithTimeout<T>(promise: Promise<T>, timeoutMs = 5000): Promise<T> that races the given promise against the timeout, throwing Request timed out after <ms>ms if the timeout wins.
Then define (but do not call) fetchUserData() that uses fetchWithTimeout around fetch('https://api.example.com/user/1') with a 3-second limit, parses the JSON, and on error logs 'Error:' with the message (cast to Error) and re-throws. Nothing is printed because no function is invoked.
Output: TypeScript file and captured output
// Helper function for timeout
const timeout = (ms: number): Promise<never> =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
// Simulate API call with timeout
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number = 5000
): Promise<T> {
return Promise.race([
promise,
timeout(timeoutMs).then(() => {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}),
]);
}
// Usage example
async function fetchUserData() {
try {
const response = await fetchWithTimeout(
fetch('https://api.example.com/user/1'),
3000 // 3 second timeout
);
const data = await response.json();
return data;
} catch (error) {
console.error('Error:', (error as Error).message);
throw error;
}
}
Promise.allSettled - Handle All Results
Use this when you want to wait for every promise, whether it succeeds or fails. Each result is checked with a type predicate before it is counted as successful or failed. Some requests fail at random, so the counts vary.
fetchData(id: number) that throws Error("Failed to fetch data for ID <id>") when Math.random() > 0.7 and otherwise returns { id, data: "Data for <id>" }.
Write async function processBatch(ids: number[]) that maps each id to fetchData(id) wrapped so it resolves to { status: 'fulfilled' as const, value } or { status: 'rejected' as const, reason }, awaits Promise.allSettled on them, separates successful and failed results with type-predicate filters (PromiseFulfilledResult / PromiseRejectedResult), logs Successfully processed: <n> and Failed: <n>, and returns both lists. Call it with ids 1 to 5. The counts are random.
Output: TypeScript file and captured output
// Simulate multiple API calls with different outcomes
const fetchData = async (id: number) => {
// Randomly fail some requests
if (Math.random() > 0.7) {
throw new Error(`Failed to fetch data for ID ${id}`);
}
return { id, data: `Data for ${id}` };
};
// Process multiple items with individual error handling
async function processBatch(ids: number[]) {
const promises = ids.map(id =>
fetchData(id)
.then(value => ({ status: 'fulfilled' as const, value }))
.catch(reason => ({ status: 'rejected' as const, reason }))
);
// Wait for all to complete
const results = await Promise.allSettled(promises);
// Process results
const successful = results
.filter((result): result is PromiseFulfilledResult<{ status: 'fulfilled', value: any }> =>
result.status === 'fulfilled' &&
result.value.status === 'fulfilled'
)
.map(r => r.value.value);
const failed = results
.filter((result): result is PromiseRejectedResult |
PromiseFulfilledResult<{ status: 'rejected', reason: any }> => {
if (result.status === 'rejected') return true;
return result.value.status === 'rejected';
});
console.log(`Successfully processed: ${successful.length}`);
console.log(`Failed: ${failed.length}`);
return { successful, failed };
}
// Process a batch of IDs
processBatch([1, 2, 3, 4, 5]);
Error Handling in Async Code
TypeScript gives you good tools for type-safe error handling in asynchronous code. Here are some patterns and best practices.
Error handling strategies:
- Try/catch blocks: for handling errors in async/await.
- Error boundaries: for React components.
- Result types: a functional approach with success/failure values.
- Error subclassing: for domain-specific errors.
Error handling patterns worth considering:
- Error boundaries in React for component-level handling.
- Result objects instead of throwing for expected cases.
- Global error handler for uncaught exceptions.
- Error logging to capture and report errors.
Custom Error Classes
Create domain-specific error types for better error handling. AppError is the base class, and NetworkError, ValidationError and NotFoundError each carry a code. A local mockFetch stands in for fetch so the example runs without a network.
AppError extends Error base class whose constructor takes message, a public readonly code: string and optional public readonly details?: unknown, sets this.name to the class name and captures the stack trace when available; then NetworkError (code 'NETWORK_ERROR'), ValidationError (code 'VALIDATION_ERROR', with a public readonly field), and NotFoundError (code 'NOT_FOUND', message <resource> with ID <id> not found).
Write async function fetchUserData(userId: string): Promise<{ id: string; name: string }> that awaits a local mockFetch (a Promise resolving to a fake { ok, status, json() } response so no network is touched), throws NotFoundError on 404, NetworkError on 5xx, a plain Error on other non-ok statuses, and ValidationError('name', ...) when the JSON has no name; unexpected errors are wrapped in AppError with code 'UNEXPECTED_ERROR'. Add displayUserProfile(userId) that logs 'User profile:' with the user, or logs a different console.error prefix (Network issue:, Validation failed:, Not found:, Unexpected error:) per error class, and call it with '123'.
Output: TypeScript file and captured output
// Base error class for our application
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly details?: unknown
) {
super(message);
this.name = this.constructor.name;
(Error as any).captureStackTrace?.(this, this.constructor);
}
}
// Specific error types
class NetworkError extends AppError {
constructor(message: string, details?: unknown) {
super(message, 'NETWORK_ERROR', details);
}
}
class ValidationError extends AppError {
constructor(
public readonly field: string,
message: string
) {
super(message, 'VALIDATION_ERROR', { field });
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string | number) {
super(
`${resource} with ID ${id} not found`,
'NOT_FOUND',
{ resource, id }
);
}
}
// Local stand-in for fetch so the example never touches the network
const mockFetch = (url: string): Promise<{ ok: boolean; status: number; json: () => Promise<any> }> =>
Promise.resolve({
ok: true,
status: 200,
json: async () => ({ id: url.split('/').pop(), name: 'Alice' })
});
// Usage example
async function fetchUserData(userId: string): Promise<{ id: string; name: string }> {
try {
// Simulate API call
const response = await mockFetch(`/api/users/${userId}`);
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundError('User', userId);
} else if (response.status >= 500) {
throw new NetworkError('Server error', { status: response.status });
} else {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
const data = await response.json();
// Validate response data
if (!data.name) {
throw new ValidationError('name', 'Name is required');
}
return data;
} catch (error) {
if (error instanceof AppError) {
// Already one of our custom errors
throw error;
}
// Wrap unexpected errors
throw new AppError(
'Failed to fetch user data',
'UNEXPECTED_ERROR',
{ cause: error }
);
}
}
// Error handling in the application
async function displayUserProfile(userId: string) {
try {
const user = await fetchUserData(userId);
console.log('User profile:', user);
} catch (error) {
if (error instanceof NetworkError) {
console.error('Network issue:', error.message);
// Show retry UI
} else if (error instanceof ValidationError) {
console.error('Validation failed:', error.message);
// Highlight the invalid field
} else if (error instanceof NotFoundError) {
console.error('Not found:', error.message);
// Show 404 page
} else {
console.error('Unexpected error:', error);
// Show generic error message
}
}
}
// Execute with example data
displayUserProfile('123');
Async Iteration with TypeScript
TypeScript supports async iterators and async generators with full typing. A for await loop pulls values out one at a time as they become ready.
Example
generateNumbers is typed as AsyncGenerator<number, void, unknown>, so inside the for await loop num is a number. The functions are only defined here, so nothing is printed.
generateNumbers() typed as AsyncGenerator<number, void, unknown> that yields 0 through 4, waiting one second (via setTimeout in an awaited Promise) before each value.
Add async function consumeNumbers() that uses for await over the generator and logs each number doubled. Only define the functions; nothing is called, so nothing is printed.
Output: TypeScript file and captured output
// Async generator function
async function* generateNumbers(): AsyncGenerator<number, void, unknown> {
let i = 0;
while (i < 5) {
// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 1000));
yield i++;
}
}
// Using the async generator
async function consumeNumbers() {
for await (const num of generateNumbers()) {
// TypeScript knows num is a number
console.log(num * 2);
}
}