npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

feature-fetch

v0.1.1

Published

Typed fetch client with tuple results and opt-in REST, OpenAPI, GraphQL, retry, and cache features

Downloads

11,515

Readme

feature-fetch turns fetch into a typed API client with explicit success and error branches. Requests return tuple results instead of throwing, TypeScript checks request shapes, and built-in or custom .with() features add REST helpers, OpenAPI, GraphQL, retry, cache, auth, or tracing without changing the client model.

  • Handle network errors, HTTP errors, and client errors without try/catch
  • Add REST helpers, OpenAPI types, GraphQL, retry, cache, or delay only when needed
  • Catch unknown OpenAPI paths, missing params, and wrong bodies at compile time
  • Move auth, logging, tracing, and other cross-cutting behavior into client features
import { createApiFetchClient, retryFeature } from 'feature-fetch';

const api = createApiFetchClient({
  baseUrl: 'https://api.example.com/v1'
}).with(retryFeature({ maxRetries: 3 }));

const [isPostOk, postErr, post] = await api.get<{ id: string; title: string }>('/posts/{postId}', {
  pathParams: { postId: '123' }
});

if (!isPostOk) {
  console.error(postErr.message); // NetworkError | HttpError | FetchError
} else {
  console.log(post.title); // typed as { id: string; title: string }
}

Migrating from 0.0.x? See MIGRATION.md.

Install

npm install feature-fetch

Usage

Pick the client that matches your API shape:

  • createApiFetchClient: REST endpoints without generated schema types
  • createOpenApiFetchClient<paths>: REST endpoints typed from an OpenAPI schema
  • createGraphQLFetchClient: GraphQL endpoints with operation and variable types

For REST endpoints, start with createApiFetchClient:

import { createApiFetchClient } from 'feature-fetch';

const api = createApiFetchClient({
  baseUrl: 'https://api.example.com/v1'
});

const [isPostOk, postErr, post] = await api.get<{ id: string; title: string }>('/posts/{postId}', {
  pathParams: { postId: '123' }
});

if (!isPostOk) {
  console.error(postErr.message);
} else {
  console.log(post.title);
}

For OpenAPI schemas, generate a paths type and pass it to createOpenApiFetchClient:

import { createOpenApiFetchClient } from 'feature-fetch';
import type { paths } from './schema';

const api = createOpenApiFetchClient<paths>({
  baseUrl: 'https://api.example.com/v1'
});

const [isPetOk, petErr, pet] = await api.get('/pets/{petId}', {
  pathParams: { petId: 123 }
});

if (!isPetOk) {
  throw petErr;
}

console.log(pet);

For GraphQL endpoints, use createGraphQLFetchClient:

import { createGraphQLFetchClient, gql } from 'feature-fetch';

const graphql = createGraphQLFetchClient({
  baseUrl: 'https://api.example.com/graphql'
});

const getUser = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`;

const [isUserOk, userErr, userResult] = await graphql.query<
  { user: { id: string; name: string } },
  { id: string }
>(getUser, { variables: { id: '123' } });

if (!isUserOk) {
  throw userErr;
}

console.log(userResult.user.name);

Compose features with createFetchClient when you want control over which features are installed and in what order:

import { apiFeature, cacheFeature, createFetchClient, retryFeature } from 'feature-fetch';

const api = createFetchClient({
  baseUrl: 'https://api.example.com/v1'
}).with(apiFeature(), cacheFeature({ maxAgeMs: 30_000 }), retryFeature());

Client

createFetchClient(options)

Creates a base fetch client with a single request() method. All built-in features build on top of this method.

const client = createFetchClient({
  baseUrl: 'https://api.example.com'
});

const [isHealthOk, healthErr, health] = await client.request<{ status: string }>('GET', '/health');

if (!isHealthOk) {
  throw healthErr;
}

console.log(health.data.status);

| Option | Default | Description | | ----------------- | ---------------------- | ------------------------------------------------------------------- | | baseUrl | '' | Prepended to every request path | | headers | {} | Default headers applied to every request | | fetch | globalThis.fetch | Custom fetch implementation | | requestInit | {} | Default RequestInit values except body, method, and headers | | pathSerializer | serializePathParams | Serializes {path} parameters | | querySerializer | serializeQueryParams | Serializes query parameters | | bodySerializer | serializeBody | Serializes request bodies | | prepareRequest | [] | Hooks called before URL and body are built | | prepareResponse | [] | Hooks called after a response is received, before parsing | | middleware | [] | Wrappers around the final fetch call |

Request options

Each call to request() and the method helpers from installed features accept these core options:

| Option | Default | Description | | ------------- | -------- | --------------------------------------------------------------------------------- | | pathParams | {} | Values for {param} placeholders in the path | | queryParams | {} | Appended to the URL as a query string | | headers | | Per-request headers merged after client defaults. null removes a default header | | parseAs | 'json' | Response parser: 'json', 'text', 'blob', 'arrayBuffer', or 'stream' | | signal | | AbortSignal for cancellation | | meta | {} | Request-scoped metadata passed to prepareRequest and prepareResponse hooks | | middleware | [] | Request-scoped middleware appended after client middleware | | baseUrl | | Overrides the client base URL for this request | | requestInit | | Overrides native RequestInit values except body, method, and headers |

request() and body-capable helpers also accept body and bodySerializer. Non-body REST helpers and OpenAPI operations without a request body reject body-specific options.

The REST, OpenAPI, and GraphQL helpers also accept withResponse: true when you need the raw Response on the success branch.

FormData bodies automatically have Content-Type removed so the browser can add the required multipart boundary.

Built-in Features

apiFeature()

Adds typed HTTP method helpers: get, post, put, patch, delete, options, head, and trace.

import { apiFeature, createFetchClient } from 'feature-fetch';

const api = createFetchClient({ baseUrl: '/api' }).with(apiFeature());

const [isPostOk, postErr, post] = await api.post<{ id: string }>('/posts', {
  body: { title: 'Hello' }
});

if (!isPostOk) {
  throw postErr;
}

console.log(post.id);

await api.delete('/posts/{postId}', { pathParams: { postId: '123' } });

createApiFetchClient(options) is shorthand for createFetchClient(options).with(apiFeature()).

openApiFeature<paths>()

Adds HTTP helpers fully typed from an OpenAPI schema. Unknown paths, missing required params, wrong bodies, and unexpected fields are rejected at compile time.

Generate the paths type from your schema:

npx openapi-typescript ./schema.yaml -o ./schema.d.ts
import { createOpenApiFetchClient } from 'feature-fetch';
import type { paths } from './schema';

const api = createOpenApiFetchClient<paths>({
  baseUrl: 'https://api.example.com/v1'
});

const [isPetOk, petErr, pet] = await api.get('/pets/{petId}', {
  pathParams: { petId: 123 }
});

if (!isPetOk) {
  throw petErr;
}

const [isCreated, createErr, created] = await api.post('/pets', {
  body: { name: 'Jeff', photoUrls: [] }
});

if (!isCreated) {
  throw createErr;
}

console.log(pet, created);

Schema-declared header parameters are typed in headers. Extra transport headers such as Authorization remain allowed alongside them.

createOpenApiFetchClient<paths>(options) is shorthand for createFetchClient(options).with(openApiFeature<paths>()).

graphqlFeature()

Adds query(), mutate(), and raw variants for GraphQL POST requests. Set baseUrl to the full GraphQL endpoint.

import { createGraphQLFetchClient, gql } from 'feature-fetch';

const graphql = createGraphQLFetchClient({
  baseUrl: 'https://api.example.com/graphql'
});

const getUser = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`;

const [isUserOk, userErr, userResult] = await graphql.query<
  { user: { id: string; name: string } },
  { id: string }
>(getUser, { variables: { id: '123' } });

if (!isUserOk) {
  throw userErr;
}

console.log(userResult.user.name);

query() and mutate() return operation data by default and unwrap GraphQL errors arrays into a GraphQLError on the error branch. Pass withResponse: true to receive { data, extensions?, response }, or use queryRaw() and mutateRaw() to receive the raw { data, errors, extensions } response without that unwrapping.

createGraphQLFetchClient(options) is shorthand for createFetchClient(options).with(graphqlFeature()).

retryFeature(options)

Retries failed requests. Network errors use exponential backoff. HTTP responses are retried when shouldRetryResponse returns true, defaulting to HTTP 429. Respects Retry-After, and uses x-rate-limit-reset when x-rate-limit-remaining is 0.

import { createApiFetchClient, retryFeature } from 'feature-fetch';

const api = createApiFetchClient({ baseUrl: '/api' }).with(retryFeature({ maxRetries: 3 }));

| Option | Default | Description | | -------------------------- | ---------- | --------------------------------------------- | | maxRetries | 3 | Number of retries after the initial request | | networkError.baseDelayMs | 1000 | Base delay in ms for exponential backoff | | networkError.maxDelayMs | 30000 | Maximum delay in ms for network-error backoff | | shouldRetryResponse | HTTP 429 | Predicate for retryable HTTP responses |

cacheFeature(options)

Caches successful GET responses in memory. Respects Cache-Control response headers. Skips requests with Authorization or Cookie headers to avoid mixing user-specific responses.

import { cacheFeature, createApiFetchClient } from 'feature-fetch';

const api = createApiFetchClient({ baseUrl: '/api' }).with(cacheFeature({ maxAgeMs: 30_000 }));

// Clear all cached responses
api.cache.clear();

// Invalidate entries matching a predicate
api.cache.invalidate((key) => key.includes('/posts'));

| Option | Default | Description | | ------------- | ------------------------- | ----------------------------------------------------------------------------------------- | | maxAgeMs | 300000 (5 minutes) | Maximum age in milliseconds | | getCacheKey | GET URL, no auth headers | Returns the cache key for a request, or null to skip caching | | shouldCache | OK, non-private responses | Returns whether a response should be cached. Skips no-store, no-cache, and private. |

The default cache also skips requestInit.cache: 'no-store' | 'reload' and responses with Set-Cookie.

delayFeature(ms)

Waits the given number of milliseconds before forwarding each request. Useful in tests and demos.

import { createApiFetchClient, delayFeature } from 'feature-fetch';

const api = createApiFetchClient({ baseUrl: '/api' }).with(delayFeature(500));

Extending with Features

Fetch features are regular feature-core features. A feature can add new methods, push hooks or middleware into the client config, or both.

import { defineFeature, type TFeature } from 'feature-core';
import type { TFetchClientBase } from 'feature-fetch';

export function authFeature(getToken: () => string): TAuthFeature {
  return defineFeature<TAuthFeature>({
    key: 'auth',
    install(client: TFetchClientBase) {
      client._config.prepareRequest.push((cx) => {
        cx.headers.authorization = `Bearer ${getToken()}`;
      });

      return {};
    }
  });
}

type TAuthFeature = TFeature<'auth', object>;

Three extension points are available in _config:

  • prepareRequest: hooks that mutate the request context before URL and body are built
  • middleware: wrappers around the final fetch call (receives the next function and returns a new one)
  • prepareResponse: hooks that can inspect or replace the raw response before parsing

Feature order matters because middleware is applied outermost-first. Install cacheFeature before retryFeature so cache is checked first and the retry logic only runs on cache misses.

Errors

All request methods return a tuple-result. The error branch is one of three types:

| Error | When it occurs | | -------------- | -------------------------------------------------------------------------- | | NetworkError | The fetch call threw before any HTTP response was received | | HttpError | The server returned a non-2xx response | | FetchError | Request preparation, serialization, middleware, or response parsing failed |

GraphQL errors arrays return GraphQLError, which extends FetchError.

import { FetchError, hasStatusCode, isHttpError, NetworkError } from 'feature-fetch';

const [isUserOk, userErr, user] = await api.get<User>('/users/123');

if (!isUserOk) {
  if (hasStatusCode(userErr, 404)) {
    console.error('Not found');
  } else if (userErr instanceof NetworkError) {
    console.error('Network error:', userErr.message);
  } else if (isHttpError(userErr)) {
    console.error('HTTP error:', userErr.status, userErr.data); // userErr.data is the parsed error body
  } else if (userErr instanceof FetchError) {
    console.error('Client error:', userErr.code, userErr.message); // userErr.code e.g. '#ERR_SERIALIZE_BODY'
  }
}

hasStatusCode(error, code) checks HttpError instances and error-like objects with a numeric status. Use isHttpError(error) when you need the typed HTTP response body. Use isGraphQLError(error) when you need typed partial GraphQL operation data.

Examples

FAQ

How does it compare to ky, openapi-fetch, and graphql-request?

feature-fetch focuses on typed results and feature composition. Use it when you want one client model for REST, OpenAPI, GraphQL, retry, cache, auth, and custom transport behavior.

How do I add auth headers to every request?

Pass static headers in the client options:

const api = createApiFetchClient({
  baseUrl: 'https://api.example.com/v1',
  headers: { Authorization: 'Bearer <token>' }
});

For dynamic tokens that may change between requests, use a prepareRequest hook instead. See Extending with Features for an authFeature example that calls getToken() on each request.

What is the difference between middleware and prepareRequest?

Use prepareRequest to mutate the structured request context: headers, path, query params, body, and metadata. It runs before the URL is built and before the body is serialized.

Use middleware for transport-level concerns that wrap the fetch call itself: retry, caching, timing, and tracing. Middleware receives (next) => (url, requestInit) => Promise<Response> and can call next zero or more times.

If you need to read or modify the final URL or RequestInit, use middleware. If you need to modify request inputs in a structured way, use prepareRequest.

Can I type the error response body?

Yes. Pass it as the second generic parameter on an API request method. OpenAPI clients infer it from the operation's non-2xx response bodies.

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

const [isUserOk, userErr, user] = await api.get<User, ApiError>('/users/123');

if (!isUserOk && isHttpError(userErr)) {
  console.error(userErr.data?.code); // data is typed as ApiError | undefined
}

userErr.data is typed as ApiError when the error is an HttpError. It remains unknown for NetworkError and FetchError.

In what order should I install features?

Install features in the order you want them to intercept requests, outermost first. For common combinations:

  • cacheFeature before retryFeature: cache is checked first; retry only runs on cache misses
  • retryFeature before a logging middleware: retry attempts are each logged individually

Features that add methods (apiFeature, openApiFeature, graphqlFeature) can go in any position relative to middleware features.

How do I mock requests in tests?

Pass a custom fetch function to createFetchClient. Return any Response you need:

const api = createApiFetchClient({
  baseUrl: '/api',
  fetch: async () => new Response(JSON.stringify({ id: '1' }), { status: 200 })
});

For more control, use delayFeature in development or a mock server in integration tests.

How do I cancel a request?

Pass an AbortSignal in the request options:

const controller = new AbortController();

const postsRequest = api.get('/posts', {
  signal: controller.signal
});

// Cancel from anywhere
controller.abort();

const [isPostsOk, postsErr, posts] = await postsRequest;

if (!isPostsOk) {
  console.error(postsErr.message);
} else {
  console.log(posts);
}

A cancelled request returns NetworkError on the error branch.

Why is @0no-co/graphql.web a dependency if GraphQL is opt-in?

@0no-co/graphql.web is imported dynamically only when a DocumentNode input needs to be printed to a string. If you pass operation strings directly or never install graphqlFeature(), modern bundlers tree-shake that path out of the bundle.