Asynchronous programming is essential in modern JavaScript development. TypeScript provides excellent support for typing Promises and async/await patterns, ensuring type safety even in asynchronous operations. Let's master async TypeScript!
Promise Basics
A Promise represents a value that may be available now, in the future, or never. TypeScript allows you to type the resolved value.
// Creating a typed Promise
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
// Promise with typed resolve value
function fetchNumber(): Promise<number> {
return new Promise((resolve, reject) => {
setTimeout(() => {
const random = Math.random();
if (random > 0.5) {
resolve(42);
} else {
reject(new Error("Random number too low"));
}
}, 1000);
});
}
// Using Promises with .then()
fetchNumber()
.then((num: number) => {
console.log("Got number:", num);
return num * 2;
})
.then((doubled: number) => {
console.log("Doubled:", doubled);
})
.catch((error: Error) => {
console.error("Error:", error.message);
});
// Promise with custom type
interface User {
id: number;
name: string;
email: string;
}
function fetchUser(id: number): Promise<User> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({
id,
name: "Yusuf Ibrahim",
email: "yusuf@example.com"
});
} else {
reject(new Error("Invalid user ID"));
}
}, 500);
});
}
// Using the typed Promise
fetchUser(1)
.then((user: User) => {
console.log(`User: ${user.name}`);
console.log(`Email: ${user.email}`);
})
.catch((error: Error) => {
console.error("Failed to fetch user:", error.message);
});
// Promise.resolve and Promise.reject
const resolvedPromise: Promise<string> = Promise.resolve("Success");
const rejectedPromise: Promise<never> = Promise.reject(new Error("Failed"));
resolvedPromise.then(value => console.log(value)); // "Success"
rejectedPromise.catch(error => console.error(error));Async Functions
Async functions always return a Promise. The return type should be wrapped in Promise<T>.
// Basic async function
async function greet(): Promise<string> {
return "Hello, World!";
}
// Equivalent to:
function greetPromise(): Promise<string> {
return Promise.resolve("Hello, World!");
}
// Async function with await
async function fetchData(): Promise<string> {
await delay(1000);
return "Data fetched";
}
// Type inference works
async function getNumber(): Promise<number> {
const num = 42; // TypeScript knows this is number
return num; // Automatically wrapped in Promise
}
// Using the async function
async function main() {
const greeting = await greet();
console.log(greeting); // Type: string
const data = await fetchData();
console.log(data); // Type: string
}
// Async function with parameters
async function calculateSum(a: number, b: number): Promise<number> {
// Simulate async operation
await delay(100);
return a + b;
}
calculateSum(5, 3).then(result => {
console.log("Sum:", result); // Type: number
});
// Async function with error handling
async function riskyOperation(): Promise<string> {
const random = Math.random();
if (random < 0.5) {
throw new Error("Operation failed");
}
return "Success";
}
async function safeCall() {
try {
const result = await riskyOperation();
console.log(result);
} catch (error) {
console.error("Error:", (error as Error).message);
}
}
// Async function returning void
async function logMessage(message: string): Promise<void> {
await delay(500);
console.log(message);
// No return statement needed for Promise<void>
}
// Helper function from earlier
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}💡 Async Functions Always Return Promises
Even if you return a plain value from an async function, it's automatically wrapped in a Promise. The return type must always be Promise<T>, never just T.
Typing Fetch Requests
// Define response types
interface User {
id: number;
name: string;
email: string;
role: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// Basic fetch with typing
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user: User = await response.json();
return user;
}
// Fetch with generic API response
async function fetchUsers(): Promise<User[]> {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const apiResponse: ApiResponse<User[]> = await response.json();
if (!apiResponse.success) {
throw new Error(apiResponse.message || "Request failed");
}
return apiResponse.data;
}
// POST request with typed body and response
interface CreateUserDto {
name: string;
email: string;
password: string;
}
async function createUser(userData: CreateUserDto): Promise<User> {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user: User = await response.json();
return user;
}
// Generic fetch function
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
return data;
}
// Using the generic function
async function loadData() {
// TypeScript knows users is User[]
const users = await fetchApi<User[]>('https://api.example.com/users');
// TypeScript knows user is User
const user = await fetchApi<User>('https://api.example.com/users/1');
console.log(users[0].name);
console.log(user.email);
}
// Error handling wrapper
async function safeFetch<T>(
url: string,
options?: RequestInit
): Promise<{ data: T | null; error: string | null }> {
try {
const data = await fetchApi<T>(url, options);
return { data, error: null };
} catch (error) {
return {
data: null,
error: (error as Error).message
};
}
}
// Using safe fetch
async function getUserSafely(id: number) {
const { data, error } = await safeFetch<User>(
`https://api.example.com/users/${id}`
);
if (error) {
console.error("Error:", error);
return;
}
console.log("User:", data?.name);
}Promise Combinators
interface User {
id: number;
name: string;
}
interface Product {
id: number;
name: string;
price: number;
}
interface Order {
id: number;
userId: number;
productIds: number[];
}
// Promise.all - wait for all promises
async function loadAllData(): Promise<void> {
const [users, products, orders] = await Promise.all([
fetchApi<User[]>('/api/users'),
fetchApi<Product[]>('/api/products'),
fetchApi<Order[]>('/api/orders')
]);
console.log(`Loaded ${users.length} users`);
console.log(`Loaded ${products.length} products`);
console.log(`Loaded ${orders.length} orders`);
}
// Promise.all with mixed types
async function loadMixedData(): Promise<[User[], number, string]> {
return Promise.all([
fetchApi<User[]>('/api/users'),
Promise.resolve(42),
Promise.resolve("hello")
]);
}
const [users, count, message] = await loadMixedData();
// users: User[]
// count: number
// message: string
// Promise.race - first to complete
async function fetchWithTimeout<T>(
url: string,
timeoutMs: number
): Promise<T> {
const fetchPromise = fetchApi<T>(url);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`Request timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
// Usage
async function loadUserWithTimeout() {
try {
const user = await fetchWithTimeout<User>('/api/users/1', 5000);
console.log("User loaded:", user);
} catch (error) {
console.error("Failed or timed out:", error);
}
}
// Promise.allSettled - wait for all, get all results
async function loadAllWithStatus() {
const results = await Promise.allSettled([
fetchApi<User[]>('/api/users'),
fetchApi<Product[]>('/api/products'),
fetchApi<Order[]>('/api/orders')
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.error(`Request ${index} failed:`, result.reason);
}
});
}
// Promise.any - first successful
async function loadFromMirrors<T>(urls: string[]): Promise<T> {
const promises = urls.map(url => fetchApi<T>(url));
return Promise.any(promises);
}
// Usage
async function loadUserFromMirrors() {
const user = await loadFromMirrors<User>([
'https://api1.example.com/users/1',
'https://api2.example.com/users/1',
'https://api3.example.com/users/1'
]);
console.log("User loaded from fastest mirror:", user);
}
// Helper function
async function fetchApi<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}Error Handling Patterns
// Custom error types
class ApiError extends Error {
constructor(
public statusCode: number,
message: string,
public response?: any
) {
super(message);
this.name = 'ApiError';
}
}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
// Fetch with detailed error handling
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
let response: Response;
try {
response = await fetch(url);
} catch (error) {
throw new NetworkError(`Network error: ${(error as Error).message}`);
}
if (!response.ok) {
const errorBody = await response.text();
throw new ApiError(
response.status,
`HTTP error ${response.status}`,
errorBody
);
}
try {
const data: T = await response.json();
return data;
} catch (error) {
throw new Error(`Invalid JSON response: ${(error as Error).message}`);
}
}
// Using typed errors
async function loadUser(id: number): Promise<User | null> {
try {
const user = await fetchWithErrorHandling<User>(`/api/users/${id}`);
return user;
} catch (error) {
if (error instanceof ApiError) {
if (error.statusCode === 404) {
console.log("User not found");
return null;
}
console.error(`API error ${error.statusCode}:`, error.message);
} else if (error instanceof NetworkError) {
console.error("Network error:", error.message);
} else {
console.error("Unknown error:", error);
}
return null;
}
}
// Result type pattern
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUserResult(id: number): Promise<Result<User>> {
try {
const user = await fetchWithErrorHandling<User>(`/api/users/${id}`);
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Using Result type
async function processUser(id: number) {
const result = await fetchUserResult(id);
if (result.success) {
console.log("User:", result.data.name);
// TypeScript knows result.data exists here
} else {
console.error("Error:", result.error.message);
// TypeScript knows result.error exists here
}
}
// Retry logic
async function fetchWithRetry<T>(
url: string,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fetchWithErrorHandling<T>(url);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (i < maxRetries - 1) {
console.log(`Retry ${i + 1}/${maxRetries - 1} after ${delayMs}ms`);
await delay(delayMs);
}
}
}
throw lastError!;
}
// Helper delay function
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
interface User {
id: number;
name: string;
email: string;
}Practical Examples
Example 1: Data Loader with Caching
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresIn: number;
}
class DataLoader<T> {
private cache = new Map<string, CacheEntry<T>>();
constructor(private defaultTTL: number = 60000) {}
async load(
key: string,
fetcher: () => Promise<T>,
ttl: number = this.defaultTTL
): Promise<T> {
// Check cache
const cached = this.cache.get(key);
if (cached) {
const age = Date.now() - cached.timestamp;
if (age < cached.expiresIn) {
console.log(`Cache hit for ${key}`);
return cached.data;
}
console.log(`Cache expired for ${key}`);
}
// Fetch fresh data
console.log(`Fetching fresh data for ${key}`);
const data = await fetcher();
// Update cache
this.cache.set(key, {
data,
timestamp: Date.now(),
expiresIn: ttl
});
return data;
}
invalidate(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
const userLoader = new DataLoader<User>();
const productLoader = new DataLoader<Product>();
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
async function fetchProduct(id: number): Promise<Product> {
const response = await fetch(`/api/products/${id}`);
return response.json();
}
async function loadUserData() {
// First call - fetches from API
const user1 = await userLoader.load(
'user:1',
() => fetchUser(1),
30000 // 30 second TTL
);
console.log(user1);
// Second call - returns from cache
const user2 = await userLoader.load(
'user:1',
() => fetchUser(1)
);
console.log(user2);
// Different user - fetches from API
const user3 = await userLoader.load(
'user:2',
() => fetchUser(2)
);
console.log(user3);
}
loadUserData();Example 2: Batch Request Handler
interface BatchRequest<T> {
id: string;
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
}
class BatchRequestHandler<TInput, TOutput> {
private queue: Map<string, BatchRequest<TOutput>> = new Map();
private batchTimeout: NodeJS.Timeout | null = null;
constructor(
private batchFn: (inputs: TInput[]) => Promise<TOutput[]>,
private delayMs: number = 10
) {}
async request(id: string, input: TInput): Promise<TOutput> {
// Check if already in queue
const existing = this.queue.get(id);
if (existing) {
return existing.promise;
}
// Create new promise
let resolve: (value: TOutput) => void;
let reject: (error: Error) => void;
const promise = new Promise<TOutput>((res, rej) => {
resolve = res;
reject = rej;
});
// Add to queue
this.queue.set(id, {
id,
promise,
resolve: resolve!,
reject: reject!
});
// Schedule batch execution
this.scheduleBatch();
return promise;
}
private scheduleBatch(): void {
if (this.batchTimeout) {
return; // Already scheduled
}
this.batchTimeout = setTimeout(() => {
this.executeBatch();
}, this.delayMs);
}
private async executeBatch(): Promise<void> {
this.batchTimeout = null;
const batch = Array.from(this.queue.values());
if (batch.length === 0) {
return;
}
console.log(`Executing batch of ${batch.length} requests`);
// Clear queue
this.queue.clear();
try {
// Execute batch
const inputs = batch.map(req => ({ id: req.id })) as TInput[];
const results = await this.batchFn(inputs);
// Resolve individual promises
batch.forEach((req, index) => {
req.resolve(results[index]);
});
} catch (error) {
// Reject all promises
batch.forEach(req => {
req.reject(error as Error);
});
}
}
}
// Usage
interface UserInput {
id: string;
}
interface User {
id: number;
name: string;
email: string;
}
async function fetchUsersBatch(inputs: UserInput[]): Promise<User[]> {
const ids = inputs.map(input => input.id).join(',');
console.log(`Fetching users: ${ids}`);
const response = await fetch(`/api/users?ids=${ids}`);
const users: User[] = await response.json();
return users;
}
const userBatcher = new BatchRequestHandler<UserInput, User>(
fetchUsersBatch,
50 // 50ms batch window
);
async function loadMultipleUsers() {
// These will be batched together
const [user1, user2, user3] = await Promise.all([
userBatcher.request('1', { id: '1' }),
userBatcher.request('2', { id: '2' }),
userBatcher.request('3', { id: '3' })
]);
console.log(user1, user2, user3);
// Only one API call made for all three users!
}
loadMultipleUsers();Example 3: Sequential and Parallel Processing
interface Task {
id: number;
name: string;
duration: number;
}
// Sequential processing - one at a time
async function processSequential(tasks: Task[]): Promise<void> {
console.log("Starting sequential processing...");
const startTime = Date.now();
for (const task of tasks) {
console.log(`Processing ${task.name}...`);
await delay(task.duration);
console.log(`Completed ${task.name}`);
}
const totalTime = Date.now() - startTime;
console.log(`Sequential total time: ${totalTime}ms`);
}
// Parallel processing - all at once
async function processParallel(tasks: Task[]): Promise<void> {
console.log("Starting parallel processing...");
const startTime = Date.now();
await Promise.all(
tasks.map(async (task) => {
console.log(`Processing ${task.name}...`);
await delay(task.duration);
console.log(`Completed ${task.name}`);
})
);
const totalTime = Date.now() - startTime;
console.log(`Parallel total time: ${totalTime}ms`);
}
// Concurrent with limit - N at a time
async function processConcurrent(
tasks: Task[],
concurrency: number
): Promise<void> {
console.log(`Starting concurrent processing (max ${concurrency})...`);
const startTime = Date.now();
const results: Promise<void>[] = [];
const executing: Promise<void>[] = [];
for (const task of tasks) {
const promise = (async () => {
console.log(`Processing ${task.name}...`);
await delay(task.duration);
console.log(`Completed ${task.name}`);
})();
results.push(promise);
if (concurrency <= tasks.length) {
const e = promise.then(() => {
executing.splice(executing.indexOf(e), 1);
});
executing.push(e);
if (executing.length >= concurrency) {
await Promise.race(executing);
}
}
}
await Promise.all(results);
const totalTime = Date.now() - startTime;
console.log(`Concurrent total time: ${totalTime}ms`);
}
// Usage
const tasks: Task[] = [
{ id: 1, name: "Task 1", duration: 1000 },
{ id: 2, name: "Task 2", duration: 2000 },
{ id: 3, name: "Task 3", duration: 1500 },
{ id: 4, name: "Task 4", duration: 1000 },
{ id: 5, name: "Task 5", duration: 500 }
];
async function compareProcessing() {
await processSequential(tasks);
// Total: ~6000ms (sum of all)
console.log("\n---\n");
await processParallel(tasks);
// Total: ~2000ms (max of all)
console.log("\n---\n");
await processConcurrent(tasks, 2);
// Total: ~3500ms (limited concurrency)
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
compareProcessing();Test Your Knowledge
Type Safety Check
Which async/await pattern is correctly typed?
// Option A
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const user: User = await response.json();
return user;
}
// Option B
async function getData() {
const data = await fetch('/api/data');
return data.json(); // What's the return type?
}
// Option C
function loadData(): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data loaded");
}, 1000);
});
}
// Option D
async function getUsers(): User[] {
const response = await fetch('/api/users');
return response.json();
}Common TypeScript Error
// Missing Promise wrapper in return type
async function fetchData(): string { // ❌ Error!
const response = await fetch('/api/data');
const data = await response.json();
return data;
}
// Wrong: async function returns string
// Right: async function returns Promise<string>
// Another error: not handling rejection
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
// No error handling - will crash on network error!
return user;
}
const user = await getUser(1);❌ The return type of an async function must be the global Promise<T> type. Did you mean to write Promise<string>?
What's Wrong?
Async functions always return a Promise, even if you just return a value. TypeScript requires you to specify Promise<T> as the return type. Also, always handle potential errors in async code.
// ✅ Correct return type with Promise
async function fetchData(): Promise<string> {
const response = await fetch('/api/data');
const data = await response.json();
return data;
}
// ✅ With error handling
async function getUser(id: number): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user: User = await response.json();
return user;
} catch (error) {
console.error("Failed to fetch user:", error);
throw error; // Re-throw or handle appropriately
}
}
// ✅ Using the function with error handling
async function loadUserData() {
try {
const user = await getUser(1);
console.log("User loaded:", user);
} catch (error) {
console.error("Error loading user:", error);
}
}
// ✅ With type guard for error
async function safeFetchUser(id: number): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return null; // Return null instead of throwing
}
const user: User = await response.json();
return user;
} catch (error) {
console.error("Error:", (error as Error).message);
return null;
}
}Solution: Add Promise wrapper to return type and handle errors
Best Practices
- Always type Promise return types explicitly - Promise<T>
- Use try/catch for error handling in async functions
- Handle both success and error cases - don't assume success
- Type error objects with
as Erroror custom error types - Use Promise.all for parallel operations - faster than sequential
- Add timeouts for network requests - prevent hanging
- Clean up pending promises - avoid memory leaks
- Consider retry logic for failed network requests
// ✅ Good: Explicit Promise type
async function fetchData(): Promise<string> {
const response = await fetch('/api/data');
return response.text();
}
// ✅ Good: Error handling
async function safeLoad(): Promise<User | null> {
try {
const user = await fetchUser(1);
return user;
} catch (error) {
console.error("Error:", (error as Error).message);
return null;
}
}
// ✅ Good: Type errors properly
async function loadWithTypedError() {
try {
await riskyOperation();
} catch (error) {
if (error instanceof ApiError) {
console.log("API error:", error.statusCode);
} else if (error instanceof Error) {
console.log("Error:", error.message);
}
}
}
// ✅ Good: Parallel operations
async function loadAll() {
const [users, products] = await Promise.all([
fetchUsers(),
fetchProducts()
]);
return { users, products };
}
// ❌ Avoid: Missing Promise wrapper
async function bad(): string { // Should be Promise<string>
return "data";
}
// ❌ Avoid: Unhandled errors
async function alsoBad() {
const data = await fetch('/api/data');
// No error handling - will crash on failure!
return data.json();
}
// ❌ Avoid: Sequential when parallel possible
async function slow() {
const users = await fetchUsers(); // Wait
const products = await fetchProducts(); // Then wait again
// Better: await Promise.all([...])
}
interface User { id: number; name: string; }
async function fetchUser(id: number): Promise<User> {
return { id, name: "User" };
}
async function fetchUsers(): Promise<User[]> { return []; }
async function fetchProducts(): Promise<any[]> { return []; }
async function riskyOperation(): Promise<void> {}
class ApiError extends Error { statusCode = 500; }Key Takeaways
- Async functions always return
Promise<T> - Type the resolved value with generic parameter
- Use
awaitto unwrap Promise values - Always handle errors with try/catch in async code
- Promise.all runs promises in parallel
- Promise.race returns first completed promise
- Promise.allSettled waits for all, regardless of success/failure
- Type error objects explicitly in catch blocks
- Fetch API returns
Promise<Response> - Generic functions work great with Promises
What's Next?
Excellent work! You've mastered asynchronous TypeScript with Promises and async/await. Next, we'll explore Working with APIs and JSON—how to type API responses, handle JSON data, create API clients, and work with REST APIs in TypeScript. This builds directly on async/await patterns!
Get ready to master API integration with TypeScript!