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;
}
}
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;
}
}
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;
}
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>();
}
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();
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();
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
});
}
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();
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();
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);
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));
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);
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;
}
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;
}
}
The library automatically:
- Formats the body as
{ query, variables } - Sets
Content-Type: application/json - Throws
RequestErrorwhen GraphQL errors are present (withthrowOnError: 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
.rawproperty - 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
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
npm install create-request
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);
That's it. No configuration, no setup, no boilerplate. Just clean, modern API calls.
Top comments (0)