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 decode decoder 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 effectdecode: Decoder<T> to get HttpResponse<T>. Decoder.fromThrowing adapts any parser that throws, such as Zod’s parseNetworkError, HttpStatusError, DecodeErrorglobalThis.fetch (browsers, Node 18+, Bun, Deno)Content-Type: application/json. Pass encode: Encoder<TReq> to control the wire shape yourselfNothing runs until you call .run() or .runOrThrow().
import { Decoder } from "functype/decoder";
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 a decoder — T inferred from the parser's return type
const typedEffect = Http.get("/api/users/1", {
decode: Decoder.fromThrowing((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" },
decode: Decoder.fromThrowing((data) => UserSchema.parse(data)),
});
// PUT, PATCH, DELETE
Http.put("/api/users/1", {
body: { name: "Alice Updated" },
decode: Decoder.fromThrowing((data) => UserSchema.parse(data)),
});
Http.patch("/api/users/1", {
body: { name: "Alice" },
decode: Decoder.fromThrowing((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" },
decode: Decoder.fromThrowing((data) => UserSchema.parse(data)),
});
Without decode, response data is unknown. This is intentional — it prevents unsafe type casts and encourages runtime validation.
// Without decode — data is unknown, you must narrow it yourself
const effect = Http.get("/api/users");
// effect: IO<never, HttpError, HttpResponse<unknown>>
// With decode — T is inferred from the decoder's type
const typedEffect = Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => z.array(UserSchema).parse(data)),
});
// typedEffect: IO<never, HttpError, HttpResponse<User[]>>
// Zod
Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => z.array(UserSchema).parse(data)),
});
// TypeBox
Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => Value.Decode(UserSchema, data)),
});
// Valibot
Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => parse(UserSchema, data)),
});
// functype's own decoders, no library
Http.get("/api/users", { decode: Decoder.array(UserDecoder) });
// Manual validation
Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => {
if (!Array.isArray(data)) throw new Error("Expected array");
return data as User[];
}),
});
Object bodies are JSON-serialized. By default (flatten: true) functype containers are stripped to
plain JSON first: Option becomes the value or null, List an array, Map a record.
flatten: false sends each container’s tagged {_tag, value} form instead, for a functype service
that decodes it with Decoder.tagged.*.
encodeWhen neither fits, such as snake_case keys or a date-only string, pass an Encoder. It is the
request-side counterpart of Decoder: any (value: A) => JSONValue function. When encode is set,
its output goes on the wire as is and flatten is ignored.
import { type Encoder, Http } from "functype";
type NewUser = { name: string; createdAt: Date };
const encodeNewUser: Encoder<NewUser> = (u) => ({
user_name: u.name,
created_at: u.createdAt.toISOString().slice(0, 10),
});
Http.post("/api/users", { body: newUser, encode: encodeNewUser });
TReq is inferred from encode. If you pass the response type explicitly, pass the request type
too: Http.post<User, NewUser>(…). TypeScript defaults any type argument you leave out to
unknown, and an Encoder<NewUser> doesn’t fit Encoder<unknown>.
If encode throws, the request fails with HttpError.NetworkError and fetch is never called.
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 decode)
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", {
decode: Decoder.fromThrowing((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:
import type { HttpMethod } from "functype/fetch";
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", {
decode: Decoder.fromThrowing((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", {
decode: Decoder.fromThrowing((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", {
decode: Decoder.fromThrowing((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 { Decoder } from "functype/decoder";
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", {
decode: Decoder.fromThrowing((data) => z.array(UserSchema).parse(data)),
}); // → https://api.example.com/v1/users
const user = api.get("/users/1", {
decode: Decoder.fromThrowing((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,
});
import type { IO } from "functype";
import type { HttpError, HttpRequestView, HttpResponse } from "functype/fetch";
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", {
decode: Decoder.fromThrowing((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.decode 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.orNull(), // 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",
decode: Decoder.fromThrowing((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 { Decoder } from "functype/decoder";
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", {
decode: Decoder.fromThrowing((data) => UserSchema.parse(data)),
}).map((res) => res.data.name);
// Chain requests — use result of first to drive second
Http.get("/api/users/1", {
decode: Decoder.fromThrowing((data) => UserSchema.parse(data)),
}).flatMap((res) =>
Http.get(`/api/users/${res.data.id}/posts`, {
decode: Decoder.fromThrowing((data) => z.array(PostSchema).parse(data)),
}),
);
// Both requests, one after the other; each result keeps its own type
const [users, posts] = await Http.get("/api/users", {
decode: Decoder.fromThrowing((data) => z.array(UserSchema).parse(data)),
})
.zip(
Http.get("/api/posts", {
decode: Decoder.fromThrowing((data) => z.array(PostSchema).parse(data)),
}),
)
.runOrThrow(); // [HttpResponse<User[]>, HttpResponse<Post[]>]
// IO.all([...], { concurrency }) runs a list of same-typed requests, up to n at once
// 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 },
decode: Decoder.fromThrowing((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" },
decode: Decoder.fromThrowing((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