- 📦 Below 575 Bytes minified + compressed (brotli)
- ✅ Zero dependencies
Define a typed JSON API client with a fixed hook pipeline: init → fetch → error → success. Omit hooks to get JSON-API defaults; override any stage (especially fetch in tests).
import { defineApi } from "@peerigon/typescript-toolkit/api";
const api = defineApi("https://api.example.com");
const user = await api({
url: "/users/1",
method: "GET",
});await api({
url: "/users", // must start with /
method: "POST",
query: { page: "1" }, // URLSearchParams or constructor params
body: { name: "Ada" }, // JSON-serialized
signal: AbortSignal.timeout(5_000),
});| Hook | Sync/async | Arguments | Default behavior |
|---|---|---|---|
init |
sync | FetchInput |
Join baseUrl + path + query, apply headers, stringify body |
fetch |
async | url, RequestInit |
globalThis.fetch |
error |
async | Response |
Throw FetchError when !response.ok |
success |
async | Response |
response.json() |
baseUrl is applied by the default init. A custom init owns URL and header construction.
const api = defineApi("https://api.example.com", {
init: (input) => ({
url: `https://api.example.com${input.url}`,
method: input.method,
headers: new Headers(),
body: input.body === undefined ? undefined : JSON.stringify(input.body),
signal: input.signal,
}),
fetch: globalThis.fetch,
error: async (response) => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
},
success: async (response) => response.json(),
});Override the fetch hook. It matches the native fetch(url, init) signature, so you can call through to defaultFetch (alias of globalThis.fetch):
import { defaultFetch, defineApi } from "@peerigon/typescript-toolkit/api";
const api = defineApi("https://api.example.com", {
fetch: async (url, init) => {
if (process.env.IS_TESTING === "true") {
return Response.json({ source: "mock" });
}
return defaultFetch(url, init);
},
});FetchInput has no headers field. Pass them as the second argument to defaultInit when overriding init:
import { defaultInit, defineApi } from "@peerigon/typescript-toolkit/api";
const baseUrl = "https://api.example.com";
const api = defineApi(baseUrl, {
init: defaultInit(baseUrl, (input) => ({
Authorization: `Bearer ${getToken()}`,
"X-Request-Path": input.url,
})),
});When body is set, Content-Type: application/json is added unless already present.
defineApi(baseUrl: string, options?: DefineApiOptions):
(input: FetchInput) => Promise<unknown>| Parameter | Type | Description |
|---|---|---|
baseUrl |
string |
Prefixed onto input.url (default init only) |
options |
DefineApiOptions |
Optional hook overrides |
| Option | Type | Description |
|---|---|---|
init |
(input: FetchInput) => FetchOptions |
Build request options |
fetch |
typeof globalThis.fetch |
Execute the request |
error |
(response: Response) => void | Promise<void> |
Throw on failure |
success |
(response: Response) => Promise<unknown> |
Decode the response body |
Also exported: defaultInit, defaultFetch, defaultError, defaultSuccess, FetchError, and related types.
- Empty /
204responses: Defaultsuccesscallsresponse.json(), which fails on empty bodies — overridesuccesswhen you need that - Error body: Default
errordoes not consume the response body; read it in a customerrororsuccesshook if needed - Custom
init: IgnoresbaseUrl— pass it into your owninit/defaultInitif needed - Return type: Always
Promise<unknown>— narrow per call site as needed
For Result.Sync instead of throws, use api/result:
import { defineApiResult } from "@peerigon/typescript-toolkit/api/result";For paced requests and Retry-After retries, use api/rate-limit:
import { defineLimitedFetch } from "@peerigon/typescript-toolkit/api/rate-limit";
const api = defineApi("https://api.example.com", {
fetch: defineLimitedFetch({ max: 10, interval: 1000 }),
});