DEV Community

Cover image for Say Goodbye to Boilerplate: A Modern Approach to API Calls
Daniel Amenou
Daniel Amenou

Posted on

Say Goodbye to Boilerplate: A Modern Approach to API Calls

The Pain of Modern API Interactions

If you've been writing API calls in JavaScript/TypeScript, you know the drill. Every HTTP request becomes a ceremony: configure fetch options, check response status, parse JSON, handle errors, add headers, manage authentication... The native fetch() API is powerful but verbose. It forces you to write the same patterns repeatedly, cluttering your codebase with boilerplate.

Native Fetch: Configuration Hell

// With native fetch - verbose and error-prone
async function fetchUser(userId: string, token: string) {
  const url = new URL(`https://api.example.com/users/${userId}`);
  url.searchParams.append("include", "profile");
  url.searchParams.append("include", "settings");

  try {
    const response = await fetch(url.toString(), {
      method: "GET",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
        "X-API-Version": "v2",
      },
      signal: AbortSignal.timeout(5000),
    });

    if (!response.ok) {
      const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
      (error as any).status = response.status;
      (error as any).url = url.toString();
      throw error;
    }

    return await response.json();
  } catch (error) {
    // Extract error information manually
    const errorMessage = error instanceof Error ? error.message : String(error);
    const status = (error as any)?.status;
    const isTimeout = error instanceof Error && error.name === "TimeoutError";

    console.error(`Request failed: ${errorMessage}`, {
      status,
      url: url.toString(),
      isTimeout,
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

What if there was a better way? A way that's as close to the metal as native fetch, but with the ergonomics of a modern library?

A Better Way: Introducing create-request

Meet create-request - a zero-dependency, TypeScript-first wrapper that transforms how you interact with APIs through an elegant chainable interface.

The Chainable Interface: Build Requests Like Sentences

The core philosophy of create-request is simple: building a request should read like a sentence, not look like a configuration object.

create-request: Fluent and Focused

import create from "create-request";

// With create-request - clean and chainable
async function fetchUser(userId: string, token: string) {
  try {
    return await create
      .get(`https://api.example.com/users/${userId}`)
      .withQueryParams({ include: ["profile", "settings"] })
      .withBearerToken(token)
      .withHeader("X-API-Version", "v2")
      .withTimeout(5000)
      .getJson();
  } catch (error) {
    console.error(`Request failed: ${error.message}`, {
      status: error.status,
      url: error.url,
      isTimeout: error.isTimeout,
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the difference. Notice how the code reads like a sentence: "get this URL, with these query params, with this bearer token, with this header, with this timeout, get JSON." No manual URL construction, no header object juggling, no response status checking. The chainable API makes complex requests readable, while automatic JSON parsing, rich error context, and full TypeScript support handle the details for you.

Real-World Example: Building an API Client

Let's look at a more complex scenario — creating a user with retry logic and custom error handling.

The Native Fetch Approach

async function createUser(userData: User, token: string) {
  const maxRetries = 3;
  let lastError: Error;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch("https://api.example.com/users", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
          "X-Requested-With": "XMLHttpRequest", // CSRF protection
        },
        body: JSON.stringify(userData),
      });

      if (!response.ok) {
        const error = new Error(`HTTP ${response.status}`);
        error.status = response.status;
        error.response = response;
        throw error;
      }

      return await response.json();
    } catch (error) {
      lastError = error;

      if (attempt < maxRetries) {
        console.log(`Attempt ${attempt + 1} failed, retrying...`);
        await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
      }
    }
  }

  throw lastError;
}
Enter fullscreen mode Exit fullscreen mode

That's 35 lines for a single POST request with retries. And we haven't even handled network errors properly, timeouts, or type safety.

The create-request Approach

import create from "create-request";

async function createUser(userData: User, token: string) {
  return create
    .post("https://api.example.com/users")
    .withBearerToken(token)
    .withBody(userData)
    .withRetries(3)
    .onRetry(({ attempt, error }) => {
      console.log(`Attempt ${attempt} failed: ${error.message}`);
      return new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
    })
    .getData<User>();
}
Enter fullscreen mode Exit fullscreen mode

10 lines instead of 35. The retry logic, error handling, JSON stringification, content-type headers — all handled automatically. And it's fully type-safe.

How the Chainable API Reduces Complexity

1. Automatic Content-Type Management

// Native fetch - you must remember to set headers
await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(data),
});

// create-request - automatic based on body type
create.post("/api/users").withBody(data).getJson();
Enter fullscreen mode Exit fullscreen mode

When you pass an object to .withBody(), the library automatically:

  • Stringifies it as JSON
  • Sets Content-Type: application/json
  • Validates the object is JSON-serializable

2. Query Parameter Handling

// Native fetch - manual URL construction
const url = new URL("https://api.example.com/search");
url.searchParams.append("q", searchTerm);
url.searchParams.append("limit", "20");
tags.forEach(tag => url.searchParams.append("tags", tag));

await fetch(url.toString());

// create-request - declarative and type-safe
create
  .get("https://api.example.com/search")
  .withQueryParams({
    q: searchTerm,
    limit: 20,
    tags: ["javascript", "typescript"], // arrays work automatically
  })
  .getJson();
Enter fullscreen mode Exit fullscreen mode

The library handles:

  • Array values (creates multiple params with same key)
  • Null/undefined filtering (ignored automatically)
  • Type coercion (numbers, booleans → strings)
  • URL encoding

3. Error Information That Actually Helps

// Native fetch - basic Error object
try {
  const response = await fetch("/api/data");
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
  console.error(error.message); // "HTTP 404" - not very helpful
}

// create-request - rich RequestError with context
try {
  await create.get("/api/data").getJson();
} catch (error) {
  // All errors are RequestError instances with full context
  console.error({
    message: error.message,
    status: error.status, // 404
    url: error.url, // '/api/data'
    method: error.method, // 'GET'
    isTimeout: error.isTimeout,
    isAborted: error.isAborted,
    response: error.response, // Raw Response object
  });
}
Enter fullscreen mode Exit fullscreen mode

Every error gives you the full picture: what failed, where, why, and how.

Authentication Patterns Made Simple

Authentication is a common source of boilerplate. The library provides helpers for the most common patterns.

// Bearer Token
create.get("/api/profile").withBearerToken(userToken).getJson();

// Basic Authentication
create.get("/api/admin").withBasicAuth("admin", "password123").getJson();

// Custom Authorization
create.get("/api/data").withAuthorization("Custom scheme-value").getJson();

// API Keys in Headers
create.get("/api/weather").withHeader("X-API-Key", apiKey).getJson();
Enter fullscreen mode Exit fullscreen mode

Interceptors: Global and Per‑Request Hooks

Interceptors let you centralize cross‑cutting concerns (auth, logging, localization) without cluttering call sites. Add them globally, or override per request when needed.

import create from "create-request";

// Global interceptors
create.config.addRequestInterceptor(config => {
  const token = getToken();
  if (token) config.headers["Authorization"] = `Bearer ${token}`;
  return config;
});
create.config.addResponseInterceptor(res => {
  console.debug(`[api] ${res.method} ${res.url} -> ${res.status}`);
  return res;
});
create.config.addErrorInterceptor(error => {
  reportError(error);
  throw error; // or return a ResponseWrapper to recover
});

// Per-request overrides
await create
  .get("/api/users")
  .withRequestInterceptor(config => {
    config.headers["X-Trace"] = traceId;
    return config;
  })
  .withErrorInterceptor(error => {
    reportError(error);
    throw error; // or return a ResponseWrapper to recover
  })
  .getJson();
Enter fullscreen mode Exit fullscreen mode

When Chainability Really Shines: Complex Configurations

Here's where the fluent interface separates itself from configuration objects — complex requests with many options.

// A production-grade request with many concerns
const userData = await create
  .get("https://api.example.com/users")
  .withBearerToken(authToken)
  .withQueryParams({
    page: currentPage,
    limit: 50,
    sort: "created_at",
    filter: ["active", "verified"],
  })
  .withHeaders({
    "X-API-Version": "v2",
    "X-Request-ID": requestId,
  })
  .withTimeout(10000)
  .withRetries(2)
  .withCredentials.INCLUDE() // Send cookies cross-origin
  .withCache.NO_CACHE() // Don't use cached responses
  .onRetry(({ attempt }) => {
    logger.warn(`Retry attempt ${attempt}`);
  })
  .getData<ApiResponse<User[]>>(data => data.users);
Enter fullscreen mode Exit fullscreen mode

With native fetch, this would be a 50+ line configuration nightmare. With create-request, it's readable, maintainable, and self-documenting.

Data Extraction Without the Boilerplate

One of the most elegant features is .getData() — a way to extract and transform data in one shot.

// Extract nested data
const posts = await create.get("https://api.example.com/feed").getData(response => response.data.posts);

// Transform while extracting
const usernames = await create.get("https://api.example.com/users").getData(response => response.users.map(u => u.username));

// Filter and select
const activeUsers = await create.get("https://api.example.com/users").getData(response => response.users.filter(u => u.isActive));
Enter fullscreen mode Exit fullscreen mode

Compare this to the native approach:

const response = await fetch("https://api.example.com/users");
if (!response.ok) throw new Error("Failed");
const json = await response.json();
const activeUsers = json.users.filter(u => u.isActive);
Enter fullscreen mode Exit fullscreen mode

With getData(), the extraction logic is part of the request declaration — cleaner and more functional.

GraphQL Made Simple: No More Manual Error Checking

GraphQL has a unique quirk: HTTP 200 doesn't mean success. A GraphQL response can return 200 OK while containing errors in the response body. This forces you to check for errors manually on every request.

The GraphQL Problem

// Native fetch - manual error checking required
async function fetchUser(userId: string) {
  const query = `query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
    }
  }`;
  const variables = { id: userId };

  const response = await fetch("https://api.example.com/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });

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

  const result = await response.json();

  // GraphQL can return 200 with errors - must check manually
  if (result.errors && result.errors.length > 0) {
    const errorMessages = result.errors.map((e: any) => e.message).join(", ");
    throw new Error(`GraphQL errors: ${errorMessages}`);
  }

  return result.data.user;
}
Enter fullscreen mode Exit fullscreen mode

Notice the boilerplate: you must manually check result.errors on every GraphQL request, even when the HTTP status is 200.

create-request: Eliminate Boilerplate with throwOnError

With create-request, you can use throwOnError: true to automatically throw errors when GraphQL responses contain errors, eliminating the need for manual error checking:

import create from "create-request";

// With create-request and throwOnError - errors handled automatically
async function fetchUser(userId: string) {
  const query = `query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
    }
  }`;
  const variables = { id: userId };

  try {
    return await create
      .post("https://api.example.com/graphql")
      .withGraphQL(query, variables, { throwOnError: true })
      .getData(response => response.data.user);
  } catch (error) {
    console.error(`Request failed: ${error.message}`, {
      status: error.status,
      url: error.url,
      isTimeout: error.isTimeout,
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The library automatically:

  • Formats the body as { query, variables }
  • Sets Content-Type: application/json
  • Throws RequestError when GraphQL errors are present (with throwOnError: true)

Building on Native Standards

Despite the convenience layer, create-request is still built on native fetch(). This means:

  • No adapter layer — direct fetch() calls under the hood
  • Standard Response objects — access via .raw property
  • AbortController support — cancel requests natively
  • Full TypeScript — type inference works throughout
// You can always access the raw Response
const wrapper = await create.get("/api/data").getResponse();
console.log(wrapper.raw); // Native Response object
console.log(wrapper.status); // 200
console.log(wrapper.headers); // Headers object
Enter fullscreen mode Exit fullscreen mode

Performance: Zero Dependencies, Tiny Bundle

Here's the kicker: all of this functionality comes in at ~6KB minified + gzipped.

Compare to alternatives:

  • Axios: ~14.1KB (2.3x larger)
  • SuperAgent: ~17.7KB (3x larger)
  • Got: ~17.8KB (3x larger)

And unlike those libraries, create-request has zero dependencies. Your dependency tree stays clean, your build times stay fast, and your bundle size stays small.

The Verdict: When to Use create-request

Use create-request when you want:

  • Clean, readable API calls without configuration objects
  • Automatic retry logic and timeout handling
  • Rich error context (not just "fetch failed")
  • Type-safe requests with minimal type annotations
  • A tiny bundle size with no dependencies
  • Modern DX without abandoning native fetch

For most TypeScript/JavaScript projects, create-request hits the sweet spot: modern, lightweight, and powerful without being heavy.

Getting Started

create-request on npm

npm install create-request
Enter fullscreen mode Exit fullscreen mode
import create from "create-request";

// Your first request
const user = await create.get("https://api.example.com/user/123").withBearerToken(token).getData<User>();

console.log(user.name);
Enter fullscreen mode Exit fullscreen mode

That's it. No configuration, no setup, no boilerplate. Just clean, modern API calls.

Top comments (0)