Typed HTTP fetch wrapper with IO integration
Make HTTP requests that return IO effects with typed errors, auto content-type detection, and full composability
HTTP fetch wrapper returning IO<never, HttpError, HttpResponse<unknown>> effects by default. Provide a validate function to get typed responses.
Http wraps the global fetch API with full IO integration:
IO<never, HttpError, HttpResponse<unknown>> by default — every request is a lazy, composable effectvalidate: (data: unknown) => T function to get HttpResponse<T>NetworkError, HttpStatusError, DecodeErrorglobalThis.fetch (browsers, Node 18+, Bun, Deno)Content-Type: application/jsonNothing runs until you call .run() or .runOrThrow().
import { Http } from "functype/fetch";
// Without validator — data is unknown
const effect = Http.get("/api/users/1");
const result = await effect.run(); // Either<HttpError, HttpResponse<unknown>>
// With validator — T inferred from validate return type
const typedEffect = Http.get("/api/users/1", {
validate: (data) => UserSchema.parse(data),
});
const typedResult = await typedEffect.run(); // Either<HttpError, HttpResponse<User>>
// POST with body — auto-serialized as JSON
const create = Http.post("/api/users", {
body: { name: "Alice", email: "alice@example.com" },
validate: (data) => UserSchema.parse(data),
});
// PUT, PATCH, DELETE
Http.put("/api/users/1", {
body: { name: "Alice Updated" },
validate: (data) => UserSchema.parse(data),
});
Http.patch("/api/users/1", {
body: { name: "Alice" },
validate: (data) => UserSchema.parse(data),
});
Http.delete("/api/users/1");
// HEAD and OPTIONS (return HttpResponse<void>)
Http.head("/api/users");
Http.options("/api/users");
// Low-level request with full control
Http.request({
url: "/api/users/1",
method: "GET",
headers: { "X-Request-Id": "abc123" },
validate: (data) => UserSchema.parse(data),
});
Without a validate function, response data is unknown. This is intentional — it prevents unsafe type casts and encourages runtime validation.
// Without validate — data is unknown, you must narrow it yourself
const effect = Http.get("/api/users");
// effect: IO<never, HttpError, HttpResponse<unknown>>
// With validate — T is inferred from the validator's return type
const typedEffect = Http.get("/api/users", {
validate: (data) => z.array(UserSchema).parse(data),
});
// typedEffect: IO<never, HttpError, HttpResponse<User[]>>
// Zod
Http.get("/api/users", { validate: (data) => z.array(UserSchema).parse(data) });
// TypeBox
Http.get("/api/users", { validate: (data) => Value.Decode(UserSchema, data) });
// Valibot
Http.get("/api/users", { validate: (data) => parse(UserSchema, data) });
// Manual validation
Http.get("/api/users", {
validate: (data) => {
if (!Array.isArray(data)) throw new Error("Expected array");
return data as User[];
},
});
Every successful request resolves to HttpResponse<T> (where T is unknown without a validator):
type HttpResponse<T> = {
readonly data: T; // Parsed response body (unknown without validate)
readonly status: number; // HTTP status code (e.g. 200)
readonly statusText: string; // HTTP status text (e.g. "OK")
readonly headers: Headers; // Response headers (standard Web API)
};
// Accessing response fields with a validator
const effect = Http.get("/api/users", {
validate: (data) => z.array(UserSchema).parse(data),
});
const either = await effect.run();
if (either._tag === "Right") {
const { data, status, headers } = either.value;
console.log(status); // 200
console.log(data[0].name); // "Alice" — data is User[], not unknown
console.log(headers.get("x-total-count"));
}
All HTTP errors are typed as HttpError, a union of three variants:
type NetworkError = {
_tag: "NetworkError";
url: string;
method: HttpMethod;
cause: unknown; // The underlying fetch error
};
type HttpStatusError = {
_tag: "HttpStatusError";
url: string;
method: HttpMethod;
status: number; // e.g. 404, 500
statusText: string;
body: string; // Raw response body text
};
type DecodeError = {
_tag: "DecodeError";
url: string;
method: HttpMethod;
body: string; // The text that failed to parse
cause: unknown;
};
import { Http, HttpError } from "functype/fetch";
const effect = Http.get("/api/users/1", {
validate: (data) => UserSchema.parse(data),
});
const result = await effect
.mapError((err) =>
HttpError.match(err, {
NetworkError: (e) => `Network failure: ${String(e.cause)}`,
HttpStatusError: (e) => `HTTP ${e.status}: ${e.statusText}`,
DecodeError: (e) => `Parse failed: ${e.body}`,
}),
)
.run();
// Recover from 404 with a default value
const user = Http.get("/api/users/99", {
validate: (data) => UserSchema.parse(data),
}).catchTag("HttpStatusError", (e) =>
e.status === 404
? IO.succeed({
data: null,
status: 404,
statusText: "Not Found",
headers: new Headers(),
})
: IO.fail(e),
);
// Handle network errors separately
const resilient = Http.get("/api/data").catchTag("NetworkError", () =>
Http.get("/api/data/fallback"),
);
import { HttpError } from "functype/fetch";
const either = await Http.get("/api/users/1", {
validate: (data) => UserSchema.parse(data),
}).run();
if (either._tag === "Left") {
const err = either.value;
if (HttpError.isHttpStatusError(err)) {
console.log(err.status, err.body); // typed as HttpStatusError
} else if (HttpError.isNetworkError(err)) {
console.log(err.cause); // typed as NetworkError
} else if (HttpError.isDecodeError(err)) {
console.log(err.body); // typed as DecodeError
}
}
Create a configured client with a base URL, default headers, or a custom fetch implementation:
import { Http } from "functype/fetch";
const api = Http.client({
baseUrl: "https://api.example.com/v1",
defaultHeaders: {
Authorization: `Bearer ${token}`,
"X-App-Version": "2.0.0",
},
});
// Paths are resolved relative to baseUrl
const users = api.get("/users", {
validate: (data) => z.array(UserSchema).parse(data),
}); // → https://api.example.com/v1/users
const user = api.get("/users/1", {
validate: (data) => UserSchema.parse(data),
}); // → https://api.example.com/v1/users/1
// Absolute URLs bypass baseUrl
const ext = api.get("https://other.com/data"); // → https://other.com/data (data is unknown)
// Custom fetch for testing or proxying
const testApi = Http.client({
baseUrl: "http://localhost:3000",
fetch: myMockFetch,
});
interface HttpClientConfig {
readonly baseUrl?: string; // Base URL prepended to relative paths
readonly defaultHeaders?: Record<string, string>; // Merged with per-request headers
readonly fetch?: typeof globalThis.fetch; // Override the fetch implementation
readonly beforeRequest?: (
request: HttpRequestView,
) => IO<never, HttpError, HttpRequestView>; // Effectful request transformer
readonly afterResponse?: (
response: HttpResponse<unknown>,
) => IO<never, HttpError, HttpResponse<unknown>>; // Success-path response transformer
}
Per-request headers always override defaultHeaders when keys conflict.
beforeRequestThe response side of an Http call composes on the returned IO — .tap, .map, .flatMap, .catchTag, .mapError, .retry, .timeout. The request side gets the same treatment via beforeRequest: an effectful transformer that runs after defaultHeaders and per-call headers are merged, but before the request is sent. It receives the assembled HttpRequestView and returns an IO<never, HttpError, HttpRequestView>, so concerns stack via standard IO operators.
import { Http, HttpError, type HttpRequestView } from "functype/fetch";
import { IO } from "functype";
// Each concern is a small function; sync ones use plain returns,
// effectful ones return an IO.
const addRequestId = (r: HttpRequestView): HttpRequestView => ({
...r,
headers: { ...r.headers, "x-request-id": crypto.randomUUID() },
});
const addBearer =
(getToken: () => Promise<string>) =>
(r: HttpRequestView): IO<never, HttpError, HttpRequestView> =>
IO.tryPromise({
try: () => getToken(),
catch: (e) => HttpError.networkError(r.url, r.method, e),
}).map((token) => ({
...r,
headers: { ...r.headers, Authorization: `Bearer ${token}` },
}));
const api = Http.client({
baseUrl: "https://api.example.com",
defaultHeaders: { "x-app": "civala" },
beforeRequest: (r) =>
IO.succeed(r)
.map(addRequestId) // sync header injection
.flatMap(addBearer(getToken)) // async, can fail with HttpError
.tap((req) => logger.info(req.method, req.url)), // side-effect
});
// Every call through `api` runs the full transformer stack.
const user = await api
.get("/users/me", { validate: (d) => UserSchema.parse(d) })
.runOrThrow();
Notes on the contract:
IO.fail(httpError) from beforeRequest aborts the call — fetch is never invoked and the error surfaces through the normal .catchTag / .run* paths.r.headers reflects defaultHeaders + per-call headers — beforeRequest is the final word.r.body is the pre-serialization value. Content-Type is derived after the hook runs, so swapping body for a different shape produces the correct content-type header.validate lives on the response side. HttpRequestView deliberately omits it; the hook can’t change response decoding.fetch override — they can coexist if you have a reason. For request-decoration use cases (auth, request IDs, logging), beforeRequest is the lighter touch and gives you typed HttpRequestOptions instead of raw (input, init).afterResponseSymmetric with beforeRequest, the afterResponse hook runs after the response is parsed (and the decoder, if any, succeeds) and before the IO resolves to the caller. Use it for ETag capture, response logging, metrics, or header transformations.
const api = Http.client({
baseUrl: "https://api.example.com",
afterResponse: (response) =>
IO.succeed(response)
.tap((r) => logger.info("response", { status: r.status }))
.map((r) => ({ ...r, headers: redactSensitiveHeaders(r.headers) })),
});
The contract differs from beforeRequest in one important way: afterResponse only runs on the success path. Errors — HttpStatusError (non-2xx), DecodeError (validation failure), NetworkError (fetch / abort) — skip the hook entirely. This keeps observability and recovery separate: response transforms live in afterResponse; error logging and recovery live in .catchTag(...) / .tapError(...) chains at the call site.
Pair retryWithBackoff with the HttpError tagged ADT for a sane production retry policy — retry network blips and server errors, never retry validation errors or 4xx:
import { Http, type HttpError } from "functype/fetch";
const isRetryable = (e: HttpError): boolean =>
e._tag === "NetworkError" ||
(e._tag === "HttpStatusError" && (e.status >= 500 || e.status === 429));
const result = await Http.get("/api/users", { decode: usersDecoder })
.retryWithBackoff({ n: 3, baseMs: 250, while: isRetryable })
.timeout(10_000)
.runOrThrow();
retryWithBackoff schedules min(maxMs, baseMs * factor^(attempt-1)) and applies full jitter (50–100% of the computed delay) by default — prevents thundering herd. retryWhile is the simpler sibling for fixed-delay (or no-delay) selective retry.
Refresh-on-401 is not an afterResponse pattern — it’s a .catchTag pattern, because a 401 is an error, not a successful response:
const api = Http.client({
baseUrl: "https://api.example.com",
beforeRequest: addBearer,
});
const me = await api
.get("/me", { decode: userDecoder })
.catchTag("HttpStatusError", (e) =>
e.status === 401
? refreshToken().flatMap(() => api.get("/me", { decode: userDecoder }))
: IO.fail(e),
)
.runOrThrow();
Pass params to any method (or to Http.request) — values are properly percent-encoded, arrays repeat the key, and undefined / null are dropped:
await api
.get("/search", {
params: {
q: "a b&c", // → q=a+b%26c
tag: ["x", "y"], // → tag=x&tag=y
page: 2, // → page=2
cursor: maybeCursor.toNullable(), // null is dropped if None
},
})
.runOrThrow();
If the URL already has a query string, params are merged (existing keys preserved; new keys appended).
Response bodies are parsed based on the Content-Type response header:
| Content-Type | Parse Mode | Result type |
|---|---|---|
application/json |
json |
Parsed JS object |
text/* (any text type) |
text |
string |
| Anything else | raw |
Response object |
Override auto-detection with the parseAs option:
type ParseMode = "json" | "text" | "blob" | "arrayBuffer" | "raw";
// Force JSON parsing regardless of Content-Type header
Http.get("/api/config", {
parseAs: "json",
validate: (data) => ConfigSchema.parse(data),
});
// Get raw Blob for file downloads
Http.get("/api/export/report.pdf", { parseAs: "blob" });
// Get ArrayBuffer for binary data
Http.get("/api/binary", { parseAs: "arrayBuffer" });
// Get the raw Response object
Http.get("/api/stream", { parseAs: "raw" });
Because Http returns IO effects, the full IO operator set is available:
import { Http } from "functype/fetch";
// Retry on failure
Http.get("/api/data").retry(3);
// Retry with delay between attempts (ms)
Http.get("/api/data").retryWithDelay(3, 1000);
// Timeout after N milliseconds
Http.get("/api/data").timeout(5000);
// Transform the response data (with validator for typed access)
Http.get("/api/users/1", { validate: (data) => UserSchema.parse(data) }).map(
(res) => res.data.name,
);
// Chain requests — use result of first to drive second
Http.get("/api/users/1", {
validate: (data) => UserSchema.parse(data),
}).flatMap((res) =>
Http.get(`/api/users/${res.data.id}/posts`, {
validate: (data) => z.array(PostSchema).parse(data),
}),
);
// Parallel requests
import { IO } from "functype/io";
const [users, posts] = await IO.all([
Http.get("/api/users", {
validate: (data) => z.array(UserSchema).parse(data),
}),
Http.get("/api/posts", {
validate: (data) => z.array(PostSchema).parse(data),
}),
]).run();
// Map over errors
Http.get("/api/data").mapError((err) => new AppError(err));
Request bodies are serialized based on their JavaScript type:
| Body value | Serialized as | Content-Type header added |
|---|---|---|
undefined / null |
No body sent | None |
string |
Passed through as-is | None (set manually if needed) |
| Object or Array | JSON.stringify() |
application/json |
| Other primitives | String(value) |
None |
// Object body → JSON serialized automatically
Http.post("/api/orders", {
body: { productId: 42, quantity: 3 },
validate: (data) => OrderSchema.parse(data),
// Content-Type: application/json is added automatically
});
// String body → sent as-is, no Content-Type added
Http.post("/api/graphql", {
body: '{"query":"{ users { id name } }"}',
headers: { "Content-Type": "application/json" },
validate: (data) => GraphQLResultSchema.parse(data),
});
// FormData or URLSearchParams — pass as string or handle manually
Http.post("/api/form", {
body: new URLSearchParams({ key: "value" }).toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
See full API documentation at functype API docs