Skip to Content
Course content

77: Building a Type-Safe API Client

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've spent any time calling APIs in TypeScript, you've probably written this line of code: const data = (await response.json()) as User;. It feels right. You know the API returns a User object, you've defined a User interface, and the type assertion as User makes the red squiggly lines go away. You feel type-safe.

The "as MyType" Illusion

Here is the cold truth: as User isn't providing safety; it's providing a blindfold. When you use a type assertion, you aren't telling TypeScript to verify that the data is a User; you're telling TypeScript to shut up and trust you. I've seen this crash production systems countless times. The API changes a field from userName to handle, the server sends back a 404 error page in HTML instead of JSON, or a field you thought was a number arrives as a string. TypeScript will happily let you call data.userName.toUpperCase() because you asserted it was a User, and then the app blows up with TypeError: Cannot read property 'toUpperCase' of undefined.

// This is the "dangerous" way
async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  // We are lying to the compiler here. 
  // If the API returns { error: "Not Found" }, this still claims to be a User.
  return (await response.json()) as User; 
}


Designing a Generic Request Wrapper

To build a truly type-safe client, we need to move the type logic out of the individual call and into a reusable wrapper. Instead of casting at the call site, we use generics to define the "shape" of the expected response. This allows us to centralize error handling and response parsing in one place, rather than repeating the same dangerous patterns in every single API function.

I prefer building a small ApiClient class. It keeps the base URL and headers in one spot and leverages a generic method to handle the heavy lifting.

interface ApiResponse<T> {
  data: T;
  status: number;
}

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  // Notice the <T>. This means "I don't know what the data is yet, 
  // but whoever calls this method will tell me."
  async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, options);

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

    // We still have to cast here once, but it's centralized.
    // In a production app, I'd use a library like Zod here to 
    // actually validate the runtime data.
    return response.json() as Promise<T>;
  }
}


Implementing Feature-Specific Services

Now that we have a generic engine, we don't want our UI components talking directly to the ApiClient. That would leak implementation details everywhere. Instead, we create "Service" classes. This is where we map our business domains (like Products or Orders) to the generic request method.

Let's say we're building a Product Catalog. We define our domain models and then use the client to fetch them.

interface Product {
  id: string;
  name: string;
  price: number;
  category: 'electronics' | 'clothing' | 'home';
}

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

  async getProduct(id: string): Promise<Product> {
    // We pass the Product type into the generic request method.
    // Now, the return type of this function is automatically Promise<Product>.
    return this.client.request<Product>(`/products/${id}`);
  }

  async listByCategory(category: string): Promise<Product[]> {
    return this.client.request<Product[]>(`/products?cat=${category}`);
  }
}

// Usage
const api = new ApiClient('https://api.mystore.com');
const products = new ProductService(api);

const item = await products.getProduct('p123'); 
// 'item' is now correctly typed as Product. No 'as' needed in the business logic.


By structuring it this way, you've created a clear pipeline: Raw Network Response → Generic Wrapper → Typed Service → UI. If the API structure changes, you update the interface in one place, and TypeScript will immediately point out every single line of code in your entire application that is now broken.




📋 Practical Task

Refactoring the Legacy Order Management Client

You've inherited a codebase where the API calls are a mess of any types and inline casts. Your goal is to refactor the OrderClient to use a type-safe generic approach.

Current Broken Code:

interface Order {
  id: string;
  total: number;
  items: OrderItem[];
}

interface OrderItem {
  productId: string;
  quantity: number;
}

class OrderClient {
  async getOrder(id: string) {
    const res = await fetch(`/api/orders/${id}`);
    return (await res.json()) as any; // This is the problem!
  }
}


Your Task:

  1. Create a BaseClient class with a generic request<T>` method that handles the fetch call, checks response.ok, and returns the JSON as type T.
  2. Refactor the OrderClient to extend BaseClient (or take it as a dependency).
  3. Update the getOrder method so that it no longer uses any and correctly returns a Promise<Order> by passing the type to the generic request method.
  4. Implement a new method getOrderItems(orderId: string) that returns a Promise<OrderItem[]>.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.