TypeScript

TypeScript with REST APIs

35 min Lesson 31 of 40

TypeScript with REST APIs

TypeScript provides excellent support for working with REST APIs by enabling type-safe HTTP requests, typed responses, and structured error handling. In this lesson, we'll explore how to type API responses, use Axios and Fetch with TypeScript, and build robust API client patterns that catch errors at compile time rather than runtime.

Why Type Your API Layer?

Typing your API interactions provides several critical benefits:

  • Type Safety: Catch API response structure mismatches before deployment
  • Autocomplete: Get IntelliSense for all API response properties
  • Refactoring: Confidently rename or restructure API models throughout your codebase
  • Documentation: Types serve as living documentation of your API contracts
  • Error Prevention: Eliminate undefined property access errors

Typing API Response Models

Start by defining TypeScript interfaces or types for your API response structures:

API Response Types:
// User resource type
interface User {
  id: number;
  email: string;
  username: string;
  firstName: string;
  lastName: string;
  avatar?: string;
  createdAt: string;
  updatedAt: string;
}

// Paginated response wrapper
interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    perPage: number;
    totalPages: number;
  };
  links: {
    first: string;
    last: string;
    prev: string | null;
    next: string | null;
  };
}

// API error structure
interface ApiError {
  message: string;
  errors?: Record<string, string[]>;
  statusCode: number;
}

// Single resource response
interface SingleResponse<T> {
  data: T;
  message?: string;
}

// Success response without data
interface SuccessResponse {
  message: string;
  success: boolean;
}
Note: Define your API types in a centralized types/api.ts file so they can be imported throughout your application. Keep these types in sync with your backend API documentation.

Typing Fetch API Requests

The native Fetch API can be typed for safe, predictable API calls:

Typed Fetch Wrapper:
// Generic fetch wrapper with type safety
async function fetchApi<T>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
    ...options,
  });

  if (!response.ok) {
    const error: ApiError = await response.json();
    throw new Error(error.message || 'API request failed');
  }

  return response.json() as Promise<T>;
}

// Usage examples
async function getUser(id: number): Promise<User> {
  const response = await fetchApi<SingleResponse<User>>(
    `https://api.example.com/users/${id}`
  );
  return response.data;
}

async function getUsers(
  page: number = 1
): Promise<PaginatedResponse<User>> {
  return fetchApi<PaginatedResponse<User>>(
    `https://api.example.com/users?page=${page}`
  );
}

async function createUser(
  userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>
): Promise<User> {
  const response = await fetchApi<SingleResponse<User>>(
    'https://api.example.com/users',
    {
      method: 'POST',
      body: JSON.stringify(userData),
    }
  );
  return response.data;
}
Tip: Use utility types like Omit and Pick to create input types from your response types. This ensures consistency between what you send and what you receive from the API.

Typing Axios Requests

Axios provides excellent TypeScript support out of the box. Here's how to leverage it effectively:

Typed Axios Client:
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';

// Create typed Axios instance
class ApiClient {
  private client: AxiosInstance;

  constructor(baseURL: string) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
      },
      timeout: 10000,
    });

    // Add request interceptor for auth tokens
    this.client.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('authToken');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // Add response interceptor for error handling
    this.client.interceptors.response.use(
      (response) => response,
      (error) => {
        if (error.response?.status === 401) {
          // Handle unauthorized access
          localStorage.removeItem('authToken');
          window.location.href = '/login';
        }
        return Promise.reject(error);
      }
    );
  }

  async get<T>(
    url: string,
    config?: AxiosRequestConfig
  ): Promise<T> {
    const response: AxiosResponse<T> = await this.client.get(url, config);
    return response.data;
  }

  async post<T, D = any>(
    url: string,
    data?: D,
    config?: AxiosRequestConfig
  ): Promise<T> {
    const response: AxiosResponse<T> = await this.client.post(
      url,
      data,
      config
    );
    return response.data;
  }

  async put<T, D = any>(
    url: string,
    data?: D,
    config?: AxiosRequestConfig
  ): Promise<T> {
    const response: AxiosResponse<T> = await this.client.put(
      url,
      data,
      config
    );
    return response.data;
  }

  async delete<T>(
    url: string,
    config?: AxiosRequestConfig
  ): Promise<T> {
    const response: AxiosResponse<T> = await this.client.delete(url, config);
    return response.data;
  }
}

// Export singleton instance
export const apiClient = new ApiClient('https://api.example.com');

Building API Service Layers

Organize your API calls into service classes for better structure and reusability:

User Service Example:
class UserService {
  private basePath = '/users';

  async getAll(params?: {
    page?: number;
    perPage?: number;
    search?: string;
  }): Promise<PaginatedResponse<User>> {
    return apiClient.get<PaginatedResponse<User>>(this.basePath, {
      params,
    });
  }

  async getById(id: number): Promise<User> {
    const response = await apiClient.get<SingleResponse<User>>(
      `${this.basePath}/${id}`
    );
    return response.data;
  }

  async create(
    userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>
  ): Promise<User> {
    const response = await apiClient.post<SingleResponse<User>>(
      this.basePath,
      userData
    );
    return response.data;
  }

  async update(
    id: number,
    userData: Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
  ): Promise<User> {
    const response = await apiClient.put<SingleResponse<User>>(
      `${this.basePath}/${id}`,
      userData
    );
    return response.data;
  }

  async delete(id: number): Promise<SuccessResponse> {
    return apiClient.delete<SuccessResponse>(`${this.basePath}/${id}`);
  }

  async uploadAvatar(id: number, file: File): Promise<User> {
    const formData = new FormData();
    formData.append('avatar', file);

    const response = await apiClient.post<SingleResponse<User>>(
      `${this.basePath}/${id}/avatar`,
      formData,
      {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      }
    );
    return response.data;
  }
}

export const userService = new UserService();

Advanced API Client Patterns

Implement sophisticated patterns for production-ready API clients:

Request Retry Logic:
interface RetryConfig {
  maxRetries: number;
  retryDelay: number;
  retryOn: number[];
}

async function fetchWithRetry<T>(
  url: string,
  options?: RequestInit,
  retryConfig: RetryConfig = {
    maxRetries: 3,
    retryDelay: 1000,
    retryOn: [408, 429, 500, 502, 503, 504],
  }
): Promise<T> {
  let lastError: Error;

  for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (
        !response.ok &&
        retryConfig.retryOn.includes(response.status) &&
        attempt < retryConfig.maxRetries
      ) {
        await new Promise((resolve) =>
          setTimeout(resolve, retryConfig.retryDelay * (attempt + 1))
        );
        continue;
      }

      if (!response.ok) {
        const error: ApiError = await response.json();
        throw new Error(error.message);
      }

      return response.json() as Promise<T>;
    } catch (error) {
      lastError = error as Error;
      if (attempt < retryConfig.maxRetries) {
        await new Promise((resolve) =>
          setTimeout(resolve, retryConfig.retryDelay * (attempt + 1))
        );
      }
    }
  }

  throw lastError!;
}
Request Cancellation:
class CancellableRequest<T> {
  private controller: AbortController;
  private promise: Promise<T>;

  constructor(url: string, options?: RequestInit) {
    this.controller = new AbortController();
    this.promise = fetch(url, {
      ...options,
      signal: this.controller.signal,
    }).then((response) => {
      if (!response.ok) {
        throw new Error('Request failed');
      }
      return response.json() as Promise<T>;
    });
  }

  cancel(): void {
    this.controller.abort();
  }

  getPromise(): Promise<T> {
    return this.promise;
  }
}

// Usage
const request = new CancellableRequest<PaginatedResponse<User>>(
  'https://api.example.com/users'
);

// Cancel if needed
setTimeout(() => request.cancel(), 5000);

try {
  const data = await request.getPromise();
  console.log(data);
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    console.log('Request was cancelled');
  }
}
Warning: Always handle request cancellation errors explicitly. Aborted requests throw AbortError exceptions that should be caught and handled appropriately rather than treated as actual API failures.

Type Guards for API Responses

Use type guards to validate API responses at runtime:

Runtime Type Validation:
function isUser(obj: any): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    typeof obj.id === 'number' &&
    typeof obj.email === 'string' &&
    typeof obj.username === 'string' &&
    typeof obj.firstName === 'string' &&
    typeof obj.lastName === 'string' &&
    typeof obj.createdAt === 'string' &&
    typeof obj.updatedAt === 'string'
  );
}

function isPaginatedResponse<T>(
  obj: any,
  itemGuard: (item: any) => item is T
): obj is PaginatedResponse<T> {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    Array.isArray(obj.data) &&
    obj.data.every(itemGuard) &&
    typeof obj.meta === 'object' &&
    typeof obj.meta.total === 'number'
  );
}

// Usage with validation
async function getSafeUsers(): Promise<PaginatedResponse<User>> {
  const data = await fetchApi<any>('https://api.example.com/users');

  if (!isPaginatedResponse(data, isUser)) {
    throw new Error('Invalid API response structure');
  }

  return data;
}
Exercise:
  1. Create a typed API client for a posts API with endpoints: GET /posts, GET /posts/:id, POST /posts, PUT /posts/:id, DELETE /posts/:id
  2. Define TypeScript interfaces for Post (with id, title, content, authorId, createdAt) and PaginatedPosts responses
  3. Implement a PostService class with type-safe methods for all CRUD operations
  4. Add error handling with custom ApiError types and retry logic for failed requests
  5. Create type guards to validate API responses at runtime
  6. Add request cancellation support using AbortController

Summary

In this lesson, you learned how to work with REST APIs in TypeScript by typing API responses, using Axios and Fetch with full type safety, and implementing robust API client patterns. You explored service layer architecture, request retry mechanisms, cancellation patterns, and runtime validation with type guards. These patterns enable you to build reliable, maintainable API integrations that catch errors at compile time and provide excellent developer experience through autocomplete and type checking.