DEV Community

Cover image for Building a Type-Safe API URL Builder in TypeScript: A Pokémon API Example
Alexander Opalic
Alexander Opalic

Posted on • Updated on

Building a Type-Safe API URL Builder in TypeScript: A Pokémon API Example

Introduction

Interacting with APIs often involves juggling multiple endpoints, diverse URL segments, and varying query parameters. This complexity can lead to errors and reduced maintainability. One way to simplify this process is through a URL builder pattern. To make things even more robust and flexible, we can leverage TypeScript's powerful type system. This blog post will guide you in building a type-safe API URL builder using TypeScript, demonstrated using the Pokémon API.

The Classic Problem: API Complexity

Before diving into our type-safe solution, let's consider why we need a URL builder in the first place:

// Fetch a specific Pokémon
fetch("https://pokeapi.co/api/v2/pokemon/1")
  .then((response) => response.json())
  .then((data) => console.log(data));

// Fetch a list of abilities
fetch("https://pokeapi.co/api/v2/ability")
  .then((response) => response.json())
  .then((data) => console.log(data));
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Base URL Duplication: The base URL is specified repeatedly, making updates cumbersome.
  • No Unified URL Strategy: There is no single source of truth for constructing URLs.
  • Query Parameter Management: It's challenging to manage optional or conditional query parameters.

A Better Way: Building a Type-Safe API URL Builder
Instead of hardcoding the URLs and parameters, we can create a flexible and type-safe URL builder using TypeScript.

The TypeScript Code:

type API = 'pokemon' | 'ability' | 'berry';

interface APIAttributes {
  pokemon: { id: number; name?: string };
  ability: { id: number; name?: string };
  berry: { color: string; flavor?: string };
}

interface SegmentTypes {
  pokemon: 'pokemon';
  ability: 'ability';
  berry: 'berry';
}

interface FetchOptions {
  headers?: Record<string, string>;
  method?: string;
  body?: Record<string, any>;
}

class URLBuilder<T extends API> {
  private baseURL: string;
  private segments: string[] = [];
  private queryParameters: Partial<APIAttributes[T]> = {};

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

  // HTTP Methods
  async get(): Promise<Response> {
    return this.fetch({ method: 'GET' });
  }
  async post(body?: Record<string, any>): Promise<Response> {
    return this.fetch({ method: 'POST', body });
  }
  async put(body?: Record<string, any>): Promise<Response> {
    return this.fetch({ method: 'PUT', body });
  }
  async delete(): Promise<Response> {
    return this.fetch({ method: 'DELETE' });
  }

  // Add resource and query parameters
  addResource(resource: SegmentTypes[T]): URLBuilder<T> {
    this.segments.push(resource);
    return this;
  }
  addQueryParam<K extends keyof APIAttributes[T]>(key: K, value: APIAttributes[T][K]): URLBuilder<T> {
    this.queryParameters[key] = value;
    return this;
  }

  // Build the URL
  private buildURL(): string {
    let url = `${this.baseURL}/${this.segments.join('/')}`;
    const queryParams = new URLSearchParams(Object.entries(this.queryParameters).map(
      ([key, value]) => [key, String(value)]
    )).toString();
    if (queryParams) {
      url += `?${queryParams}`;
    }
    return url;
  }

  // Fetch data
  private async fetch(options: FetchOptions = {}): Promise<Response> {
    const { body, ...restOptions } = options;
    return fetch(this.buildURL(), {
      ...restOptions,
      body: body ? JSON.stringify(body) : null,
      headers: {
        'Content-Type': 'application/json',
        ...restOptions.headers,
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Example usage for fetching an ability

// Create a new URLBuilder instance for 'ability' API, benefiting from TypeScript's type inference.
const abilityAPI = new URLBuilder<'ability'>('https://pokeapi.co/api/v2');

// Add resource and query parameters in a type-safe manner.
// TypeScript will throw an error if you try to add a non-existent resource or query parameter.
abilityAPI.addResource('ability').addQueryParam('id', 1)
  .get()
  .then((response) => response.json())
  .then((data) => console.log('Ability:', data));

// Create a new URLBuilder instance for 'berry' API.
const berryAPI = new URLBuilder<'berry'>('https://pokeapi.co/api/v2');

// Attempt to add a non-valid query parameter. TypeScript will flag this as an error.
// Uncommenting the next line would trigger a TypeScript error, showing the type-safe nature of the builder.
// berryAPI.addResource('berry').addQueryParam('color3', 'red'); // TypeScript error!

// Correct usage: this passes the TypeScript check.
berryAPI.addResource('berry').addQueryParam('color', 'red')
  .get()
  .then((response) => response.json())
  .then((data) => console.log('Berry:', data));

Enter fullscreen mode Exit fullscreen mode

With this TypeScript-powered URL Builder, you can enjoy both flexibility and type safety, making your API interactions more reliable and maintainable.


Understanding the TypeScript Concepts in the URL Builder

Type Aliases and Union Types

  • Used in: type API = 'pokemon' | 'ability' | 'berry';
  • Purpose: To restrict the API types to a predefined set of values ('pokemon', 'ability', 'berry').

Interfaces

  • Used in: interface APIAttributes { /*...*/ }, interface SegmentTypes { /*...*/ }, interface FetchOptions { /*...*/ }
  • Purpose: To define the shape or structure that objects should have.

Generics

  • Used in: class URLBuilder<T extends API> { /*...*/ }
  • Purpose: To allow the URLBuilder class to work with different API types while retaining type safety.

Method Chaining

  • Used in: abilityAPI.addResource('ability').addQueryParam('id', 1).get();
  • Purpose: To allow multiple methods to be called in a single statement for cleaner, more readable code.

Async/Await

  • Used in: async get(): Promise<Response> { /*...*/ }
  • Purpose: To handle asynchronous operations, making the code easier to read and write.

Keyof and Indexed Types

  • Used in: addQueryParam<K extends keyof APIAttributes[T]>(key: K, value: APIAttributes[T][K]): URLBuilder<T> { /*...*/ }
  • Purpose: To ensure that only valid query parameters are added, based on the specific API type.

By understanding these TypeScript features, you'll have a clearer insight into how this type-safe URL Builder works. This should help you not only to use this utility more effectively but also to implement similar type-safe patterns in your own projects.


Extending the URL Builder for Typed Responses

The URL Builder we've designed so far is fairly flexible and type-safe, but it can be improved further. One area where we can enhance its functionality is by typing the responses from our API calls. Currently, the .get() method returns a generic Promise<Response>, which doesn't give us much insight into the shape of the data we're expecting.

Imagine a scenario where you want to fetch a Pokémon's data. Wouldn't it be great if the .get() method could also tell you that it's going to return a Pokémon object? With TypeScript generics, we can do exactly that.

Adding Generics for Responses

We can extend the .get() method to accept a generic type that describes the response data. Here's how you could update the method:

async get<T>(): Promise<T> {
  const response = await this.fetch({ method: 'GET' });
  const data = await response.json();
  return data as T;
}
Enter fullscreen mode Exit fullscreen mode

Now you can make api calls like

const abilityAPI = new URLBuilder<'ability'>('https://pokeapi.co/api/v2');
abilityAPI
  .addResource('ability')
  .addQueryParam('id', 1)
  .get<Pokemon>()
  .then((data) => console.log('Ability:', data));
Enter fullscreen mode Exit fullscreen mode

In this example, the .get<Pokemon>() method will return a Promise<Pokemon>, giving you type safety not just for the API call, but also for the response data.

By adding this small but powerful feature, our URL Builder becomes even more robust, making it easier to work with APIs in a type-safe manner.

Areas for Potential Improvement

While our URL Builder is a useful tool as is, it's by no means a complete solution. Here are some points that could be improved:

  • Error Handling: Although we've added some basic error handling, this could be extended to include more custom error types, logging, and potentially retries on failure.

  • Parameter Validation: Currently, there is no validation for query parameters. Adding a validation layer can ensure more robust API calls.

  • Rate Limiting: Adding functionality to handle API rate limits would make the class more resilient for real-world use.

  • Caching: Implementing caching mechanisms to avoid redundant requests can optimize performance.

Feel free to extend and adapt the URL Builder according to the specific requirements of your project.

Top comments (0)