Back to roadmaps typescript Course

Project: Strongly-Typed API Client

In web development, interacting with external APIs is a common task. Without proper typing, API responses are often treated as any, which breaks your application safety boundaries.

In this practical project, we will build a generic, type-safe API client wrapper around the standard fetch API.


1. Defining Our Data Models

First, let us declare our backend resource structures:

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

interface CreateUserDTO {
  name: string;
  email: string;
}

2. Implementing the Client Class

Our API client will accept a base URL and provide helper methods for GET and POST requests. We use generic type parameters to ensure the response payload matches the expected data shape.

class ApiClient {
  constructor(private baseUrl: string) {}

  private async request<ResponseShape>(
    path: string,
    options?: RequestInit
  ): Promise<ResponseShape> {
    const url = `${this.baseUrl}${path}`;
    const response = await fetch(url, options);

    if (!response.ok) {
      throw new Error(`API Error: ${response.status} ${response.statusText}`);
    }

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

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

  public async post<ResponseShape, RequestBody>(
    path: string,
    body: RequestBody
  ): Promise<ResponseShape> {
    return this.request<ResponseShape>(path, {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify(body)
    });
  }
}

3. Putting the Client to Use

Now, we can instantiate the client and perform requests with complete compile-time validation.

const client = new ApiClient("https://jsonplaceholder.typicode.com");

async function runDemo() {
  // TypeScript knows 'users' is an array of User objects
  const users = await client.get<User[]>("/users");
  console.log(`Fetched ${users.length} users.`);

  // TypeScript enforces properties of CreateUserDTO
  const newUser = await client.post<User, CreateUserDTO>("/users", {
    name: "John Doe",
    email: "john@example.com"
  });

  console.log(`Created user with ID: ${newUser.id}`);
}

If you try to access non-existent properties on newUser, or pass invalid fields to the payload body of post, the compiler will catch it instantly.


4. Summary

  • Building an API wrapper centralizes error handling and response formatting.
  • Generics allow a single request method to output correctly-typed payloads.
  • Linking request body inputs to generic types prevents payload structure mismatches during dev.
Published on Last updated: