Skip to content

hnkatze/fetchmate

Repository files navigation

fetchmate

A typed HTTP client built on the Fetch API — with response validation, reactive state, interceptors, retry, caching, and Angular support.

Features

  • Fully typed — generic methods infer response types end-to-end
  • Response validation — catch { success: false } on HTTP 200 before it reaches your code
  • Response transform — unwrap { data, message, success } envelopes automatically
  • Reactive stateresource() returns { loading, data, error } with subscriptions
  • Error hierarchy — distinct classes for HTTP, timeout, abort, network, parse, and validation errors
  • Typed errorsHttpError<TError> gives you typed error.data with errorTransform
  • Dynamic headers — pass a function that resolves auth tokens on every request
  • Lifecycle hooks — global onSuccess / onError for toasts, logging, analytics
  • Interceptors — transform requests and responses, or handle errors globally
  • Retry — exponential backoff with jitter, configurable limits and status codes
  • Cache — in-memory TTL cache with auto-invalidation on mutations
  • FormData / Blob — auto-detected, no manual Content-Type handling needed
  • Timeout — global default plus per-request override, merged with AbortSignal
  • Path & query params:id substitution, array serialization
  • Child clientsextend() inherits config, interceptors, and all options
  • Angular supportNgFetchMate service with Observable and Signal-based APIs
  • Zero dependencies — core uses only the native Fetch API

Installation

npm install @hnkatze/fetchmate

Angular peer dependencies (only required for the Angular integration):

npm install @angular/core @angular/common rxjs

Quick Start

import { createFetchMate } from '@hnkatze/fetchmate';

const api = createFetchMate({
  baseUrl: 'https://api.example.com/v1',
  headers: () => ({ Authorization: `Bearer ${getToken()}` }),
  transform: (raw) => raw.data,
  validateResponse: (raw) => {
    if (raw?.success === false) throw new Error(raw.message);
  },
  onError: (error) => toast.error(error.message),
  timeout: 10_000,
  retry: { limit: 3 },
  cache: { ttl: 60_000 },
});

// Typed requests — returns User[], not the wrapper
const users = await api.get<User[]>('/users');

// Reactive state
const resource = api.resource<User[]>('/users');
resource.subscribe(({ loading, data, error }) => {
  console.log({ loading, data, error });
});

API Reference

createFetchMate(config?)

const api = createFetchMate(config?: FetchMateConfig): FetchMateInstance;

FetchMateConfig

Option Type Default Description
baseUrl string '' Base URL prepended to every request path
headers Record<string, string> | () => Record | Promise<Record> {} Static headers or function evaluated per-request
timeout number 30_000 Global timeout in milliseconds
retry Partial<RetryConfig> | false Retry configuration; false disables
cache Partial<CacheConfig> | false Cache configuration; false disables
transform (raw: any) => any Unwrap response envelopes globally
validateResponse (raw, ctx) => void Validate raw body; throw to reject
errorTransform (data: unknown) => unknown Normalize error body before HttpError.data
onSuccess (data, ctx) => void Called after every successful request
onError (error, ctx) => void Called after every failed request

HTTP Methods

api.get<T>(path, options?): Promise<T>
api.post<T>(path, options?): Promise<T>
api.put<T>(path, options?): Promise<T>
api.patch<T>(path, options?): Promise<T>
api.delete<T>(path, options?): Promise<T>

RequestOptions

Option Type Description
params Record<string, string | number> Path parameter values (:key segments)
query Record<string, string | number | boolean | array> Query string parameters
body unknown Request body — JSON-serialized (or raw for FormData/Blob)
headers Record<string, string> Per-request headers (override config)
timeout number Per-request timeout in ms
signal AbortSignal External abort signal
retry Partial<RetryConfig> | false Per-request retry override
cache Partial<CacheConfig> | false Per-request cache override
raw boolean Return full FetchMateResponse<T>
transform fn | false Per-request transform override
validateResponse fn | false Per-request validation override

Response Validation

Catch API-level errors that come back as HTTP 200:

// Your API returns: { success: false, message: "Email taken", data: null }
// Without validation: you'd get null back and no error

const api = createFetchMate({
  baseUrl: 'https://api.example.com',
  transform: (raw) => raw.data,
  validateResponse: (raw, ctx) => {
    if (raw?.success === false) {
      throw new Error(raw.message); // "Email taken"
    }
  },
});

try {
  const user = await api.post<User>('/users', { body: { email: 'taken@test.com' } });
} catch (error) {
  console.error(error.message); // "Email taken"
}

Pipeline order: fetch → parse → validateResponse → errorCheck → interceptors → transform

  • Runs on ALL HTTP statuses (200, 422, etc.)
  • Throws before transform runs, so your transform never sees bad data
  • Disable per-request: { validateResponse: false }
  • Override per-request: { validateResponse: (raw) => { ... } }

Response Transform

Unwrap encapsulated API responses once, use everywhere:

const api = createFetchMate({
  baseUrl: 'https://api.example.com',
  transform: (raw) => raw.data,
});

// API returns: { success: true, message: "OK", data: [{ id: 1, name: "Alice" }] }
const users = await api.get<User[]>('/users');
// users = [{ id: 1, name: "Alice" }] — already unwrapped

// Skip transform for a specific request
const full = await api.get<ApiResponse>('/users', { transform: false });

// Override transform for a specific request
const msg = await api.get<string>('/users', { transform: (raw) => raw.message });

Reactive State — resource()

Get { loading, data, error } with automatic fetching and subscriptions:

const users = api.resource<User[]>('/users');

// Initial state: loading=true, data=undefined, error=undefined
// Auto-fetches on creation

users.subscribe(({ loading, data, error }) => {
  if (loading) showSpinner();
  else if (error) showError(error.message);
  else renderUsers(data);
});

// Refetch
await users.refetch();

// Optimistic update (no network call)
users.mutate([...users.data!, newUser]);

Also available as a standalone factory:

import { createResource } from '@hnkatze/fetchmate';

const users = createResource<User[]>(api, '/users', { query: { active: true } });

Dynamic Headers

Pass a function to resolve headers on every request — great for auth tokens:

const api = createFetchMate({
  baseUrl: 'https://api.example.com',
  headers: () => ({
    Authorization: `Bearer ${localStorage.getItem('token')}`,
    'X-Request-Id': crypto.randomUUID(),
  }),
});

// Async headers are also supported
const api2 = createFetchMate({
  headers: async () => {
    const token = await refreshTokenIfExpired();
    return { Authorization: `Bearer ${token}` };
  },
});

Per-request headers always override config headers on key conflicts.


Error Handling

Error classes

Class When thrown Notable properties
HttpError<TError> Non-2xx response status, statusText, url, data: TError, headers
ValidationError validateResponse throws url, data, message
TimeoutError Timeout exceeded url, timeout
AbortError Request cancelled url
NetworkError No connection url, cause
ParseError Body parse failure url, cause

All extend FetchMateError which extends Error.

Typed error data with errorTransform

interface ApiError {
  code: string;
  message: string;
}

const api = createFetchMate({
  baseUrl: 'https://api.example.com',
  errorTransform: (raw: any) => ({
    code: raw?.code ?? 'UNKNOWN',
    message: raw?.message ?? 'Something went wrong',
  }),
});

try {
  await api.get('/protected');
} catch (error) {
  if (error instanceof HttpError) {
    const { code, message } = error.data as ApiError;
    console.error(`[${code}] ${message}`);
  }
}

If errorTransform throws, fetchmate falls back to the raw response body — your app never breaks.

Full error handling example

import { HttpError, TimeoutError, NetworkError, ValidationError } from '@hnkatze/fetchmate';

try {
  const user = await api.get<User>('/users/999');
} catch (error) {
  if (error instanceof ValidationError) {
    console.error('API validation failed:', error.message);
  } else if (error instanceof HttpError) {
    console.error(`HTTP ${error.status}:`, error.data);
  } else if (error instanceof TimeoutError) {
    console.error(`Timed out after ${error.timeout}ms`);
  } else if (error instanceof NetworkError) {
    console.error('No connection');
  }
}

Lifecycle Hooks

Global callbacks for cross-cutting concerns. Hooks never break the request flow — if a hook throws, the error is silently caught.

const api = createFetchMate({
  baseUrl: 'https://api.example.com',
  transform: (raw) => raw.data,
  onSuccess: (data, ctx) => {
    analytics.track('api_success', { method: ctx.method, url: ctx.url });
  },
  onError: (error, ctx) => {
    toast.error(error.message);
    Sentry.captureException(error);
  },
});
  • onSuccess receives transformed data (after transform)
  • onError receives the normalized FetchMateError
  • Hooks do not fire for cache hits
  • Hooks do not affect return values or thrown errors

Interceptors

Transform every request or response, with unsubscribe support.

// Add a header to every request
const unsub = api.interceptors.request.use((ctx) => {
  ctx.headers.set('X-Trace-Id', generateId());
  return ctx;
});

// Log responses
api.interceptors.response.use((ctx) => {
  console.log(`[${ctx.status}] ${ctx.method} ${ctx.url}`);
  return ctx;
});

// Handle errors globally
api.interceptors.response.use(undefined, (error) => {
  if (error instanceof HttpError && error.status === 401) {
    window.location.href = '/login';
  }
  return error;
});

// Stop an interceptor
unsub();

Retry

Exponential backoff with jitter, configurable per-client or per-request.

Option Type Default Description
limit number 3 Max retry attempts
methods HttpMethod[] ['GET', 'PUT', 'DELETE'] Methods eligible for retry
statusCodes number[] [408, 429, 500, 502, 503, 504] Status codes that trigger retry
delay number 300 Base delay in ms
maxDelay number 10_000 Maximum delay cap
const api = createFetchMate({
  retry: { limit: 3, delay: 500 },
});

// Disable retry for a specific request
await api.post('/auth/token', { body: creds, retry: false });

Cache

In-memory TTL cache with automatic mutation invalidation.

Option Type Default Description
ttl number 60_000 Time-to-live in ms
maxEntries number 100 Max cached entries
methods HttpMethod[] ['GET'] Methods to cache
autoInvalidate boolean true Invalidate GET cache on mutations
const api = createFetchMate({
  cache: { ttl: 30_000 },
});

// First call: network request
const users = await api.get<User[]>('/users');

// Second call: served from cache
const cached = await api.get<User[]>('/users');

// POST to /users → auto-invalidates GET /users cache
await api.post('/users', { body: newUser });

// Next GET hits the network again (cache was invalidated)
const fresh = await api.get<User[]>('/users');

Disable auto-invalidation:

// Globally
createFetchMate({ cache: { ttl: 30_000, autoInvalidate: false } });

// Per-request
await api.post('/users', { body: data, cache: { autoInvalidate: false } });

FormData & File Upload

FormData and Blob bodies are auto-detected — no manual Content-Type handling needed:

const form = new FormData();
form.append('avatar', file);
form.append('name', 'Jane');

// Content-Type is automatically set to multipart/form-data with boundary
await api.post('/users/avatar', { body: form });

// Blob bodies also work
const blob = new Blob([csvData], { type: 'text/csv' });
await api.post('/import', { body: blob });

Timeout

const api = createFetchMate({ timeout: 15_000 });

// Per-request override
await api.get('/slow', { timeout: 60_000 });

// Combine with user AbortSignal
const controller = new AbortController();
await api.get('/data', { signal: controller.signal, timeout: 5_000 });

Path & Query Parameters

// Path params — /users/42/posts/15
await api.get('/users/:userId/posts/:postId', {
  params: { userId: 42, postId: 15 },
});

// Query params — /products?category=electronics&inStock=true
await api.get('/products', {
  query: { category: 'electronics', inStock: true },
});

// Array query params — /search?tag=ts&tag=node
await api.get('/search', {
  query: { tag: ['ts', 'node'] },
});

extend() — Child Clients

Create scoped clients that inherit all parent config:

const api = createFetchMate({
  baseUrl: 'https://api.example.com/v1',
  headers: () => ({ Authorization: `Bearer ${getToken()}` }),
  transform: (raw) => raw.data,
  validateResponse: (raw) => { if (!raw.success) throw new Error(raw.message); },
  onError: (err) => toast.error(err.message),
});

// Child inherits everything, adds extra header
const adminApi = api.extend({
  headers: { 'X-Admin': 'true' },
});

// Child overrides transform
const rawApi = api.extend({
  transform: (raw) => raw, // no unwrapping
});

Raw Mode

Access the full response metadata:

const response = await api.get<User>('/users/1', { raw: true });

response.status;  // 200
response.headers; // Headers
response.url;     // full URL
response.data;    // User (after transform)

Angular

The @hnkatze/fetchmate/angular entry point provides an Angular-native integration built on HttpClient.

Setup

// app.config.ts
import { provideHttpClient } from '@angular/common/http';
import { provideFetchMate } from '@hnkatze/fetchmate/angular';

export const appConfig = {
  providers: [
    provideHttpClient(),
    provideFetchMate({
      baseUrl: 'https://api.example.com/v1',
      timeout: 15_000,
    }),
  ],
};

Usage

import { inject } from '@angular/core';
import { NgFetchMate } from '@hnkatze/fetchmate/angular';

export class UsersComponent {
  private readonly http = inject(NgFetchMate);

  readonly users$ = this.http.get<User[]>('/users');
}
@if (users$ | async; as users) {
  @for (user of users; track user.id) {
    <div>{{ user.name }}</div>
  }
}

All methods return Observable<T> and support the same options as the core client (params, query, body, headers, timeout, retry).

Signal-based API (Angular >=19.2)

For projects using Angular's full signals approach, NgFetchMate also provides signal-based methods that coexist with the Observable API.

Reactive GET with resource()

Uses Angular's httpResource under the hood — the request re-fires automatically when signals in the URL factory change:

import { signal, computed } from '@angular/core';
import { NgFetchMate } from '@hnkatze/fetchmate/angular';

export class UserProfile {
  private readonly http = inject(NgFetchMate);

  readonly userId = signal(1);
  readonly user = this.http.resource<User>(() => `/users/${this.userId()}`);
}
@if (user.hasValue()) {
  <h1>{{ user.value().name }}</h1>
} @else if (user.isLoading()) {
  <spinner />
} @else if (user.error()) {
  <error-message [error]="user.error()" />
}

The returned HttpResourceRef<T> exposes signals: value(), isLoading(), error(), hasValue(), and a reload() method.

Resource options

// With path params and query
readonly posts = this.http.resource<Post[]>(
  () => `/users/${this.userId()}/posts`,
  {
    params: { userId: this.userId() },
    query: { limit: 10 },
  },
);

// With response validation (e.g., Zod)
readonly user = this.http.resource<User>(
  () => `/users/${this.userId()}`,
  { parse: userSchema.parse },
);

// With default value (removes undefined from type)
readonly users = this.http.resource<User[]>(
  () => '/users',
  { defaultValue: [] },
);

// Return undefined to skip the request (resource stays idle)
readonly user = this.http.resource<User>(
  () => this.userId() ? `/users/${this.userId()}` : undefined,
);

Signal-based mutations

For POST, PUT, PATCH, and DELETE, use the signal mutation methods. They reuse the same Observable pipeline (timeout, retry, error mapping) and expose the result as signals:

export class UserForm {
  private readonly http = inject(NgFetchMate);

  save(data: CreateUser) {
    const result = this.http.postSignal<User>('/users', { body: data });

    // result.value()     — Signal<User | undefined>
    // result.error()     — Signal<HttpError | undefined>
    // result.isLoading() — Signal<boolean>
    return result;
  }
}

All mutation methods:

http.postSignal<T>(path, options?)    // → NgMutationResult<T>
http.putSignal<T>(path, options?)     // → NgMutationResult<T>
http.patchSignal<T>(path, options?)   // → NgMutationResult<T>
http.deleteSignal<T>(path, options?)  // → NgMutationResult<T>

NgMutationResult<T>

Signal Type Description
value Signal<T | undefined> Response data (undefined while loading or on error)
error Signal<HttpError | undefined> Error (undefined on success)
isLoading Signal<boolean> Whether the request is in flight

Note: Signal-based methods require Angular >=19.2. Observable methods (get, post, put, patch, delete) continue to work on Angular >=17.


TypeScript

Exported types

// Core types
import type {
  FetchMateConfig,
  FetchMateInstance,
  FetchMateResponse,
  RequestOptions,
  HttpMethod,
  HeadersInit,
  RetryConfig,
  CacheConfig,
  ResponseTransform,
  ValidateResponseFn,
  ErrorTransformFn,
  RequestContext,
  ResponseContext,
  Resource,
  ResourceState,
} from '@hnkatze/fetchmate';

// Angular types
import type {
  NgFetchMateConfig,
  NgResourceOptions,
  NgMutationResult,
} from '@hnkatze/fetchmate/angular';

Request Pipeline

The full execution order with all features enabled:

1. Resolve dynamic headers
2. Detect body type (FormData/Blob → raw, object → JSON)
3. Apply request interceptors
4. fetch()
5. Parse response (JSON / text / undefined)
6. validateResponse (runs on ALL statuses)
7. Error check (!response.ok → errorTransform → throw HttpError)
8. Apply response interceptors
9. Apply transform
10. Cache store / auto-invalidation
11. onSuccess / onError hooks

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors