Documentation/Buki/Typescript/ skills /typescript-async-patterns

📖 typescript-async-patterns

Use when typeScript async patterns including Promises, async/await, and async iterators with proper typing. Use when writing asynchronous TypeScript code.



Overview

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

  1. Always Handle Errors: Use try-catch with async/await or .catch() with Promises. Never let errors go unhandled in async code.

  2. Avoid Mixing Paradigms: Choose either Promise chains or async/await for a given flow. Mixing them makes code harder to read and maintain.

  3. Use Promise.all for Parallel Operations: When operations are independent, use Promise.all to run them in parallel rather than sequentially.

  4. Type Promise Return Values: Always explicitly type the return value of async functions and Promises for better type safety and IDE support.

  5. Handle Race Conditions: Be careful with shared state in async code. Use proper synchronization or immutable data structures.

  6. Set Timeouts for Network Requests: Always add timeouts to prevent hanging requests. Use AbortController or Promise.race.

  7. Implement Proper Cleanup: Use finally blocks or try/finally to ensure cleanup code runs regardless of success or failure.

  8. Avoid Async in Constructors: Constructors cannot be async. Use factory functions or initialization methods instead.

  9. Use AbortController for Cancellation: Prefer standard AbortController over custom cancellation for better browser/Node.js compatibility.

  10. Document Async Behavior: Clearly document what async functions do, what they return, and what errors they might throw.

Common Pitfalls

  1. Forgetting await: Forgetting await on async functions returns a Promise instead of the resolved value, causing type errors and bugs.

  2. Sequential When Parallel Is Better: Using await in loops when operations could run in parallel leads to poor performance.

  3. Unhandled Promise Rejections: Not catching errors in Promises or async functions can crash Node.js applications or cause silent failures.

  4. Floating Promises: Not awaiting or handling Promises (fire-and-forget) can cause unhandled rejections and race conditions.

  5. Promise Constructor Anti-pattern: Wrapping already-promisified functions in new Promise is unnecessary and adds complexity.

  6. Async IIFE Mistakes: Forgetting to await or handle errors from immediately-invoked async functions causes silent failures.

  7. Wrong Error Type Assumptions: Assuming all errors are Error instances. Use proper type checking or type guards.

  8. Memory Leaks in Async Iterators: Not properly cleaning up async iterators can cause memory leaks, especially with infinite streams.

  9. Ignoring Cancellation: Not implementing cancellation for long-running operations wastes resources and degrades user experience.

  10. 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

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

Tools