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

@unruly-software/api-client

v2.0.3

Published

Modelling Typescript value objects on top of Zod schemas. Define a type once, get runtime validation, a real class you can attach methods to, and lossless JSON.stringify round-tripping — without writing boilerplate.

Readme

@unruly-software/api-client

NPM Version License Coverage Status Bundle Size TypeScript Downloads

A type-safe API client built around Zod schemas. You describe each endpoint once — request shape, response shape, and whatever metadata your transport needs — and the client validates I/O on the way in and on the way out.

This is the core package of the @unruly-software/api monorepo. It is the only package you need to define endpoints and call them; the sibling packages (api-server, api-query, api-server-express) are optional layers that consume the same definitions.

Install

yarn add @unruly-software/api-client zod

zod is a peer dependency — version ^4.0.0.

Quick Start

Define your endpoints with defineAPI. The type parameter declares whatever metadata your transport needs (HTTP method and path here, but it could be a queue name, an IPC channel, an auth scope — whatever you want):

import { defineAPI } from '@unruly-software/api-client';
import z from 'zod';

const api = defineAPI<{
  path: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
}>();

export const apiDefinition = {
  getUser: api.defineEndpoint({
    request: z.object({ userId: z.number() }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string().email(),
    }),
    metadata: { method: 'GET', path: '/users/:userId' },
  }),
};

Construct a client with a resolver — a single function that takes the validated request and returns whatever the server sent back:

import { APIClient } from '@unruly-software/api-client';

const client = new APIClient(apiDefinition, {
  resolver: async ({ definition, request, abortSignal }) => {
    const response = await fetch(
      `https://api.example.com${definition.metadata.path}`,
      {
        method: definition.metadata.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request),
        signal: abortSignal,
      },
    );
    return response.json();
  },
});

Call it. The result is fully typed from the response schema:

const user = await client.request('getUser', { request: { userId: 123 } });
//    ^? { id: number; name: string; email: string }

The resolver

The resolver is the only thing the client needs to function. It receives the endpoint key, its full definition (including your metadata), the validated request, and an AbortSignal. It returns whatever raw value the response schema should parse.

import type { APIResolver } from '@unruly-software/api-client';

const resolver: APIResolver<typeof apiDefinition> = async ({
  endpoint,    // 'getUser'
  definition,  // the full endpoint definition with your metadata
  request,     // already validated against the request schema
  abortSignal, // forward to fetch / your transport
}) => {
  // Any transport works: fetch, axios, websocket, IPC, in-memory, a mock.
  return await transport.send(definition.metadata, request);
};

Anything the resolver throws becomes the error the caller sees (after the error formatter, if you've installed one). That's the hook the next section uses to turn server errors into typed exceptions.

Throwing typed errors from your server

The error formatter is the recommended place to convert raw transport errors into domain-specific error classes that callers can catch by instanceof. The full round trip looks like this:

1. Throw a domain error on the server. Define a class your handlers can throw, then teach the Express adapter how to serialise it. The api-server-express package accepts a handleError option exactly for this:

// shared/errors.ts
export class NotFoundError extends Error {
  readonly code = 'NOT_FOUND';
  constructor(message: string) {
    super(message);
    this.name = 'NotFoundError';
  }
}
// server.ts
import { mountExpressApp } from '@unruly-software/api-server-express';
import { NotFoundError } from './shared/errors';

mountExpressApp({
  app,
  router,
  makeContext: async (req) => ({ /* ... */ }),
  handleError: ({ error, res }) => {
    if (error instanceof NotFoundError) {
      res.status(404).json({ code: error.code, message: error.message });
      return;
    }
    res.status(500).json({ code: 'INTERNAL', message: error.message });
  },
});

A handler can now throw new NotFoundError('User 123 not found') and the server will respond with a recognisable JSON envelope.

2. Surface the body from the client resolver. Keep the resolver dumb — it just throws whatever the server returned, with enough context for the formatter to classify it:

class APIError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly code: string | undefined,
  ) {
    super(message);
  }
}

const client = new APIClient(apiDefinition, {
  resolver: async ({ definition, request, abortSignal }) => {
    const response = await fetch(
      `https://api.example.com${definition.metadata.path}`,
      {
        method: definition.metadata.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request),
        signal: abortSignal,
      },
    );

    if (!response.ok) {
      const body = await response.json().catch(() => ({}));
      throw new APIError(body.message ?? response.statusText, response.status, body.code);
    }

    return response.json();
  },
});

3. Convert it in the error formatter. Define a matching class on the client and re-throw it from setErrorFormatter. The formatter receives the original thrown error, so instanceof checks work:

import { NotFoundError } from './shared/errors';

client.setErrorFormatter((error, context) => {
  if (context.stage === 'resolver' && error instanceof APIError) {
    if (error.code === 'NOT_FOUND') {
      return new NotFoundError(error.message);
    }
  }
  return error;
});

4. Catch it by class at the call site.

try {
  const user = await client.request('getUser', { request: { userId: 123 } });
} catch (e) {
  if (e instanceof NotFoundError) {
    // render a 404 state, redirect, whatever
    return;
  }
  throw e;
}

The three formatter stages

context.stage is one of 'request-validation', 'resolver', or 'response-validation':

| Stage | When it fires | Published to $failed? | |---|---|---| | request-validation | Zod rejects the input you passed to client.request | No | | resolver | Your resolver throws (network failure, server error, etc.) | Yes | | response-validation | Zod rejects what the resolver returned | No |

Only the resolver stage publishes to $failed, so put cross-cutting "a request failed" telemetry in the formatter or in a $failed subscriber depending on whether you also want validation failures.

Cancelling requests

Pass an AbortSignal to request. The client forwards it to the resolver as abortSignal:

const controller = new AbortController();

const promise = client.request('getUser', {
  request: { userId: 123 },
  abort: controller.signal,
});

controller.abort();

Observing requests

Every client exposes two topics. Subscribe to either; the returned function unsubscribes.

const offSuccess = client.$succeeded.subscribe(({ endpoint, request, response }) => {
  console.log(`✓ ${String(endpoint)}`, { request, response });
});

const offFailure = client.$failed.subscribe(({ endpoint, request, error }) => {
  console.error(`✗ ${String(endpoint)}`, { request, error });
});

Remember that $failed only fires for resolver-stage errors. Validation failures throw without publishing — handle those in the error formatter if you need to observe them.

Other packages in this monorepo

| Package | When you'd reach for it | |---|---| | @unruly-software/api-server | When you also own the server side and want typed handlers with shared definitions and a context object. | | @unruly-software/api-query | When you're using @tanstack/react-query and want typed useAPIQuery / useAPIMutation hooks with declarative cache invalidation. | | @unruly-software/api-server-express (experimental) | When you want to plug an api-server router into an Express app, including the handleError hook used above. |

For end-to-end walkthroughs — including the typed-error round trip against a real Express server — see the examples directory, in particular examples/express-app. The root README covers the design rationale and how this framework compares to tRPC, GraphQL, OpenAPI, gRPC, ts-rest, and Zodios.

License

MIT