okfetch is a small family of TypeScript-first HTTP packages built around one idea: make fetch safer and more composable without hiding how the web platform works.
Heavily inspired by better-fetch 🙌🏻
The repo is split into focused packages:
@okfetch/fetchfor direct typed requests with validation, retries, plugins, timeouts, auth, and streaming@okfetch/apifor schema-defined endpoint trees that generate a typed API client@okfetch/loggerfor a ready-madepinoplugin you can drop into request flows
All request execution is based on better-result, so success and failure stay explicit as data instead of being pushed into exception-based control flow.
| Package | What it does | Best for |
|---|---|---|
@okfetch/fetch |
Direct fetch wrapper with runtime validation and lifecycle hooks |
Low-level requests and shared transport config |
@okfetch/api |
Typed API client generated from endpoint definitions | Larger applications with repeated API calls |
@okfetch/logger |
pino-based plugin for okfetch hooks |
Request/response logging without writing your own plugin |
Package-level docs:
- Validate response payloads with any Standard Schema-compatible library before they reach business logic
- Validate endpoint
body,params, andquerybefore a request is sent - Handle transport and API failures with typed
Resultvalues - Reuse cross-cutting concerns through plugins instead of ad hoc wrappers
- Add retries, auth, timeouts, and streaming without giving up standard
fetch
bun add @okfetch/fetch better-resultnpm install @okfetch/fetch better-resultbun add @okfetch/api @okfetch/fetch better-resultnpm install @okfetch/api @okfetch/fetch better-resultbun add @okfetch/logger @okfetch/fetch pinonpm install @okfetch/logger @okfetch/fetch pinoimport { okfetch } from "@okfetch/fetch";
import { z } from "zod/v4";
const todoSchema = z.object({
completed: z.boolean(),
id: z.number(),
title: z.string(),
userId: z.number(),
});
const result = await okfetch("https://jsonplaceholder.typicode.com/todos/1", {
outputSchema: todoSchema,
});
result.match({
err: (error) => console.error(error._tag, error.message),
ok: (todo) => console.log(todo.title),
});import { createApi, createEndpoints } from "@okfetch/api";
import { z } from "zod/v4";
const todoSchema = z.object({
completed: z.boolean(),
id: z.number(),
title: z.string(),
userId: z.number(),
});
const endpoints = createEndpoints({
todos: {
get: {
method: "GET",
output: todoSchema,
params: z.object({ id: z.number() }),
path: "/todos/:id",
},
create: {
body: z.object({
title: z.string().min(1),
userId: z.number(),
}),
method: "POST",
output: todoSchema,
path: "/todos",
},
},
});
const api = createApi({
baseURL: "https://jsonplaceholder.typicode.com",
endpoints,
});
const result = await api.todos.get({ params: { id: 1 } });import { okfetch } from "@okfetch/fetch";
import { logger } from "@okfetch/logger";
const result = await okfetch("https://example.com/health", {
plugins: [logger()],
});@okfetch/fetch is the transport core. It owns request execution, retries, streaming support, auth, plugin execution, timeout behavior, and parsing.
@okfetch/api sits on top of @okfetch/fetch. It turns endpoint definitions into typed client methods and injects request validation based on the schemas attached to each endpoint.
@okfetch/logger is optional sugar. It is just a plugin package built on the public OkfetchPlugin interface from @okfetch/fetch.
Both direct requests and generated client calls resolve to a Result.
That means callers can use .isOk(), .isErr(), .map(), .match(), and other better-result helpers instead of relying on try/catch for expected HTTP and validation failures.
@okfetch/fetch can validate:
- successful response bodies with
outputSchema - structured API error payloads with
apiErrorDataSchema - stream chunks when
stream: trueis enabled
@okfetch/api adds request-side validation for:
bodyparamsquery
Schemas are library-agnostic as long as they implement Standard Schema v1, so zod, valibot, arktype, and similar libraries can be passed directly.
Helpers from @okfetch/fetch:
import { validateAllErrors, validateClientErrors } from "@okfetch/fetch";validateClientErrorsvalidates only4xxresponsesvalidateAllErrorsvalidates both4xxand5xxresponses
Retries support:
"fixed""linear""exponential"
You can set them globally or per request:
await okfetch("https://api.example.com/users/1", {
retry: {
attempts: 3,
initialDelay: 200,
strategy: "exponential",
},
timeout: 5000,
});Plugins can participate in the request lifecycle through:
initonRequestonResponseonSuccessonFailonRetry
This makes it easy to add logging, tracing, metrics, request rewriting, custom auth, or any other cross-cutting concern once and reuse it everywhere.
Set stream: true to receive a ReadableStream.
import { okfetch } from "@okfetch/fetch";
import { z } from "zod/v4";
const result = await okfetch("https://example.com/events", {
stream: true,
outputSchema: z.object({
id: z.number(),
message: z.string(),
}),
});Each SSE data: chunk is parsed independently. If you pass an outputSchema, each chunk is validated before it is emitted by the stream.
@okfetch/fetch returns tagged errors:
FetchErrorTimeoutErrorApiErrorParseErrorValidationErrorPluginError
ValidationError.type identifies the failing boundary:
"body""query""params""output""error"
A small runnable example lives in examples/app/index.ts.
Run it with:
bun run --cwd examples/app devIt demonstrates:
- one direct
okfetch(...)call - one generated typed API client
- one request-side validation failure
Useful commands from the repo root:
bun x ultracite fixbun x ultracite checkbun test packages/fetch/src/index.test.tsbun test packages/api/src/index.test.ts