TypeScript Blitz Day 10: Async/Await & Promises

Master asynchronous TypeScript - Promise typing, async/await patterns, Result types for error handling, Promise.all with tuples, async generators, and advanced async patterns for bulletproof code.

10 min read

Goal

After this lesson you’ll type asynchronous code perfectly and handle errors like a pro.


Theory Part (20 min)

1. Promise Typing - Promise<T>

// Basic Promise typing
const promise: Promise<string> = new Promise((resolve, reject) => {
  setTimeout(() => resolve("Hello"), 1000);
});

// Promises with different types
const numberPromise: Promise<number> = Promise.resolve(42);
const voidPromise: Promise<void> = saveData();

interface User {
  id: number;
  name: string;
}

const userPromise: Promise<User> = fetchUser(1);

// Promise can be rejected - error type is always unknown
promise
  .then((value: string) => console.log(value))
  .catch((error: unknown) => {
    if (error instanceof Error) {
      console.error(error.message);
    }
  });

// Key principles:
// 1. Promise is always generic: Promise<T>
// 2. Type T is the resolved value type
// 3. Error in reject() is typically unknown or Error
// 4. Use void for operations without return values

2. Async Function Return Types

// Async functions automatically return Promise
async function getName(): Promise<string> {
  return "John"; // Returns Promise<string>
}

// TypeScript automatically infers Promise
async function getAge() {
  return 25; // Inferred as Promise<number>
}

// Explicit typing is recommended for clarity
async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// Async function with void
async function logMessage(msg: string): Promise<void> {
  console.log(msg);
  await delay(1000);
}

// Important: async function ALWAYS returns Promise<T>
// Even if you return a plain value
async function getValue(): Promise<number> {
  return 42; // Wrapped in Promise automatically
}

// Wrong - don't return Promise<Promise<T>>
async function wrongWay(): Promise<Promise<number>> {
  return Promise.resolve(42); // TypeScript error!
}

3. Error Handling in Async/Await

// Traditional try-catch
async function fetchUserTraditional(id: number): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${id}`);
    return await response.json();
  } catch (error) {
    if (error instanceof Error) {
      console.error('Error:', error.message);
    }
    return null;
  }
}

// Result type pattern - explicit error handling
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

// Custom error types
type ApiError = {
  code: string;
  message: string;
  status: number;
};

// Type-safe async with Result
async function fetchUser(id: number): Promise<Result<User, ApiError>> {
  try {
    const response = await fetch(`/api/users/${id}`);
    
    if (!response.ok) {
      return {
        success: false,
        error: {
          code: 'FETCH_ERROR',
          message: response.statusText,
          status: response.status
        }
      };
    }
    
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: {
        code: 'NETWORK_ERROR',
        message: error instanceof Error ? error.message : 'Unknown error',
        status: 0
      }
    };
  }
}

// Using Result type with type narrowing
async function displayUser(id: number) {
  const result = await fetchUser(id);
  
  if (result.success) {
    console.log(result.data.name); // Type: User
  } else {
    console.error(result.error.message); // Type: ApiError
  }
}

// Why use Result type?
// 1. Errors are explicit in the type system
// 2. Forces you to handle errors
// 3. Better than try-catch for API calls
// 4. Type-safe error handling

4. Promise.all and Tuple Types

// Promise.all with tuple types
async function loadDashboard() {
  const [users, posts, comments] = await Promise.all([
    fetchUsers(),    // Promise<User[]>
    fetchPosts(),    // Promise<Post[]>
    fetchComments()  // Promise<Comment[]>
  ]);
  
  // TypeScript knows exact types:
  // users: User[]
  // posts: Post[]
  // comments: Comment[]
  
  return { users, posts, comments };
}

// Explicit tuple typing
async function loadData(): Promise<[User[], Post[], number]> {
  return Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchCommentCount()
  ]);
}

// Promise.all with different types
interface Stats {
  totalUsers: number;
  totalPosts: number;
}

async function loadStats(userId: number) {
  const [profile, stats, notifications] = await Promise.all([
    fetchUser(userId),          // Promise<User>
    fetchStats(userId),         // Promise<Stats>
    fetchNotifications(userId)  // Promise<Notification[]>
  ]);
  
  return { profile, stats, notifications };
}

// Promise.allSettled for handling failures
async function loadWithFailures() {
  const results = await Promise.allSettled([
    fetchUsers(),
    fetchPosts(),
    fetchComments()
  ]);
  
  results.forEach((result) => {
    if (result.status === 'fulfilled') {
      console.log('Success:', result.value);
    } else {
      console.error('Failed:', result.reason);
    }
  });
}

// Promise.race - first to resolve
async function fetchWithTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), timeoutMs);
  });
  
  return Promise.race([promise, timeout]);
}

5. Async Generators - AsyncIterator<T>

// Basic async generator
async function* numberGenerator(): AsyncGenerator<number> {
  for (let i = 0; i < 5; i++) {
    await delay(1000);
    yield i;
  }
}

// Consuming async generator
async function consumeNumbers() {
  for await (const num of numberGenerator()) {
    console.log(num); // Type: number
  }
}

// Async generator with API pagination
interface PageResponse<T> {
  data: T[];
  nextPage: number | null;
}

async function* fetchAllUsers(): AsyncGenerator<User> {
  let page = 1;
  
  while (true) {
    const response: PageResponse<User> = await fetchPage(page);
    
    for (const user of response.data) {
      yield user;
    }
    
    if (!response.nextPage) break;
    page = response.nextPage;
  }
}

// Processing paginated data
async function processAllUsers() {
  for await (const user of fetchAllUsers()) {
    console.log(user.name); // Process each user
  }
}

// AsyncGenerator with return and throw
async function* dataStream(): AsyncGenerator<string, void, unknown> {
  try {
    yield "Start";
    yield "Processing";
    yield "Complete";
  } catch (error) {
    console.error('Stream error:', error);
  }
}

// Why use async generators?
// 1. Memory efficient - process one item at a time
// 2. Great for pagination
// 3. Streaming data
// 4. Lazy evaluation

6. Advanced Async Patterns

// Retry logic with exponential backoff
async function retry<T>(
  fn: () => Promise<T>,
  maxAttempts: number = 3,
  delay: number = 1000
): Promise<T> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) throw error;
      await new Promise(resolve => 
        setTimeout(resolve, delay * attempt)
      );
    }
  }
  throw new Error('Max attempts reached');
}

// Usage
const user = await retry(() => fetchUser(1), 3, 1000);

// Timeout wrapper
async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), timeoutMs);
  });
  
  return Promise.race([promise, timeout]);
}

// Usage
const data = await withTimeout(fetchData(), 5000);

// Parallel with concurrency limit
async function executeWithLimit<T, R>(
  items: T[],
  limit: number,
  fn: (item: T) => Promise<R>
): Promise<R[]> {
  const results: R[] = [];
  const executing: Promise<void>[] = [];
  
  for (const item of items) {
    const promise = fn(item).then(result => {
      results.push(result);
    });
    
    executing.push(promise);
    
    if (executing.length >= limit) {
      await Promise.race(executing);
      executing.splice(
        executing.findIndex(p => p === promise),
        1
      );
    }
  }
  
  await Promise.all(executing);
  return results;
}

// Usage - process 5 items at a time
const results = await executeWithLimit(
  userIds,
  5,
  (id) => fetchUser(id)
);

7. Type Utilities for Async

// Extract Promise resolved type
type PromiseValue<T> = T extends Promise<infer U> ? U : T;

async function getUser(): Promise<User> {
  return { id: 1, name: "John" };
}

type UserType = PromiseValue<ReturnType<typeof getUser>>; // User

// Awaited utility type (built-in)
type User2 = Awaited<ReturnType<typeof getUser>>; // User

// Make all properties async
type AsyncObject<T> = {
  [K in keyof T]: Promise<T[K]>;
};

interface Config {
  apiUrl: string;
  timeout: number;
}

type AsyncConfig = AsyncObject<Config>;
// { apiUrl: Promise<string>; timeout: Promise<number> }

// Unwrap nested Promises
type DeepAwaited<T> = T extends Promise<infer U>
  ? DeepAwaited<U>
  : T;

type Nested = Promise<Promise<Promise<number>>>;
type Unwrapped = DeepAwaited<Nested>; // number

Practice Part (25 min)

Exercise: Build a Type-Safe API Client

Create a complete API client with proper error handling and async patterns.

// TODO: Define base types
type User = {
  id: number;
  name: string;
  email: string;
};

type Post = {
  id: number;
  userId: number;
  title: string;
  content: string;
};

// TODO: Define Result type for error handling
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

// TODO: Define ApiError type
type ApiError = {
  code: string;
  message: string;
  status: number;
};

// TODO: Create ApiClient class
class ApiClient {
  constructor(private baseUrl: string) {}
  
  // Implement GET request
  async get<T>(endpoint: string): Promise<Result<T, ApiError>> {
    // Your implementation
  }
  
  // Implement POST request
  async post<T, B>(
    endpoint: string, 
    body: B
  ): Promise<Result<T, ApiError>> {
    // Your implementation
  }
}

// TODO: Create UserApi class
class UserApi {
  constructor(private client: ApiClient) {}
  
  async getUser(id: number): Promise<Result<User, ApiError>> {
    // Your implementation
  }
  
  async getAllUsers(): Promise<Result<User[], ApiError>> {
    // Your implementation
  }
  
  async createUser(
    user: Omit<User, 'id'>
  ): Promise<Result<User, ApiError>> {
    // Your implementation
  }
}

// TODO: Create loadDashboard function with Promise.all
async function loadDashboard(userId: number) {
  // Fetch user, posts, and stats in parallel
  // Return typed object with all data
}

// TODO: Implement retry logic
async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxAttempts: number = 3
): Promise<T> {
  // Your implementation
}

// Usage example
async function main() {
  const client = new ApiClient('https://api.example.com');
  const userApi = new UserApi(client);
  
  const result = await userApi.getUser(1);
  
  if (result.success) {
    console.log('User:', result.data.name);
  } else {
    console.error('Error:', result.error.message);
  }
}

Solution:

Click to see solution
// Base types
type User = {
  id: number;
  name: string;
  email: string;
};

type Post = {
  id: number;
  userId: number;
  title: string;
  content: string;
};

type Stats = {
  totalPosts: number;
  totalComments: number;
};

// Result type for error handling
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

// ApiError type
type ApiError = {
  code: string;
  message: string;
  status: number;
};

// ApiClient implementation
class ApiClient {
  constructor(private baseUrl: string) {}
  
  async get<T>(endpoint: string): Promise<Result<T, ApiError>> {
    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`);
      
      if (!response.ok) {
        return {
          success: false,
          error: {
            code: 'API_ERROR',
            message: response.statusText,
            status: response.status
          }
        };
      }
      
      const data = await response.json();
      return { success: true, data };
    } catch (error) {
      return {
        success: false,
        error: {
          code: 'NETWORK_ERROR',
          message: error instanceof Error ? error.message : 'Unknown error',
          status: 0
        }
      };
    }
  }
  
  async post<T, B>(
    endpoint: string, 
    body: B
  ): Promise<Result<T, ApiError>> {
    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
      });
      
      if (!response.ok) {
        return {
          success: false,
          error: {
            code: 'API_ERROR',
            message: response.statusText,
            status: response.status
          }
        };
      }
      
      const data = await response.json();
      return { success: true, data };
    } catch (error) {
      return {
        success: false,
        error: {
          code: 'NETWORK_ERROR',
          message: error instanceof Error ? error.message : 'Unknown error',
          status: 0
        }
      };
    }
  }
}

// UserApi implementation
class UserApi {
  constructor(private client: ApiClient) {}
  
  async getUser(id: number): Promise<Result<User, ApiError>> {
    return this.client.get<User>(`/users/${id}`);
  }
  
  async getAllUsers(): Promise<Result<User[], ApiError>> {
    return this.client.get<User[]>('/users');
  }
  
  async createUser(
    user: Omit<User, 'id'>
  ): Promise<Result<User, ApiError>> {
    return this.client.post<User, Omit<User, 'id'>>('/users', user);
  }
}

// Dashboard with Promise.all
type DashboardData = {
  user: User;
  posts: Post[];
  stats: Stats;
};

async function loadDashboard(
  userId: number
): Promise<Result<DashboardData, ApiError>> {
  const client = new ApiClient('https://api.example.com');
  
  try {
    const [userResult, postsResult, statsResult] = await Promise.all([
      client.get<User>(`/users/${userId}`),
      client.get<Post[]>(`/users/${userId}/posts`),
      client.get<Stats>(`/users/${userId}/stats`)
    ]);
    
    // Check if all succeeded
    if (!userResult.success) return userResult;
    if (!postsResult.success) return postsResult;
    if (!statsResult.success) return statsResult;
    
    return {
      success: true,
      data: {
        user: userResult.data,
        posts: postsResult.data,
        stats: statsResult.data
      }
    };
  } catch (error) {
    return {
      success: false,
      error: {
        code: 'UNKNOWN_ERROR',
        message: error instanceof Error ? error.message : 'Unknown error',
        status: 0
      }
    };
  }
}

// Retry implementation
async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  maxAttempts: number = 3,
  delay: number = 1000
): Promise<T> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxAttempts) throw error;
      await new Promise(resolve => 
        setTimeout(resolve, delay * attempt)
      );
    }
  }
  throw new Error('Max attempts reached');
}

// Usage
async function main() {
  const client = new ApiClient('https://api.example.com');
  const userApi = new UserApi(client);
  
  // Get single user
  const userResult = await userApi.getUser(1);
  
  if (userResult.success) {
    console.log('User:', userResult.data.name);
  } else {
    console.error('Error:', userResult.error.message);
  }
  
  // Load dashboard
  const dashboard = await loadDashboard(1);
  
  if (dashboard.success) {
    console.log('User:', dashboard.data.user.name);
    console.log('Posts:', dashboard.data.posts.length);
    console.log('Total posts:', dashboard.data.stats.totalPosts);
  } else {
    console.error('Dashboard error:', dashboard.error.message);
  }
  
  // With retry
  const userWithRetry = await fetchWithRetry(
    () => userApi.getUser(1),
    3,
    1000
  );
  
  if (userWithRetry.success) {
    console.log('User with retry:', userWithRetry.data.name);
  }
}

main();

Quick Review - Key Concepts

1. How do you type Promises correctly?

Answer: Always use Promise<T> where T is the resolved value type.

const promise: Promise<string> = fetchName();
const numbers: Promise<number[]> = fetchNumbers();
const nothing: Promise<void> = saveData();

// Async functions automatically return Promise<T>
async function getData(): Promise<User> {
  return { id: 1, name: "John" };
}

The type inside Promise<T> is the resolved value, not the Promise itself.


2. What’s the difference between try-catch and Result types?

Try-catch - traditional error handling:

async function fetch(id: number): Promise<User | null> {
  try {
    return await fetchUser(id);
  } catch (error) {
    console.error(error);
    return null;
  }
}

Result type - explicit error handling:

type Result<T, E> = 
  | { success: true; data: T }
  | { success: false; error: E };

async function fetch(id: number): Promise<Result<User, Error>> {
  try {
    const data = await fetchUser(id);
    return { success: true, data };
  } catch (error) {
    return { success: false, error: error as Error };
  }
}

Result types make errors explicit in the type system and force handling.


3. How does Promise.all work with tuple types?

Answer: Promise.all preserves individual types in a tuple.

async function loadData() {
  const [users, count, config] = await Promise.all([
    fetchUsers(),    // Promise<User[]>
    getCount(),      // Promise<number>
    loadConfig()     // Promise<Config>
  ]);
  
  // TypeScript knows:
  // users: User[]
  // count: number
  // config: Config
}

Promise.all with an array literal creates a tuple type, preserving each Promise’s type.


4. How do async generators work?

Answer: Use AsyncGenerator<T> for streaming async data.

async function* fetchPages(): AsyncGenerator<User[]> {
  let page = 1;
  while (page <= 10) {
    yield await fetchPage(page);
    page++;
  }
}

// Consume with for await...of
for await (const users of fetchPages()) {
  console.log(users); // Type: User[]
}

Async generators are great for pagination, streaming, and lazy evaluation.


5. How to handle multiple Promise failures?

Answer: Use Promise.allSettled when you want to handle both successes and failures.

const results = await Promise.allSettled([
  fetchUsers(),
  fetchPosts(),
  fetchComments()
]);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log(result.value); // Success data
  } else {
    console.error(result.reason); // Error
  }
});

Unlike Promise.all, allSettled never rejects - it returns status for each Promise.


Checklist

  • Type Promises with Promise<T>
  • Async functions always return Promise
  • Use Result types for explicit error handling
  • Type-safe Promise.all with tuples
  • Async generators with AsyncGenerator
  • Implement retry logic with proper types
  • Use withTimeout for Promise timeouts
  • Promise.allSettled vs Promise.all
  • Type-safe API clients with error handling
  • Handle errors with type guards

Quick Reference

// ============================================
// ASYNC/AWAIT & PROMISES CHEAT SHEET
// ============================================

// 1. PROMISE TYPING
const promise: Promise<string> = fetchData();
const asyncFn = async (): Promise<number> => 42;

// 2. RESULT TYPE PATTERN
type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

async function safeFetch<T>(url: string): Promise<Result<T>> {
  try {
    const res = await fetch(url);
    const data = await res.json();
    return { success: true, data };
  } catch (error) {
    return { 
      success: false, 
      error: error as Error 
    };
  }
}

// 3. PROMISE.ALL WITH TUPLES
const [users, posts, count] = await Promise.all([
  fetchUsers(),    // User[]
  fetchPosts(),    // Post[]
  getCount()       // number
]);

// 4. PROMISE.ALLSETTLED
const results = await Promise.allSettled([
  promise1, 
  promise2
]);

results.forEach(r => {
  if (r.status === 'fulfilled') {
    console.log(r.value);
  } else {
    console.error(r.reason);
  }
});

// 5. ASYNC GENERATOR
async function* streamData(): AsyncGenerator<Data> {
  while (hasMore) {
    yield await fetchNext();
  }
}

for await (const item of streamData()) {
  process(item);
}

// 6. RETRY LOGIC
async function retry<T>(
  fn: () => Promise<T>,
  attempts: number
): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      if (i === attempts - 1) throw err;
    }
  }
  throw new Error('Failed');
}

// 7. WITH TIMEOUT
async function withTimeout<T>(
  promise: Promise<T>,
  ms: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error('Timeout')), ms)
  );
  return Promise.race([promise, timeout]);
}

// 8. TYPE UTILITIES
type PromiseValue<T> = T extends Promise<infer U> ? U : T;
type User = PromiseValue<ReturnType<typeof getUser>>;

// Built-in Awaited
type User2 = Awaited<ReturnType<typeof getUser>>;

🎉 Congratulations! You’ve completed the TypeScript Blitz series! You now have a solid foundation in TypeScript - from basic types to advanced async patterns. Keep practicing and building real projects to master these concepts!

Back to blog