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
}
Per-request headers always override defaultHeaders when keys conflict.
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