Skip to content

Repository files navigation

http-client

A typesafe and robust HTTP client with schema validation.

Why?

Typesafe by design: Path params, query strings, request bodies, and responses are all typed. Responses are typed per status code (with 2xx/4xx/5xx wildcard fallbacks), so a 200 body and a 404 body each carry their own type. Schema validation happens at runtime with full TypeScript inference.

Standard Schema compatible: Works with Zod, ArkType, Valibot, or any schema library implementing the Standard Schema spec.

Robust error handling: Typed errors for timeouts, network failures, serialization issues, and unexpected errors. No more guessing what went wrong.

Built-in retry: Configurable retry policies with contextual conditions and exponential backoff support.

Installation

npm install @afoures/http-client
# or
pnpm add @afoures/http-client
# or
yarn add @afoures/http-client
# or
bun add @afoures/http-client

The package has no runtime dependencies. It needs a runtime with fetch, AbortSignal.any, AbortSignal.timeout and URL.canParse: Node 20.3 or later (declared in engines), Bun, Deno, and every current browser. Schemas come from your own library, anything implementing the Standard Schema spec.

Quick Start

import { Endpoint, http_client } from "@afoures/http-client";
import { z } from "zod";

const api = http_client(
  {
    users: {
      list: new Endpoint(
        { method: "GET", pathname: "/users" },
        {
          query: {
            schema: z.object({
              page: z
                .number()
                .transform((n) => String(n))
                .optional(),
              limit: z
                .number()
                .transform((n) => String(n))
                .optional(),
            }),
          },
          responses: {
            200: {
              schema: z.array(z.object({ id: z.string(), name: z.string() })),
              parse: "json",
            },
          },
        },
      ),
      get: new Endpoint(
        { method: "GET", pathname: "/users/:id" },
        {
          responses: {
            200: {
              schema: z.object({ id: z.string(), name: z.string() }),
              parse: "json",
            },
            404: {
              schema: z.object({ message: z.string() }),
              parse: "json",
            },
          },
        },
      ),
      create: new Endpoint(
        { method: "POST", pathname: "/users" },
        {
          body: {
            schema: z.object({ name: z.string(), email: z.string().email() }),
            serialize: "json",
          },
          responses: {
            201: {
              schema: z.object({ id: z.string(), name: z.string() }),
              parse: "json",
            },
          },
        },
      ),
    },
  },
  { base_url: "https://api.example.com" },
);

// All endpoints are fully typed
const list = await api.users.list({ query: { page: 1, limit: 10 } });
const user = await api.users.get({ params: { id: "123" } });
const created = await api.users.create({ body: { name: "John", email: "john@example.com" } });

// Failures are returned, never thrown; peel them off first, then narrow on `status`
if (user instanceof Error) {
  console.error(user.message, user.context);
} else if (user.status === 200) {
  console.log(user.data.name); // { id: string; name: string }
} else if (user.status === 404) {
  console.warn(user.error.message); // { message: string }
}

Two things worth knowing before the first request:

  • base_url follows standard URL resolution, so a path prefix needs a trailing slash: "https://api.example.com/v1/" keeps /v1, "https://api.example.com/v1" drops it. See Base URL.
  • Every call takes one input object, even when empty: api.users.list({}).

Documentation

License

MIT

About

๐Ÿ”Ž typesafe and robust HTTP client

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages