A lightweight, zero-dependency HTTP client built for server-side TypeScript applications.
Enterprise-grade features β’ Discriminated union responses β’ Never throws β’ Full type inference
Installation β’ Quick Start β’ API Reference β’ Examples
- Why CustomFetch?
- Features
- Installation
- Quick Start
- API Reference
- Configuration Options
- Response Types
- Advanced Usage
- TypeScript Types
- Best Practices
- Comparison with Alternatives
- Author
- License
Modern applications require robust HTTP clients that go beyond simple fetch calls. CustomFetch addresses common pain points:
| Problem | CustomFetch Solution |
|---|---|
| Inconsistent error handling | Discriminated union responses (isSuccess: true/false) - never throws, always returns structured data |
| No built-in retries | Automatic retries with exponential backoff for 5xx errors and rate limits (429) |
| No request timeouts | Built-in timeout support with AbortController |
| Repetitive boilerplate | Client factory with shared configuration (baseUrl, headers, interceptors) |
| Runtime type safety | Optional type guards for validating response shapes at runtime |
| No caching | In-memory response caching with configurable TTL |
| Hard to debug | Request IDs, duration tracking, and configurable debug logging |
| Complex interceptor patterns | Simple onRequest and onResponse hooks |
| No type assertions needed | 100% type-safe with zero any, unknown, or as casts |
| Inconsistent binary handling | Automatic detection and base64 encoding of binary responses |
// β Traditional fetch - error prone, verbose, no type safety
try {
const response = await fetch("https://api.example.com/users/1");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const user = await response.json(); // any type!
// No idea if user actually has the shape you expect
} catch (error) {
// Could be network error, JSON parse error, or HTTP error
// Good luck distinguishing them!
}
// β
CustomFetch - clean, type-safe, predictable
const result = await api.get<User>("/users/1");
if (result.isSuccess) {
console.log(result.response.name); // Fully typed!
} else {
console.log(result.errors.message); // Structured error
console.log(result.code); // HTTP status or null for network errors
}| Feature | Description |
|---|---|
| π― Zero Dependencies | Built entirely on native fetch API - no bloat |
| π¦ Tiny Bundle | < 5KB minified + gzipped |
| π 100% Type-Safe | No any, no unknown, no type assertions |
| π·οΈ Discriminated Unions | isSuccess flag enables perfect type narrowing |
| π« Never Throws | All errors returned in structured ErrorResponse |
| π Automatic Retries | Exponential backoff for 5xx and 429 errors |
| β±οΈ Request Timeouts | Built-in AbortController-based timeouts |
| πΎ Response Caching | In-memory TTL-based caching |
| πͺ Interceptors | onRequest and onResponse hooks |
| π Runtime Validation | Type guards for response shape validation |
| π Request Metrics | Unique request IDs and duration tracking |
| π§ Client Factory | Pre-configured reusable instances |
| Feature | Description |
|---|---|
| π€ All HTTP Methods | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
| π Query Parameters | Automatic serialization of objects, arrays, primitives |
| π Custom Headers | Static headers or async header factories |
| πͺ Credentials | Optional cookie inclusion |
| π Bearer Auth | Built-in customToken option |
| π Binary Support | Automatic base64 encoding for binary responses |
| Feature | Description |
|---|---|
| π Debug Mode | Environment-aware logging |
| π Transformers | Response and error transformation hooks |
| β‘ Async Headers | Dynamic header resolution (e.g., refresh tokens) |
| π Request IDs | Unique identifiers for tracing |
| β° Duration Metrics | Built-in request timing |
# npm
npm install @hco/custom-fetch
# yarn
yarn add @hco/custom-fetch
# pnpm
pnpm add @hco/custom-fetch
# bun
bun add @hco/custom-fetch- Node.js >= 18.0.0 (native fetch support)
- Bun >= 1.0.0
Note: This package is designed for server-side usage only. It uses Node.js/Bun globals like
process.envandBuffer.
import { CustomFetch } from "@hco/custom-fetch";
interface User {
id: string;
name: string;
email: string;
}
const result = await CustomFetch<User>({
options: {
url: "https://api.example.com/users/1",
},
});
if (result.isSuccess) {
console.log(result.response); // User object
console.log(result.code); // 200
console.log(result.requestId); // "m5x2k-a3b4c5"
console.log(result.durationMs); // 142
} else {
console.error(result.errors.message);
console.error(result.code); // null or HTTP status code
}import { createClient } from "@hco/custom-fetch";
const api = createClient({
baseUrl: "https://api.example.com",
defaultHeaders: { "Authorization": "Bearer token123" },
defaultTimeout: 10000,
defaultRetries: 3,
});
// GET request
const users = await api.get<User[]>("/users");
// POST request
const newUser = await api.post<User>("/users", {
name: "John Doe",
email: "john@example.com",
});
// PUT request
const updated = await api.put<User>("/users/1", { name: "Jane Doe" });
// PATCH request
const patched = await api.patch<User>("/users/1", { email: "jane@example.com" });
// DELETE request
const deleted = await api.delete<void>("/users/1");The core function for making HTTP requests.
async function CustomFetch<T, E extends BaseError = BaseError>(
props: CustomFetchProps<T, E>
): Promise<CustomFetchResult<T, E>>Type Parameters:
T- Expected response data typeE- Error type (must extendBaseError, defaults toBaseError)
Returns: Promise<CustomFetchResult<T, E>> - A discriminated union of success or error response
Creates a configured HTTP client instance with shared defaults.
function createClient(config?: ClientConfig): {
get<T, E>(path: string, options?): Promise<CustomFetchResult<T, E>>;
post<T, E>(path: string, body?, options?): Promise<CustomFetchResult<T, E>>;
put<T, E>(path: string, body?, options?): Promise<CustomFetchResult<T, E>>;
patch<T, E>(path: string, body?, options?): Promise<CustomFetchResult<T, E>>;
delete<T, E>(path: string, options?): Promise<CustomFetchResult<T, E>>;
request<T, E>(method: HttpMethod, path: string, options?): Promise<CustomFetchResult<T, E>>;
}ClientConfig Options:
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
"" |
Base URL prepended to all requests |
defaultHeaders |
HeadersInit | () => HeadersInit | Promise<HeadersInit> |
{} |
Default headers (static or async function) |
defaultTimeout |
number |
30000 |
Default request timeout in milliseconds |
defaultRetries |
number |
0 |
Default retry count for failed requests |
defaultRetryDelay |
number |
1000 |
Default base delay between retries |
debug |
boolean |
process.env.NODE_ENV === "development" |
Enable debug logging |
onRequest |
RequestInterceptor |
- | Global request interceptor |
onResponse |
ResponseInterceptor |
- | Global response interceptor |
// Clear all cached responses
function clearFetchCache(): void;
// Clear a specific cache entry
function clearSpecificCache(
url: string,
method?: HttpMethod, // default: "GET"
body?: string | null // default: null
): void;
// Get cache statistics
function getCacheStats(): { size: number; keys: string[] };// Create a type guard that checks for required fields
function createObjectGuard<T extends Record<string, unknown>>(
requiredFields: (keyof T)[]
): (data: unknown) => data is T;
// Create a type guard with custom validation logic
function createCustomGuard<T>(
validator: (data: unknown) => boolean
): (data: unknown) => data is T;Full list of options available in FetchOptions<T, E>:
| Option | Type | Default | Description |
|---|---|---|---|
url |
string |
required | Request URL (absolute or relative to baseUrl) |
method |
HttpMethod |
"GET" |
HTTP method: "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS" |
headers |
HeadersInit |
{} |
Request headers |
body |
BodyInit | null |
null |
Request body |
params |
QueryParams |
- | URL query parameters (automatically serialized) |
| Option | Type | Default | Description |
|---|---|---|---|
customToken |
string |
- | Bearer token (automatically adds Authorization: Bearer <token> header) |
| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number |
30000 |
Request timeout in milliseconds |
retries |
number |
0 |
Number of retry attempts for failed requests |
retryDelay |
number |
1000 |
Base delay between retries (uses exponential backoff) |
| Option | Type | Default | Description |
|---|---|---|---|
validateStatus |
(status: number) => boolean |
(status) => status >= 200 && status < 300 |
Custom status validation function |
| Option | Type | Default | Description |
|---|---|---|---|
responseGuard |
ResponseGuard<T> |
- | Type guard to validate successful response shape |
errorGuard |
ErrorGuard<E> |
- | Type guard to validate error response shape |
| Option | Type | Default | Description |
|---|---|---|---|
responseTransformer |
ResponseTransformer<T> |
- | Transform raw response data to type T |
errorTransformer |
ErrorTransformer<E> |
- | Transform raw error data to type E |
| Option | Type | Default | Description |
|---|---|---|---|
cacheResponse |
boolean |
false |
Enable response caching |
cacheTime |
number |
300000 (5 min) |
Cache TTL in milliseconds |
| Option | Type | Default | Description |
|---|---|---|---|
includeCookies |
boolean |
false |
Include cookies in request (credentials: "include") |
debug |
boolean |
process.env.NODE_ENV === "development" |
Enable debug console logging |
abortController |
AbortController |
- | External abort controller for request cancellation |
| Option | Type | Default | Description |
|---|---|---|---|
onRequest |
RequestInterceptor |
- | Modify request before sending |
onResponse |
ResponseInterceptor<T, E> |
- | Process/modify response after receiving |
Returned when isSuccess: true:
interface SuccessResponse<T> {
isSuccess: true;
response: T; // Parsed response data
errors: undefined;
code: number; // HTTP status code (e.g., 200)
createdAt: Date; // Response timestamp
headers: Record<string, string | string[]>;
requestId: string; // Unique request identifier
durationMs: number; // Request duration in milliseconds
}Returned when isSuccess: false:
interface ErrorResponse<E> {
isSuccess: false;
response: undefined;
errors: E; // Error data (extends BaseError)
code: number | null; // HTTP status code or null for network errors
createdAt: Date; // Response timestamp
requestId: string; // Unique request identifier
durationMs: number; // Request duration in milliseconds
stack?: string; // Error stack trace (only in development)
}Minimum error shape:
interface BaseError {
message: string;
[key: string]: string | number | boolean | null | undefined;
}Validate response shapes at runtime using type guards:
import { CustomFetch, createObjectGuard, createCustomGuard } from "@hco/custom-fetch";
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Simple field presence check
const isUser = createObjectGuard<User>(["id", "name", "email", "age"]);
// Custom validation with type checking
const isValidUser = createCustomGuard<User>((data) => {
const d = data as Partial<User>;
return (
typeof d.id === "string" &&
typeof d.name === "string" &&
typeof d.email === "string" &&
d.email.includes("@") &&
typeof d.age === "number" &&
d.age > 0
);
});
const result = await CustomFetch<User>({
options: {
url: "https://api.example.com/users/1",
responseGuard: isValidUser,
},
});
if (result.isSuccess) {
// TypeScript knows result.response is User
console.log(result.response.email);
} else {
// Could be "Response validation failed" if guard returned false
console.error(result.errors.message);
}Modify requests before sending and process responses after receiving:
import { createClient, type RequestContext, type ResponseContext } from "@hco/custom-fetch";
const api = createClient({
baseUrl: "https://api.example.com",
// Request interceptor - runs before every request
onRequest: (context: RequestContext) => {
// Add custom headers
context.headers.set("X-Request-ID", context.requestId);
context.headers.set("X-Timestamp", Date.now().toString());
// Log outgoing requests
console.log(`[${context.requestId}] ${context.method} ${context.url}`);
return context;
},
// Response interceptor - runs after every response
onResponse: (context) => {
const { request, result, durationMs } = context;
// Log response metrics
console.log(
`[${request.requestId}] ${request.method} ${request.url} -> ${result.code} (${durationMs}ms)`
);
// You can transform the result here
return result;
},
});
// Per-request interceptors (override global)
const result = await api.get<User>("/users/1", {
onRequest: (ctx) => {
ctx.headers.set("X-Custom-Header", "value");
return ctx;
},
});Async Interceptors:
const api = createClient({
baseUrl: "https://api.example.com",
// Async header resolution (e.g., refresh token)
defaultHeaders: async () => {
const token = await getAccessToken(); // Your async token logic
return {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
};
},
onRequest: async (context) => {
// Async operations in interceptor
await logToAnalytics(context);
return context;
},
});Automatic retries for transient failures (5xx errors, 429 rate limits, network errors):
const result = await CustomFetch<User>({
options: {
url: "https://api.example.com/users/1",
retries: 3, // Retry up to 3 times
retryDelay: 1000, // Base delay: 1 second
debug: true, // See retry logs
},
});
// Retry behavior:
// - Attempt 1: immediate
// - Attempt 2: after 1000ms (1s)
// - Attempt 3: after 2000ms (2s)
// - Attempt 4: after 4000ms (4s)
// Total max wait: 7 seconds
// Retries happen for:
// - HTTP 500-599 (server errors)
// - HTTP 429 (rate limited)
// - Network errors (TypeError with "NetworkError")Cache successful responses in memory:
const api = createClient({
baseUrl: "https://api.example.com",
});
// Enable caching for this request
const result = await api.get<User[]>("/users", {
cacheResponse: true,
cacheTime: 60000, // Cache for 1 minute
});
// Second call returns cached response instantly
const cachedResult = await api.get<User[]>("/users", {
cacheResponse: true,
cacheTime: 60000,
});
// Cache management
import { clearFetchCache, clearSpecificCache, getCacheStats } from "@hco/custom-fetch";
// Clear specific entry
clearSpecificCache("https://api.example.com/users", "GET");
// Clear all cache
clearFetchCache();
// Check cache status
const stats = getCacheStats();
console.log(`Cache size: ${stats.size}`);
console.log(`Cached keys:`, stats.keys);Cache Key Format: {METHOD}:{URL}:{BODY}
Request timeouts and manual cancellation:
// Automatic timeout
const result = await CustomFetch<User>({
options: {
url: "https://api.example.com/slow-endpoint",
timeout: 5000, // 5 second timeout
},
});
if (!result.isSuccess && result.code === 408) {
console.log("Request timed out");
}
// Manual cancellation with external AbortController
const controller = new AbortController();
// Cancel after 3 seconds
setTimeout(() => controller.abort(), 3000);
const result = await CustomFetch<User>({
options: {
url: "https://api.example.com/users/1",
abortController: controller,
},
});
// Or cancel on user action
document.getElementById("cancel-btn")?.addEventListener("click", () => {
controller.abort();
});Binary responses (images, PDFs, etc.) are automatically encoded as base64:
// Fetch an image
const result = await CustomFetch<string>({
options: {
url: "https://api.example.com/avatar.png",
},
});
if (result.isSuccess) {
// result.response is base64-encoded string
const base64Image = result.response;
const imgSrc = `data:image/png;base64,${base64Image}`;
}
// Detected binary content types:
// - image/*
// - audio/*
// - video/*
// - font/*
// - application/octet-stream
// - application/pdf
// - application/zip, x-zip, x-rar, x-tar, x-bzip, x-gzip
// - application/java-archive
// - application/vnd.ms-*
// - application/vnd.openxmlformats-*Transform API error responses to a consistent format:
// API returns: { error: "Not found", errorCode: 404 }
// You want: { message: "Not found", code: 404 }
interface ApiError extends BaseError {
message: string;
code: number;
}
const result = await CustomFetch<User, ApiError>({
options: {
url: "https://api.example.com/users/999",
errorTransformer: (rawError, statusCode) => ({
message: (rawError as any).error || rawError.message || "Unknown error",
code: (rawError as any).errorCode || statusCode,
}),
},
});
if (!result.isSuccess) {
console.log(result.errors.message); // "Not found"
console.log(result.errors.code); // 404
}Automatic serialization of query parameters:
const result = await api.get<User[]>("/users", {
params: {
page: 1,
limit: 10,
status: "active",
tags: ["admin", "verified"], // Arrays supported
includeDeleted: false,
search: null, // null/undefined are skipped
},
});
// Request URL: /users?page=1&limit=10&status=active&tags=admin&tags=verified&includeDeleted=falseAll types are exported for your convenience:
import type {
// Response types
SuccessResponse,
ErrorResponse,
CustomFetchResult,
// Base types
BaseError,
HttpMethod,
QueryParams,
// Validation
ResponseGuard,
ErrorGuard,
// Transformers
ErrorTransformer,
ResponseTransformer,
// Interceptors
RequestContext,
ResponseContext,
RequestInterceptor,
ResponseInterceptor,
// Configuration
FetchOptions,
CustomFetchProps,
ClientConfig,
CacheItem,
// Legacy aliases
CustomFetchReturnType,
OptionsType,
} from "@hco/custom-fetch";// β
Good - Use isSuccess for type narrowing
const result = await api.get<User>("/users/1");
if (result.isSuccess) {
// TypeScript knows: result.response is User
console.log(result.response.name);
} else {
// TypeScript knows: result.errors is BaseError
console.error(result.errors.message);
}
// β Bad - Don't assume success
const result = await api.get<User>("/users/1");
console.log(result.response.name); // Error: response could be undefined// β
Good - Reusable configured client
const api = createClient({
baseUrl: process.env.API_URL,
defaultHeaders: { "X-API-Key": process.env.API_KEY },
defaultTimeout: 10000,
defaultRetries: 2,
});
// β Bad - Repeating configuration
await CustomFetch({ options: { url: "https://api.example.com/users", timeout: 10000 }});
await CustomFetch({ options: { url: "https://api.example.com/posts", timeout: 10000 }});// β
Good - Validate untrusted responses
const isUser = createObjectGuard<User>(["id", "name", "email"]);
const result = await api.get<User>("/users/1", { responseGuard: isUser });
// β Bad - Trust external API blindly
const result = await api.get<User>("/users/1");
// API could return anything!// β
Good - Comprehensive error handling
const result = await api.get<User>("/users/1");
if (!result.isSuccess) {
if (result.code === 404) {
return notFound();
}
if (result.code === 401) {
return redirect("/login");
}
if (result.code === null) {
// Network error
return showNetworkError();
}
// Generic error
return showError(result.errors.message);
}
return result.response;// Automatically enabled when NODE_ENV === "development"
// Or enable explicitly:
const result = await api.get<User>("/users/1", { debug: true });
// Console output:
// [CustomFetch] GET https://api.example.com/users/1 -> 200 (142ms)
// [CustomFetch] Retry 1/3 after 1000ms
// [CustomFetch] Cache hit: https://api.example.com/users/1| Feature | CustomFetch | axios | ky | got |
|---|---|---|---|---|
| Bundle Size | ~5KB | ~13KB | ~8KB | ~48KB |
| Zero Dependencies | β | β | β | β |
| TypeScript First | β | Partial | Partial | Partial |
| Discriminated Unions | β | β | β | β |
| Never Throws | β | β | β | β |
| Runtime Validation | β Built-in | β | β | β |
| Type Guards | β Built-in | β | β | β |
| Request ID Tracking | β Built-in | β | β | β |
| Duration Metrics | β Built-in | β | β | β |
| Server-side Optimized | β | β | β | β |
| Automatic Retries | β | Via plugin | β | β |
| Request/Response Interceptors | β | β | β | β |
| Response Caching | β Built-in | β | β | β |
- axios: Large bundle, throws on HTTP errors, requires interceptors for structured error handling
- ky: Throws on HTTP errors, limited TypeScript inference, no built-in validation
- got: Node.js only, large bundle, complex API, throws on HTTP errors
CustomFetch is purpose-built for server-side TypeScript applications where type safety and predictable error handling are paramount.
- GitHub: @HidayetCanOzcan
- Email: hidayetcan@gmail.com
This project is not accepting external contributions.
CustomFetch is a personal project maintained solely by the author. Bug reports and feature requests can be submitted via GitHub Issues, but pull requests will not be reviewed or merged.
MIT License Β© 2024 Hidayet Can Γzcan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Made with β€οΈ for the TypeScript community
If you find this package useful, consider giving it a β on GitHub!