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 🙏

© 2025 – Pkg Stats / Ryan Hefner

wiretyped

v0.2.1

Published

Typed HTTP client with error-first ergonomics, caching, retries, SSE, and runtime validation powered by @standard-schema/spec.

Readme

WireTyped

Typed HTTP client utilities for defining endpoints with @standard-schema, issuing requests, and handling errors in an error-first style.

Why

  • Typed endpoints first: Define once with the schema of your choice, get full TypeScript safety for params, bodies, and responses.
  • Error-first ergonomics: Returns [error, data] tuples (a Go-like pattern) to avoid hidden throws and make control flow explicit.
  • Runtime validation: Optional request/response validation to catch mismatches early, not in production logs.
  • Pragmatic helpers: Built-in caching, retries, and SSE support with minimal configuration.
  • Runtime errors: I hate them, and wanted to get rid of them.
  • Badges: Plus, look at these cool badges.

CI Coverage minzip

npm JSR

Contents

Installation

pnpm add wiretyped
# or: npm install wiretyped
# or: npx jsr add @kasperrt/wiretyped

Quick start

Define your endpoints with the schema of your choice (re-exported for convenience) and create a RequestClient.

Notes on path params:

  • Use $path when you want constrained values (e.g., enums for /integrations/{provider} and want said providers to be from a given set like slack, salesforce, etc.).
  • For dynamic segments that accept generic strings/numbers, you can omit $path—the URL template (e.g., /users/{id}) already infers string/number.
import { RequestClient, type RequestDefinitions, z } from 'wiretyped/core';

const endpoints = {
  '/users/{id}': {
    get: {
      response: z.object({ id: z.string(), name: z.string() }),
    },
  },
} satisfies RequestDefinitions;

const client = new RequestClient({
  baseUrl: 'https://api.example.com',
  hostname: 'api.example.com',
  endpoints,
  validation: true,
});

const [err, user] = await client.get('/users/{id}', { id: '123' });
if (err) {
  return err; // preferably re-wrap, and don't throw, you'll go to jail
};
console.log(user.name);

Prefer a single import? The root export works too:

import { RequestClient, type RequestDefinitions } from 'wiretyped';

Imports

  • Root: import { RequestClient, ...errors } from 'wiretyped'
  • Subpath: import { RequestClient } from 'wiretyped/core'
  • Errors-only: import { HTTPError, unwrapErrorType, ... } from 'wiretyped/error'

Client options

  • baseUrl (required): Base path prepended to all endpoints (e.g., https://api.example.com/).

  • hostname (required): Absolute hostname used when building URLs (e.g., https://api.example.com); keeps url() outputs absolute.

  • endpoints (required): Your typed endpoint definitions (RequestDefinitions).

  • validation (default true): Validate request/response bodies using your schema definitions; can be overridden per call.

  • debug (default false): Log internal client debug info.

  • cacheOpts: Configure the cache store for GET requests (used when cacheRequest is enabled per-call).

    {
      ttl?: number;              // Default cache TTL in ms (default 500)
      cleanupInterval?: number;  // How often to evict expired entries (default 30_000)
    }
  • fetchOpts: Default fetch options for all calls (headers, credentials, timeout, retry).

    {
      headers?: Record<string, string>;  // Merged with defaults; adds { Accept: 'application/json' } by default
      credentials?: RequestCredentials;  // Passed to fetch
      mode?: RequestMode;                // Passed to fetch
      timeout?: number | false;          // Request timeout in ms (default 60_000). false disables
      retry?: number | {                 // Per-call retry (default limit 2, timeout 1000ms, retry on 408/429/500-504 and always on timeout or other errors)
        limit?: number;                  // How many times to retry (total attempts = limit + 1)
        timeout?: number;                // Ms between retries
        statusCodes?: number[];          // Status codes to retry
        ignoreStatusCodes?: number[];    // Status codes to never retry
      };
    }

Request options

Per-call options mirror the fetch-level options (FetchOptions) with extra cache/validation flags for GET.

{
  headers?: Record<string, string>;  // Merged with defaults; adds { Accept: 'application/json' } by default
  credentials?: RequestCredentials;  // Passed to fetch
  mode?: RequestMode;                // Passed to fetch
  timeout?: number | false;          // Request timeout in ms (default 60_000). false disables
  retry?: number | {                 // Per-call retry (default limit 2, timeout 1000ms, retry on 408/429/500-504 and always on timeout or other errors)
    limit?: number;                  // How many times to retry (total attempts = limit + 1)
    timeout?: number;                // Ms between retries
    statusCodes?: number[];          // Status codes to retry
    ignoreStatusCodes?: number[];    // Status codes to never retry
  };
  validate?: boolean;                // Override global validation

  // Only available for GET requests
  cacheRequest?: boolean;            // GET only: enable in-memory cache
  cacheTimeToLive?: number;          // GET only: cache TTL in ms (default 500)
}

Runtime config (optional)

RequestClient exposes a config() helper to update defaults at runtime—useful for rotated auth headers, new retry/timeout settings, or cache tuning. It is entirely optional; if you never call it, the client sticks with the constructor options.

// Later in your app lifecycle
client.config({
  fetchOpts: {
    headers: { Authorization: `Bearer ${token}` },    // merged with existing + default Accept
    credentials: 'include',                           // fetch-level only
    retry: { limit: 1 },                              // max retries; total attempts = limit + 1
    timeout: 10_000,                                  // request timeout in ms
  },
  cacheOpts: { ttl: 5_000, cleanupInterval: 30_000 }, // cache defaults when cacheRequest is enabled
});

The method forwards fetch-related updates to the underlying fetch provider and cache-related updates to the cache client without recreating them, so connections and caches stay intact while defaults change.

Disposal

RequestClient runs a small cleanup interval for the in-memory cache. For short-lived clients (scripts, tests), call client.dispose() to clear timers and drop cached entries. If your custom fetch provider exposes dispose, it will be called too (useful for cleaning up agents, sockets, etc.).

const client = new RequestClient({ /* ... */ });
// ...use the client...
client.dispose(); // clears cache timers/state and invokes provider dispose if present

Methods

Each method is a thin, typed wrapper over your endpoint definitions. The shape stays consistent: (endpointKey, params, [body], options), and every call returns an error-first tuple [error, data] so you can handle outcomes without hidden throws.

GET

Request definition:

const endpoints = {
  '/users': { get: { $search: z.object({ limit: z.number().optional() }).optional(), response: z.array(z.object({ id: z.string() })) } },
  '/integrations/{provider}': {
    get: {
      $path: z.object({ provider: z.enum(['slack', 'github']) }),
      response: z.object({ provider: z.enum(['slack', 'github']), status: z.string() }),
    },
  },
} satisfies RequestDefinitions;

Fetch data with optional query/path validation and opt-in caching.

const [err, users] = await client.get('/users', { $search: { limit: 10 } });
const [integrationErr, integration] = await client.get('/integrations/{provider}', { $path: { provider: 'slack' } });

POST

Request definition:

const endpoints = {
  '/users': {
    post: { request: z.object({ name: z.string(), email: z.string().email() }), response: z.object({ id: z.string(), name: z.string(), email: z.string() }) },
  },
} satisfies RequestDefinitions;

Create resources with validated request/response bodies.

const [err, created] = await client.post('/users', null, { name: 'Ada', email: '[email protected]' });

PUT

Request definition:

const endpoints = {
  '/users/{id}': {
    put: {
      request: z.object({ name: z.string(), email: z.string().email() }),
      response: z.object({ id: z.string(), name: z.string(), email: z.string() }),
    },
  },
} satisfies RequestDefinitions;

Replace resources, validating both path and payload.

const [err, updated] = await client.put('/users/{id}', { id: '123' }, { name: 'Ada', email: '[email protected]' });

PATCH

Request definition:

const endpoints = {
  '/users/{id}': {
    patch: {
      request: z.object({ name: z.string().optional() }),
      response: z.object({ id: z.string(), name: z.string() }),
    },
  },
} satisfies RequestDefinitions;

Partially update resources.

const [err, patched] = await client.patch('/users/{id}', { id: '123' }, { name: 'Ada Lovelace' });

DELETE

Request definition:

const endpoints = {
  '/users/{id}': {
    delete: { response: z.object({ deleted: z.boolean() }) },
  },
} satisfies RequestDefinitions;

Delete resources; still typed responses if your API returns a body.

const [err, deletion] = await client.delete('/users/{id}', { id: '123' });

DOWNLOAD

Request definition:

const endpoints = {
  '/files/{id}/download': {
    download: { response: z.instanceof(Blob) },
  },
} satisfies RequestDefinitions;

Retrieve binary data (e.g., Blob/stream).

const [err, file] = await client.download('/files/{id}/download', { id: 'file-1' });

URL

Request definition:

const endpoints = {
  '/links': { url: { response: z.string().url() } },
} satisfies RequestDefinitions;

Return a constructed URL string without performing a request.

const [err, link] = await client.url('/links', null);

SSE

Request definition:

const endpoints = {
  '/events': { sse: { response: z.string() } },
} satisfies RequestDefinitions;

Subscribe to server-sent events; signature is (endpoint, params, handler, options). Returns a stop function for the stream.

const [err, close] = await client.sse(
  '/events',
  null,
  ([err, data]) => {
    if (err) return console.error('sse error', err);
    console.log('sse message', data);
  },
  // The SSE client also inherits credentials adding from the fetchOpts
  // as long as it is not 'omit', so usually this will end up sending withCredentials: true
  { withCredentials: true },
);

if(err) {
  return new Error('some error-handling', { cause: err });
}

// Closer
close();

Caching

GET requests can use an in-memory cache.

  • Per-call: client.get('/users', params, { cacheRequest: true, cacheTimeToLive: 60_000 })
  • Global cache defaults (applied when cacheRequest is true): new RequestClient({ ..., cacheOpts: { ttl: 60_000, cleanupInterval: 30_000 } })

Cache keys are derived from the constructed URL. When cacheRequest is enabled, cached data is returned until the TTL expires (per-call TTL wins; otherwise the cache client's ttl is used).

Be careful when enabling caching across callers: the cache is local to the client instance and keyed by URL plus headers. If two requests hit the same URL, the only reliable way to guarantee they do not overlap in the cache is to vary the headers (e.g., swap in a distinguishing header value) so the derived key changes.

In general, to avoid any issues, avoid caching sensitive data.

Retries

Configure retries via retry on request options (or globally in the client constructor). Default retriable codes: 408, 429, 500–504. Be careful enabling retries on non-idempotent verbs (POST/PATCH/PUT/DELETE) to avoid duplicate side effects.

  • Number only: retry: 3 (just a limit)
  • Custom object:
const [err, data] = await client.get('/users', params, {
  retry: {
    limit: 5,                // max retries (total attempts = limit + 1)
    statusCodes: [429, 500], // retry only these statuses
    ignoreStatusCodes: [404], // never retry on these (skip retry)
    timeout: 500,            // wait 500ms between tries
  },
});

Example with a timeout focus:

const [err, _] = await client.post('/users', null, body, {
  timeout: 10_000,
  retry: { limit: 2, statusCodes: [408], timeout: 1000 },
});

Error handling

wiretyped/error exports helpers for richer error handling:

import { HTTPError, getHttpError, isHttpError, isTimeoutError, unwrapErrorType } from 'wiretyped/error';

const [err, user] = await client.get('/users/{id}', { $path: { id: '123' } });
if (err) {
  const httpError = getHttpError(err);
  if (httpError) {
    console.error('error request failed with status', httpError.status);
    return _something_here_http_error_;
  } 
  
  if (isTimeoutError(err)) {
    console.error('error request timed out');
    return _something_here_timeout_error_;
  }

  return _something_here_general_error_;
}

Exposed entrypoints

  • Root import (client, types, errors): wiretyped
  • Core client and types: wiretyped/core
  • Error helpers: wiretyped/error

Providers

Defaults are FetchClient for HTTP and the global EventSource for SSE. Override only if you need custom transports. If your runtime does not provide EventSource (e.g., Node without a polyfill), install one such as eventsource and pass it as sseProvider when constructing the client.

HTTP provider shape

interface FetchClientProvider {
  new (baseUrl: string, opts: FetchClientOptions): FetchClientProviderDefinition;
}

interface FetchClientProviderDefinition {
  get(url: string, opts: Omit<FetchOptions, 'method' | 'body'>): SafeWrapAsync<Error, FetchResponse>;
  put(url: string, opts: Omit<FetchOptions, 'method'>): SafeWrapAsync<Error, FetchResponse>;
  patch(url: string, opts: Omit<FetchOptions, 'method'>): SafeWrapAsync<Error, FetchResponse>;
  post(url: string, opts: Omit<FetchOptions, 'method'>): SafeWrapAsync<Error, FetchResponse>;
  delete(url: string, opts: Omit<FetchOptions, 'method' | 'body'>): SafeWrapAsync<Error, FetchResponse>;
  config(opts: FetchClientOptions): void;
}

SSE provider shape

interface SSEClientProvider {
  new (url: string | URL, init?: { withCredentials?: boolean }): SSEClientProviderInstance;
}

interface SSEClientProviderInstance {
  readonly url: string;
  readonly withCredentials: boolean;
  readonly readyState: number;
  readonly CLOSED: 2;
  readonly CONNECTING: 0;
  readonly OPEN: 1;
  onopen: ((ev: Event) => void) | null;
  onmessage: ((ev: MessageEvent) => void) | null;
  onerror: ((ev: Event) => void) | null;
  close(): void;
  addEventListener<K extends 'open' | 'message' | 'error'>(
    type: K,
    listener: (ev: K extends 'message' ? MessageEvent : Event) => void,
    options?: boolean | AddEventListenerOptions,
  ): void;
  removeEventListener<K extends 'open' | 'message' | 'error'>(
    type: K,
    listener: (ev: K extends 'message' ? MessageEvent : Event) => void,
    options?: boolean | EventListenerOptions,
  ): void;
  dispatchEvent(event: Event): boolean;
}

Pass these via fetchProvider or sseProvider in the RequestClient constructor when swapping transports.

Building

Library builds are handled by Vite:

pnpm build

Outputs land in dist/ as both ESM (*.mjs) and CJS (*.cjs) bundles, with declarations under dist/types.

Tests

  • Use Vitest with co-located files: prefer *.test.ts beside the code under test (e.g., fetch/client.ts and fetch/client.test.ts in the same folder).
  • Keep tests focused and readable: arrange inputs, act, then assert. Prefer the error-first tuple ergonomics to mirror real usage.
  • Stub external effects (fetch, timers, SSE) with lightweight fakes rather than hitting the network.
  • Favor small, focused cases over large integration-style suites.

Publishing

Publishing is automated via GitHub Actions on tags (v*). Keep versions in sync:

  • npm: package.json version
  • JSR: jsr.json version
  • Trigger: push a tag vX.Y.Z matching package.json version

CI will build, smoke-test, and publish to npm and JSR if the version isn’t already published.

Scripts

  • pnpm build – generate bundles (Vite) and type declarations.
  • pnpm test – run the Vitest suite.
  • pnpm check – type-check without emitting output.
  • pnpm format:fix / pnpm lint:fix / pnpm fix – Biome formatting and linting helpers.

FAQ

Why is the error first in the tuple?
So you can’t avoid handling it. Putting the error first forces you to look at it. If you still ignore it… that’s on you.


How can I access the response with status code and all that?
You can’t, because you don’t need it.
If you care about the status code, it’s almost always because of an error.
On success, you care about the data, not the status code.
If you feel you really need it, you’ve probably structured something wrong.


Why always return both error and data?
So you don’t end up with “floaty” types.
You either have an error defined or you have data defined.
(If your data is legitimately null, then you only have to care about error.)