@hidayetcanozcan/custom-fetch
v1.0.0
Published
Production-ready, 100% type-safe HTTP client for Node.js and Bun server-side environments. Zero dependencies, discriminated union responses, automatic retries, interceptors, caching, runtime validation, and more.
Downloads
132
Maintainers
Readme
🚀 CustomFetch
Production-Ready, 100% Type-Safe HTTP Client
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
📋 Table of Contents
- Why CustomFetch?
- Features
- Installation
- Quick Start
- API Reference
- Configuration Options
- Response Types
- Advanced Usage
- TypeScript Types
- Best Practices
- Comparison with Alternatives
- Author
- License
🤔 Why CustomFetch?
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 |
The Problem with Traditional Fetch
// ❌ 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
}✨ Features
Core Features
| 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 |
HTTP Features
| 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 |
Developer Experience
| 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 |
📦 Installation
# npm
npm install @hco/custom-fetch
# yarn
yarn add @hco/custom-fetch
# pnpm
pnpm add @hco/custom-fetch
# bun
bun add @hco/custom-fetchRequirements
- 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.
🚀 Quick Start
Basic Usage
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
}Using the Client Factory
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: "[email protected]",
});
// PUT request
const updated = await api.put<User>("/users/1", { name: "Jane Doe" });
// PATCH request
const patched = await api.patch<User>("/users/1", { email: "[email protected]" });
// DELETE request
const deleted = await api.delete<void>("/users/1");📖 API Reference
CustomFetch Function
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
createClient Factory
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 |
Cache Utilities
// 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[] };Type Guard Helpers
// 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;⚙️ Configuration Options
Full list of options available in FetchOptions<T, E>:
Request Configuration
| 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) |
Authentication
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| customToken | string | - | Bearer token (automatically adds Authorization: Bearer <token> header) |
Timing & Retry
| 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) |
Status Handling
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| validateStatus | (status: number) => boolean | (status) => status >= 200 && status < 300 | Custom status validation function |
Runtime Validation
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| responseGuard | ResponseGuard<T> | - | Type guard to validate successful response shape |
| errorGuard | ErrorGuard<E> | - | Type guard to validate error response shape |
Transformers
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| responseTransformer | ResponseTransformer<T> | - | Transform raw response data to type T |
| errorTransformer | ErrorTransformer<E> | - | Transform raw error data to type E |
Caching
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| cacheResponse | boolean | false | Enable response caching |
| cacheTime | number | 300000 (5 min) | Cache TTL in milliseconds |
Other Options
| 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 |
Interceptors
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| onRequest | RequestInterceptor | - | Modify request before sending |
| onResponse | ResponseInterceptor<T, E> | - | Process/modify response after receiving |
📋 Response Types
SuccessResponse
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
}ErrorResponse
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)
}BaseError
Minimum error shape:
interface BaseError {
message: string;
[key: string]: string | number | boolean | null | undefined;
}🔧 Advanced Usage
Runtime Validation
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);
}Request/Response Interceptors
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;
},
});Retry with Exponential Backoff
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")Response Caching
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}
Timeout & Abort Controller
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 Response Handling
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-*Error Transformation
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
}Query Parameters
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=false📝 TypeScript Types
All 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";✅ Best Practices
1. Always Use Discriminated Unions
// ✅ 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 undefined2. Create Configured Client Instances
// ✅ 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 }});3. Use Runtime Validation for External APIs
// ✅ 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!4. Handle Errors Gracefully
// ✅ 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;5. Use Debug Mode During Development
// 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🔄 Comparison with Alternatives
| 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 | ❌ | ❌ | ✅ |
Why Not axios/ky/got?
- 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.
👤 Author
Hidayet Can Özcan
Contact
- GitHub: @HidayetCanOzcan
- Email: [email protected]
⚠️ Contributing
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.
📄 License
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!
