advanced Step 18 of 18

Building a Typed API Client

TypeScript Programming

Building a Typed API Client

A typed API client is the culmination of everything we have learned in this TypeScript learning path. By combining generics, utility types, discriminated unions, and type inference, we will build a reusable HTTP client that provides complete type safety from request to response. This pattern is used in production applications to ensure that API calls always send correctly shaped data and that responses are typed without manual casting. The result is code that is self-documenting, refactor-safe, and catches integration errors at compile time rather than in production.

Defining API Types

Start by defining the types for your API resources, request payloads, and response shapes. These types serve as the single source of truth for data flowing between your front end and back end.

// Types for API resources
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  createdAt: string;
}

interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
  published: boolean;
  createdAt: string;
}

// Request payload types (Omit server-generated fields)
type CreateUserPayload = Omit<User, "id" | "createdAt">;
type UpdateUserPayload = Partial<Omit<User, "id" | "createdAt">>;
type CreatePostPayload = Omit<Post, "id" | "createdAt">;

// API response wrapper
interface ApiResponse<T> {
  data: T;
  meta?: {
    page: number;
    perPage: number;
    total: number;
  };
}

interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

type ApiResult<T> =
  | { ok: true; data: T; status: number }
  | { ok: false; error: ApiError; status: number };

Building the HTTP Client

Create a generic HTTP client class that wraps the Fetch API with type safety, automatic JSON parsing, and consistent error handling.

class ApiClient {
  constructor(private baseUrl: string, private defaultHeaders: Record<string, string> = {}) {}

  private async request<T>(
    method: string,
    path: string,
    body?: unknown
  ): Promise<ApiResult<T>> {
    try {
      const response = await fetch(`${this.baseUrl}${path}`, {
        method,
        headers: {
          "Content-Type": "application/json",
          ...this.defaultHeaders,
        },
        body: body ? JSON.stringify(body) : undefined,
      });

      const json = await response.json();

      if (response.ok) {
        return { ok: true, data: json as T, status: response.status };
      }

      return {
        ok: false,
        error: json as ApiError,
        status: response.status,
      };
    } catch (e) {
      return {
        ok: false,
        error: {
          code: "NETWORK_ERROR",
          message: e instanceof Error ? e.message : "Unknown error",
        },
        status: 0,
      };
    }
  }

  async get<T>(path: string): Promise<ApiResult<T>> {
    return this.request<T>("GET", path);
  }

  async post<T, B = unknown>(path: string, body: B): Promise<ApiResult<T>> {
    return this.request<T>("POST", path, body);
  }

  async put<T, B = unknown>(path: string, body: B): Promise<ApiResult<T>> {
    return this.request<T>("PUT", path, body);
  }

  async delete<T = void>(path: string): Promise<ApiResult<T>> {
    return this.request<T>("DELETE", path);
  }
}

Resource-Specific Services

Build typed service classes on top of the generic client. Each service handles a specific API resource with correctly typed methods.

class UserService {
  constructor(private client: ApiClient) {}

  async getAll(): Promise<ApiResult<ApiResponse<User[]>>> {
    return this.client.get<ApiResponse<User[]>>("/users");
  }

  async getById(id: number): Promise<ApiResult<User>> {
    return this.client.get<User>(`/users/${id}`);
  }

  async create(payload: CreateUserPayload): Promise<ApiResult<User>> {
    return this.client.post<User, CreateUserPayload>("/users", payload);
  }

  async update(id: number, payload: UpdateUserPayload): Promise<ApiResult<User>> {
    return this.client.put<User, UpdateUserPayload>(`/users/${id}`, payload);
  }

  async remove(id: number): Promise<ApiResult<void>> {
    return this.client.delete(`/users/${id}`);
  }
}

// Usage — fully typed from request to response
const api = new ApiClient("https://api.example.com", {
  Authorization: "Bearer token123",
});
const userService = new UserService(api);

async function main() {
  // Create — payload is type-checked
  const createResult = await userService.create({
    name: "Alice",
    email: "alice@example.com",
    role: "editor",
    // role: "superadmin", // Error: not assignable to "admin" | "editor" | "viewer"
  });

  if (createResult.ok) {
    console.log(`Created user: ${createResult.data.name}`);
    const userId = createResult.data.id; // type: number

    // Update — partial payload
    const updateResult = await userService.update(userId, {
      role: "admin",
    });

    if (updateResult.ok) {
      console.log(`Updated role to: ${updateResult.data.role}`);
    }
  } else {
    console.error(`Error: ${createResult.error.message}`);
  }

  // List — includes pagination metadata
  const listResult = await userService.getAll();
  if (listResult.ok) {
    const users = listResult.data.data; // User[]
    const total = listResult.data.meta?.total;
    users.forEach(u => console.log(`${u.name} (${u.role})`));
  }
}
Tip: For production API clients, consider adding request/response interceptors, retry logic, request cancellation with AbortController, and query parameter serialization. Libraries like ky or axios provide these features and have excellent TypeScript support.

Key Takeaways

  • Define shared types for API resources, request payloads, and response wrappers as the single source of truth.
  • Use generics in the HTTP client to ensure type safety from request to response.
  • Build resource-specific service classes that wrap the generic client with concrete types.
  • The ApiResult discriminated union forces callers to handle both success and error cases.
  • Utility types like Omit and Partial derive request payload types from resource types.
arrow_back TypeScript with React check_circle Lap Complete!