@kylecodes/http-client
v0.1.0
Published
A single typed fetch wrapper: structured requests in, zod-validated values out, structured errors on failure.
Downloads
98
Maintainers
Readme
@kylecodes/http-client
A single typed fetch wrapper: structured requests in, zod-validated values out, structured errors on failure.
- One function.
httpRequest()is the entire API. No client classes, no interceptor chains. - Validated by construction. Pass a zod schema and the return type is inferred from it — the network boundary is also the type boundary.
- Structured errors. Non-2xx responses throw
HttpResponseError(withstatus,url, and the parsed body); transport failures throwHttpNetworkError(with the originalcause). Schema mismatches propagate the rawZodErrorso you can inspect the issues directly. - Runtime-agnostic. Built on global
fetch,URL, andURLSearchParamsonly — works on Node ≥18, Bun, Deno, and browsers. Zero dependencies besides your own zod. - Testable.
fetchis injectable, so tests stub aResponseinstead of a network.
Install
npm install @kylecodes/http-client zod
# or
bun add @kylecodes/http-client zodzod (v4) is a peer dependency — you supply the schemas, so you own the zod version.
Usage
GET with a validated response
import { httpRequest } from "@kylecodes/http-client";
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string().optional(),
});
// Return type is inferred from the schema: Promise<z.infer<typeof UserSchema>>
const user = await httpRequest({
url: "https://api.example.com/users/123",
headers: { authorization: `Bearer ${token}` },
schema: UserSchema,
});
user.email; // string — validated at the boundary, typed everywhere afterQuery params
const results = await httpRequest({
url: "https://api.example.com/search",
params: { q: "quarterly report", page: "2" }, // encoded for you
schema: SearchResultsSchema,
});POST — JSON body
await httpRequest({
method: "POST",
url: "https://api.example.com/notes",
body: { title: "hello", starred: true }, // JSON-encoded, content-type set
schema: NoteSchema,
});POST — form-encoded body (e.g. an OAuth2 token exchange)
const TokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
refresh_token: z.string().optional(),
});
const tokens = await httpRequest({
method: "POST",
url: "https://oauth2.example.com/token",
encoding: "form", // urlencoded body + content-type
body: {
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: clientId,
},
schema: TokenResponseSchema,
});Error handling
import {
httpRequest,
HttpResponseError,
HttpNetworkError,
} from "@kylecodes/http-client";
import { z } from "zod";
try {
return await httpRequest({ url, schema });
} catch (err) {
if (err instanceof HttpResponseError) {
// A response arrived but was non-2xx.
err.status; // 429
err.url; // the full URL requested
err.body; // parsed JSON body if possible, else raw text
} else if (err instanceof HttpNetworkError) {
// fetch itself threw — DNS failure, connection refused, …
err.cause; // the original TypeError
} else if (err instanceof z.ZodError) {
// 2xx, but the body didn't match the schema. Deliberately NOT wrapped.
err.issues;
}
throw err;
}All error classes extend HttpError → AppError → Error, so a single instanceof HttpError catches both response and network failures.
Without a schema
When you genuinely can't validate (exploratory calls, pass-through proxies), omit schema and assert the type yourself:
const raw = await httpRequest<{ items: unknown[] }>({ url });Testing — inject fetch
The second argument carries dependencies. Tests hand in a stub and never touch the network:
import { describe, expect, test } from "bun:test"; // or vitest/jest
test("fetches the profile", async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ id: "1", email: "[email protected]" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
const user = await httpRequest(
{ url: "https://api/x", schema: UserSchema },
{ fetchImpl },
);
expect(user.email).toBe("[email protected]");
});API
function httpRequest<S extends ZodType>(
req: HttpRequest<S> & { schema: S },
deps?: HttpDeps,
): Promise<z.infer<S>>;
function httpRequest<T = unknown>(
req: HttpRequest<undefined>,
deps?: HttpDeps,
): Promise<T>;
interface HttpRequest<S> {
method?: "GET" | "POST";
url: string;
params?: Record<string, string>; // appended as a querystring
headers?: Record<string, string>;
body?: Record<string, unknown>;
encoding?: "json" | "form"; // default 'json'
schema?: S;
}
interface HttpDeps {
fetchImpl?: typeof fetch; // default: global fetch
}Design notes
BodyInit/Responseare never exposed — code that decides what to request stays separate from the code that does the request.- Retry/backoff is intentionally not built in;
httpRequestis the single choke point where a retry wrapper would attach if you need one.
Development
bun install
bun run build # tsc → dist/ (ESM + .d.ts + source maps)
bun testLicense
MIT
