Skip to content

HidayetCanOzcan/CustomFetch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸš€ CustomFetch

Production-Ready, 100% Type-Safe HTTP Client

npm version TypeScript Zero Dependencies License: MIT Node.js Bun

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?

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-fetch

Requirements

  • 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.env and Buffer.


πŸš€ 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: "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");

πŸ“– 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 type
  • E - Error type (must extend BaseError, defaults to BaseError)

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 undefined

2. 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

GitHub Email

Contact


⚠️ 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!

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published