From Zero to AI

Lesson 3.4: Typing Async Functions

Duration: 50 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Properly type async functions with Promise return types
  2. Use type inference effectively with async/await
  3. Type complex async patterns including generics
  4. Handle unknown error types in catch blocks
  5. Create reusable typed async utilities

Introduction

TypeScript and async/await work beautifully together. The type system understands that async functions return Promises, and await automatically unwraps the Promise type. This lesson covers how to leverage TypeScript's type system to write safer asynchronous code.


Basic Async Function Types

Every async function returns a Promise. TypeScript wraps your return type in Promise<T> automatically.

// Explicit return type
async function fetchNumber(): Promise<number> {
  return 42;
}

// TypeScript infers Promise<number>
async function fetchNumberInferred() {
  return 42;
}

// Both functions have the same type: () => Promise<number>

The await Operator and Types

When you await a Promise, TypeScript gives you the resolved type:

async function example(): Promise<void> {
  const promise: Promise<string> = fetchName();
  const name: string = await promise; // await unwraps Promise<string> to string
  console.log(name);
}

Return Type Best Practices

Always explicitly annotate return types for public functions:

// Good - explicit return type
async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

// Less ideal - inferred type
async function getUserInferred(id: number) {
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // Returns Promise<any>
}

The inferred version returns Promise<any> because response.json() returns any. Explicit types catch this problem.


Typing Async Function Parameters

Function parameters work the same as in synchronous functions:

interface FetchOptions {
  timeout?: number;
  retries?: number;
  headers?: Record<string, string>;
}

async function fetchData(url: string, options: FetchOptions = {}): Promise<unknown> {
  const { timeout = 5000, retries = 3 } = options;
  // Implementation
}

Callback Parameters

When passing async functions as callbacks, type them properly:

// Type for an async callback
type AsyncCallback<T, R> = (item: T) => Promise<R>;

async function mapAsync<T, R>(items: T[], callback: AsyncCallback<T, R>): Promise<R[]> {
  return Promise.all(items.map(callback));
}

// Usage
const users = await mapAsync([1, 2, 3], async (id) => fetchUser(id));

Generic Async Functions

Generics make async utilities flexible and type-safe.

Basic Generic Async Function

async function fetchAndParse<T>(url: string): Promise<T> {
  const response = await fetch(url);
  const data: T = await response.json();
  return data;
}

// Usage - specify the expected type
interface User {
  id: number;
  name: string;
}

const user = await fetchAndParse<User>('/api/users/1');
// user is typed as User

Generic Wrapper Functions

async function withRetry<T>(operation: () => Promise<T>, maxRetries: number = 3): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));
      if (attempt < maxRetries) {
        await delay(1000 * attempt);
      }
    }
  }

  throw lastError;
}

// Usage - T is inferred from the operation
const user = await withRetry(() => fetchUser(1));
// user is typed as User

Generic Result Types

interface AsyncResult<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
}

async function safeAsync<T>(operation: () => Promise<T>): Promise<AsyncResult<T>> {
  try {
    const data = await operation();
    return { data, error: null, loading: false };
  } catch (error) {
    return {
      data: null,
      error: error instanceof Error ? error : new Error(String(error)),
      loading: false,
    };
  }
}

// Usage
const result = await safeAsync(() => fetchUser(1));
if (result.error) {
  console.error(result.error.message);
} else {
  console.log(result.data?.name); // data is User | null
}

Typing Error Handling

In TypeScript strict mode, caught errors are typed as unknown. You must narrow the type before using error properties.

Narrowing Error Types

async function handleRequest(): Promise<void> {
  try {
    await riskyOperation();
  } catch (error: unknown) {
    // Cannot access error.message directly - error is unknown

    if (error instanceof Error) {
      // Now TypeScript knows it's an Error
      console.error('Message:', error.message);
      console.error('Stack:', error.stack);
    } else {
      console.error('Unknown error:', error);
    }
  }
}

Type Guards for Custom Errors

interface ApiError {
  code: string;
  message: string;
  statusCode: number;
}

function isApiError(error: unknown): error is ApiError {
  return (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    'message' in error &&
    'statusCode' in error &&
    typeof (error as ApiError).code === 'string' &&
    typeof (error as ApiError).message === 'string' &&
    typeof (error as ApiError).statusCode === 'number'
  );
}

async function callApi<T>(url: string): Promise<T> {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      const errorBody = await response.json();
      throw errorBody;
    }
    return response.json();
  } catch (error: unknown) {
    if (isApiError(error)) {
      // Full type safety here
      console.error(`API Error ${error.code}: ${error.message}`);
      if (error.statusCode === 401) {
        // Handle unauthorized
      }
    }
    throw error;
  }
}

Creating a Typed Error Handler

type ErrorHandler = (error: unknown) => void;

function createErrorHandler(handlers: {
  onApiError?: (error: ApiError) => void;
  onNetworkError?: (error: TypeError) => void;
  onUnknownError?: (error: unknown) => void;
}): ErrorHandler {
  return (error: unknown) => {
    if (isApiError(error) && handlers.onApiError) {
      handlers.onApiError(error);
    } else if (error instanceof TypeError && handlers.onNetworkError) {
      handlers.onNetworkError(error);
    } else if (handlers.onUnknownError) {
      handlers.onUnknownError(error);
    }
  };
}

// Usage
const handleError = createErrorHandler({
  onApiError: (e) => console.log(`API: ${e.code}`),
  onNetworkError: (e) => console.log(`Network: ${e.message}`),
  onUnknownError: (e) => console.log('Unknown:', e),
});

Typing Promise Utilities

Typed Promise.all

TypeScript infers tuple types for Promise.all:

async function loadDashboard(): Promise<[User, Post[], Notification[]]> {
  const result = await Promise.all([
    fetchUser(1), // Promise<User>
    fetchPosts(), // Promise<Post[]>
    fetchNotifications(), // Promise<Notification[]>
  ]);

  // result is [User, Post[], Notification[]]
  const [user, posts, notifications] = result;

  return [user, posts, notifications];
}

Typed Promise.allSettled

async function fetchMultipleUsers(ids: number[]): Promise<User[]> {
  const results: PromiseSettledResult<User>[] = await Promise.allSettled(
    ids.map((id) => fetchUser(id))
  );

  return results
    .filter((r): r is PromiseFulfilledResult<User> => r.status === 'fulfilled')
    .map((r) => r.value);
}

Custom Async Utility Types

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

// Example usage
type UserPromise = Promise<User>;
type ResolvedUser = Awaited<UserPromise>; // User

// Type for an async function
type AsyncFunction<T extends unknown[], R> = (...args: T) => Promise<R>;

// Usage
const fetchUserTyped: AsyncFunction<[number], User> = async (id) => {
  return fetchUser(id);
};

Typing Async Iterators

For streaming data or paginated APIs, use async iterators:

interface Page<T> {
  items: T[];
  nextCursor: string | null;
}

async function* fetchAllPages<T>(
  fetchPage: (cursor?: string) => Promise<Page<T>>
): AsyncGenerator<T, void, undefined> {
  let cursor: string | undefined;

  while (true) {
    const page = await fetchPage(cursor);

    for (const item of page.items) {
      yield item;
    }

    if (!page.nextCursor) break;
    cursor = page.nextCursor;
  }
}

// Usage
async function processAllUsers(): Promise<void> {
  for await (const user of fetchAllPages<User>(fetchUserPage)) {
    console.log(user.name);
  }
}

Common Type Patterns

Conditional Async Returns

async function fetchOptional<T>(url: string, required: boolean): Promise<T | null> {
  try {
    const response = await fetch(url);
    if (!response.ok && !required) {
      return null;
    }
    return response.json();
  } catch (error) {
    if (required) throw error;
    return null;
  }
}

Overloaded Async Functions

// Overload signatures
async function getData(id: number): Promise<User>;
async function getData(ids: number[]): Promise<User[]>;
async function getData(idOrIds: number | number[]): Promise<User | User[]> {
  if (Array.isArray(idOrIds)) {
    return Promise.all(idOrIds.map((id) => fetchUser(id)));
  }
  return fetchUser(idOrIds);
}

// Usage
const user = await getData(1); // User
const users = await getData([1, 2]); // User[]

Exercise

Create a typed async cache utility with the following requirements:

  1. Generic type parameter for cached values
  2. Async fetch function when cache misses
  3. Expiration time support
  4. Type-safe get and set operations

Solution:

interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}

class AsyncCache<T> {
  private cache: Map<string, CacheEntry<T>> = new Map();

  constructor(private defaultTtlMs: number = 60000) {}

  async get(key: string, fetcher: () => Promise<T>, ttlMs?: number): Promise<T> {
    const cached = this.cache.get(key);

    if (cached && cached.expiresAt > Date.now()) {
      return cached.value;
    }

    const value = await fetcher();
    this.set(key, value, ttlMs);
    return value;
  }

  set(key: string, value: T, ttlMs?: number): void {
    this.cache.set(key, {
      value,
      expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
    });
  }

  delete(key: string): boolean {
    return this.cache.delete(key);
  }

  clear(): void {
    this.cache.clear();
  }
}

// Usage
const userCache = new AsyncCache<User>(300000); // 5 min default TTL

const user = await userCache.get(`user:${userId}`, () => fetchUser(userId));

Key Takeaways

  1. Async functions always return Promise<T> - TypeScript handles this automatically
  2. Explicit return types on public functions prevent any from sneaking in
  3. Use generics to create reusable, type-safe async utilities
  4. Caught errors are unknown - use type guards to narrow them
  5. Promise.all preserves tuple types for typed destructuring
  6. Create custom type guards for domain-specific error types

Resources

Resource Type Level
TypeScript Handbook: Generics Documentation Intermediate
TypeScript Handbook: Narrowing Documentation Intermediate
TypeScript: Everyday Types Documentation Beginner

Next Lesson

Continue to Lesson 3.5: Practice - Async Data Loader to build a complete, typed async data loader applying all concepts from this module.