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

aspi

v2.8.0

Published

Rest API client for typescript projects with chain of responsibility design pattern.

Readme

aspi

Bundle Size License: MIT

A tiny, type-safe HTTP client for TypeScript built on native fetch.

Zero runtime dependencies. Three response modes. Full error-union types.


Features

  • Zero dependencies — thin wrapper around the platform fetch API
  • Three response modes — tuple [data, error], Result monad, or throwable (your choice per call, plus explicit .withTuple() to reset)
  • Typed error unions — every error variant is tagged and narrowable at compile time
  • Custom error mapping — map any HTTP status code to a structured, typed error object
  • Retry with back-off — fixed or dynamic delay, status-code filtering, custom predicates
  • Schema validation — validate request bodies and responses via any StandardSchemaV1 library (Zod, Valibot, Arktype, …)
  • Middleware — transform the RequestInit for every request via use()
  • Capabilities — plugin-level interception of the raw fetch call (logging, token refresh, tracing)

Installation

npm install aspi
# or
yarn add aspi
# or
pnpm add aspi

TypeScript 5+ is required as a peer dependency.


Quick start

import { Aspi, Result } from 'aspi';

const api = new Aspi({
  baseUrl: 'https://jsonplaceholder.typicode.com',
  headers: { 'Content-Type': 'application/json' },
});

// Tuple mode — default
const [data, error] = await api
  .get('/todos/1')
  .notFound(() => ({ message: 'Todo not found' }))
  .json<{ id: number; title: string; completed: boolean }>();

if (error) {
  if (error.tag === 'aspiError') console.error(error.response.status);
  if (error.tag === 'notFoundError') console.warn(error.data.message);
  if (error.tag === 'jsonParseError') console.error(error.data.message);
}

if (data) console.log(data.title);

Response modes

Every request can be consumed in one of three modes. Switch mode by calling .withResult(), .throwable(), or .withTuple() before the body-parser method.

1. Tuple mode (default)

Returns [AspiResultOk | null, ErrorUnion | null]. Familiar to anyone who has used Go-style error handling.

const [data, error] = await api.get('/users/1').json<User>();

if (error) {
  /* handle */
}
console.log(data!.name);

2. Result mode

Returns a Result<Ok, ErrorUnion> tagged union. Use .withResult() to enable.

const result = await api.get('/users/1').withResult().json<User>();

Result.match(result, {
  onOk: ({ data }) => console.log(data.name),
  onErr: (err) => console.error(err.tag, err),
});

3. Throwable mode

Returns the parsed value directly and throws a typed error on any non-2xx response. Use .throwable() to enable.

try {
  const { data } = await api.get('/users/1').throwable().json<User>();
  console.log(data.name);
} catch (err) {
  if (err.tag === 'aspiError') console.error(err.response.status);
}

throwable(), withResult(), and withTuple() are mutually exclusive — the last one called wins. Use .withTuple() to explicitly reset back to the default tuple mode after a previous .withResult() or .throwable().


Error handling

Built-in error variants

Every response mode surfaces the same tagged error variants:

| Tag | When | | ------------------ | ------------------------------------------------------------------------------------------------- | | aspiError | Any non-2xx response with no matching custom handler | | jsonParseError | Response body could not be parsed as JSON | | schemaParseError | Response (or request body) failed schema validation (when .schema() or .bodySchema() is used) | | custom | Any tag you define via .error() or a convenience shortcut |

Custom error mapping

Map an HTTP status to a typed, tagged error object. The callback receives the full request and response.

const [data, error] = await api
  .post('/login')
  .bodyJson({ email, password })
  .error('rateLimitedError', 'TOO_MANY_REQUESTS', ({ response }) => ({
    retryAfter: response.response.headers.get('Retry-After'),
  }))
  .json<{ token: string }>();

if (error?.tag === 'rateLimitedError') {
  console.warn('Retry after', error.data.retryAfter, 'seconds');
}

Convenience shortcuts

Pre-built shortcuts for the most common statuses. Each produces a typed error with a predictable tag.

| Method | Status | Error tag | | -------------------------- | ------ | ---------------------- | | .notFound(cb) | 404 | notFoundError | | .badRequest(cb) | 400 | badRequestError | | .unauthorized(cb) | 401 | unauthorizedError | | .forbidden(cb) | 403 | forbiddenError | | .conflict(cb) | 409 | conflictError | | .tooManyRequests(cb) | 429 | tooManyRequestsError | | .notImplemented(cb) | 501 | notImplementedError | | .internalServerError(cb) | 500 | internalServerError |

Note: When calling these on the Request object (e.g. api.get('/…').unauthorised(…)) the method is spelled .unauthorised() (British) and produces an unauthorisedError tag. On the Aspi instance itself the method is .unauthorized() (American). All other shortcuts are spelled identically on both.

const [data, error] = await api
  .get('/account')
  .notFound(() => ({ message: 'Account does not exist' }))
  .unauthorized(() => ({ message: 'Please sign in' }))
  .json<Account>();

if (error?.tag === 'notFoundError') redirect('/signup');
if (error?.tag === 'unauthorizedError') redirect('/login');

Inspecting AspiError

The base aspiError variant exposes the full request and response, plus an .ifMatch() helper for conditional handling.

if (error?.tag === 'aspiError') {
  console.log(error.response.status); // numeric HTTP status code
  console.log(error.response.statusLabel); // e.g. "NOT_FOUND"
  console.log(error.response.statusText); // raw status text
  console.log(error.request.path); // request path

  // Run a callback only for a specific status
  error.ifMatch('INTERNAL_SERVER_ERROR', ({ response }) => {
    reportToSentry(response);
  });
}

Making requests

HTTP methods

api.get('/users');
api.post('/users');
api.put('/users/1');
api.patch('/users/1');
api.delete('/users/1');
api.head('/users');
api.options('/users');

Request body

Use .bodyJson() to send a JSON payload. Pair it with .bodySchema() to validate the body before the network call.

// Plain JSON body
const [data, error] = await api
  .post('/users')
  .bodyJson({ name: 'Alice', email: '[email protected]' })
  .json<User>();

// Validated body (Zod example)
import { z } from 'zod';

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const [data, error] = await api
  .post('/users')
  .bodySchema(CreateUserSchema) // validate before sending
  .bodyJson({ name: 'Alice', email: '[email protected]' })
  .json<User>();

// If bodyJson fails validation, error.tag === 'schemaParseError'

Query parameters

.setQueryParams() accepts an object, URLSearchParams, an array of tuples, or a raw string.

// Object — most common
api.get('/todos').setQueryParams({ page: '2', limit: '20' }).json();

// URLSearchParams
api
  .get('/todos')
  .setQueryParams(new URLSearchParams({ q: 'typescript' }))
  .json();

// Check the resolved URL before sending
console.log(api.get('/todos').setQueryParams({ page: '2' }).url());
// → https://api.example.com/todos?page=2

Headers

// Single header
api.get('/data').setHeader('X-Request-ID', crypto.randomUUID());

// Multiple headers
api.get('/data').setHeaders({ Accept: 'application/json', 'X-Version': '2' });

// Bearer token shortcut
api.get('/me').setBearer(accessToken);

Retry

Configure retry behavior globally on the Aspi instance, then override per request as needed.

const api = new Aspi({
  baseUrl: 'https://api.example.com',
  headers: { 'Content-Type': 'application/json' },
}).setRetry({
  retries: 3,
  retryDelay: 500, // fixed 500 ms between attempts
  retryOn: [429, 500, 502, 503, 504],
});

// Override for a single request — exponential back-off
const [data, error] = await api
  .get('/reports/heavy')
  .setRetry({
    retryDelay: (remaining, total) => Math.pow(2, total - remaining) * 200,
    retryWhile: (_req, res) => res.status >= 500,
    onRetry: (_req, res) => console.warn('Retrying after', res.status),
  })
  .withResult()
  .json<Report>();

Retry config options

| Option | Type | Description | | ------------ | ----------------------------------------------------------- | --------------------------------------------- | | retries | number | Maximum number of retry attempts | | retryDelay | number \| (remaining, total, request, response) => number | Delay in ms, or a function returning one | | retryOn | number[] | HTTP status codes that should trigger a retry | | retryWhile | (request, response) => boolean | Custom predicate — return true to retry | | onRetry | (request, response) => void | Hook called after each failed attempt |


Schema validation

Aspi integrates with any library that implements the StandardSchemaV1 interface, including Zod, Valibot, and Arktype.

Attach a schema with .schema() before the body-parser. The inferred output type is used automatically — you don't need to pass a generic.

Aspi supports both synchronous and asynchronous validate implementations, so schema libraries that return Promise<Result> work out of the box.

import { z } from 'zod';

const TodoSchema = z.object({
  id: z.number(),
  title: z.string(),
  completed: z.boolean(),
});

const result = await api.get('/todos/1').withResult().schema(TodoSchema).json(); // return type is inferred from the schema

Result.match(result, {
  onOk: ({ data }) => console.log(data.title), // data: { id: number; title: string; completed: boolean }
  onErr: (err) => {
    if (err.tag === 'schemaParseError') {
      console.error('Validation failed:', err.data); // StandardSchemaV1 issue list
    }
  },
});

Middleware

.use() registers a request transformer that runs for every request created from the instance. It returns a new Aspi instance typed with the transformed request shape.

// Add a correlation ID to every outgoing request
const api = new Aspi({ baseUrl: 'https://api.example.com' }).use((req) => ({
  ...req,
  headers: {
    ...req.headers,
    'X-Correlation-ID': crypto.randomUUID(),
  },
}));

// Chain multiple transformers
const authedApi = api.use((req) => ({
  ...req,
  headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
}));

Capabilities

Experimental — names and behavior may change in minor versions.

Capabilities are plugins that wrap the low-level fetch call. Unlike middleware (which transforms the RequestInit), capabilities can inspect the raw Response, call runner() multiple times, or return a synthetic response entirely.

import type { Capability } from 'aspi';

const loggingCapability: Capability = ({ request }) => ({
  async run(runner) {
    console.log('→', request.requestInit.method, request.path);
    const res = await runner();
    console.log('←', res.status, res.statusText);
    return res;
  },
});

const api = new Aspi({ baseUrl: 'https://api.example.com' }).useCapability(
  loggingCapability,
);

Capabilities are composed in registration order, each wrapping the next.

const api = new Aspi({ baseUrl: 'https://api.example.com' })
  .useCapability(loggingCapability)
  .useCapability(tracingCapability)
  .useCapability(tokenRefreshCapability);

Example: token refresh capability

import type { Capability } from 'aspi';

let tokens = { access: '', refresh: '' };

const tokenRefreshCapability: Capability = () => {
  let isRefreshing = false;

  return {
    async run(runner) {
      const res = await runner();
      if (res.status !== 401 || !tokens.refresh || isRefreshing) return res;

      isRefreshing = true;
      try {
        const refreshRes = await fetch('/auth/refresh', {
          method: 'POST',
          body: JSON.stringify({ refreshToken: tokens.refresh }),
          headers: { 'Content-Type': 'application/json' },
        });
        const body = await refreshRes.json();
        tokens = { access: body.accessToken, refresh: body.refreshToken };
      } finally {
        isRefreshing = false;
      }

      // Retry the original request with the new token
      return runner();
    },
  };
};

Result module

aspi exports a standalone Result module — a small tagged-union utility used internally and available for your own code.

import * as Result from 'aspi/result';
// or
import { Result } from 'aspi';

Creating results

const success = Result.ok(42); // { __tag: 'ok', value: 42 }
const failure = Result.err('not found'); // { __tag: 'err', error: 'not found' }

Checking and extracting

Result.isOk(success); // true
Result.isErr(failure); // true

Result.getOrNull(success); // 42
Result.getOrNull(failure); // null

Result.getErrorOrNull(failure); // 'not found'
Result.getOrElse(failure, 0); // 0

Result.getOrThrow(success); // 42
Result.getOrThrow(failure); // throws 'not found'

Result.getOrThrowWith(failure, (e) => new Error(e)); // throws Error('not found')

Transforming

Result.map(success, (n) => n * 2); // ok(84)
Result.mapErr(failure, (e) => e.toUpperCase()); // err('NOT FOUND')

// Curried style (useful in pipelines)
const double = Result.map((n: number) => n * 2);
double(success); // ok(84)

Pattern matching

const message = Result.match(result, {
  onOk: ({ data }) => `Loaded ${data.name}`,
  onErr: (err) => `Failed: ${err.tag}`,
});

Handling tagged errors

When the error type is a tagged union, use catchError and catchErrors to handle specific variants and narrow the remaining type.

type AppError =
  | { tag: 'notFoundError'; message: string }
  | { tag: 'unauthorizedError' }
  | { tag: 'aspiError'; response: AspiResponse };

// Handle one tag
Result.catchError(result, 'notFoundError', (e) => {
  console.warn(e.message);
});

// Handle multiple tags
Result.catchErrors(result, {
  notFoundError: (e) => console.warn(e.message),
  unauthorizedError: () => redirect('/login'),
});

Pipe utility

const price = Result.pipe(
  1234,
  (cents) => cents / 100,
  (amount) => amount.toFixed(2),
  (str) => `$${str}`,
);
// '$12.34'

Global configuration reference

These methods are available on the Aspi instance and affect all requests created from it.

| Method | Description | | ------------------------- | -------------------------------------------- | | setBaseUrl(url) | Change the base URL | | setHeaders(headers) | Merge an object of headers | | setHeader(key, value) | Set a single header | | setBearer(token) | Shortcut for Authorization: Bearer <token> | | setRetry(config) | Set a global retry strategy | | use(fn) | Register a request-transformer middleware | | useCapability(cap) | Register a capability | | withResult() | Switch all requests to Result mode | | throwable() | Switch all requests to throwable mode | | withTuple() | Switch all requests back to tuple mode | | .error(tag, status, cb) | Map an HTTP status to a typed error |

Per-request methods (api.get('/…').setQueryParams(…), .schema(…), .bodyJson(…), etc.) override the global config for that call only.


Contributing

# Install dependencies
pnpm install

# Run tests in watch mode
pnpm test

# Run tests once (used in CI)
pnpm test:run

# Build
pnpm build

# Type-check
pnpm lint

# Format
pnpm format

All CI checks (pnpm ci) run test, build, format check, and type-check in sequence. Please ensure they pass before opening a pull request.


License

MIT © Harsh Pareek