Http

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

HTTP fetch wrapper returning IO<never, HttpError, HttpResponse<unknown>> effects by default. Provide a validate function to get typed responses.

Overview

Http wraps the global fetch API with full IO integration:

  • Returns IO<never, HttpError, HttpResponse<unknown>> by default — every request is a lazy, composable effect
  • BYOV (Bring Your Own Validator): Pass a validate: (data: unknown) => T function to get HttpResponse<T>
  • Works with any validation library: Zod, TypeBox, Valibot, or manual validators
  • Three typed error variants: NetworkError, HttpStatusError, DecodeError
  • Zero dependencies — uses globalThis.fetch (browsers, Node 18+, Bun, Deno)
  • Auto content-type detection from response headers (JSON, text, raw)
  • Body serialization — objects are automatically JSON-serialized with Content-Type: application/json
  • Configurable clients with base URLs and default headers

Nothing runs until you call .run() or .runOrThrow().

Basic Usage

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),
});

Validation

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[]>>

Using Different Validators

// 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[];
  },
});

HttpResponse<T>

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"));
}

Error Handling

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;
};

Pattern Matching with HttpError.match

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();

Catching Specific Error Tags

// 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"),
);

Type Guards

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
  }
}

Http.client()

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,
});

HttpClientConfig

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.

Request-side composition with beforeRequest

The 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:

  • Failure short-circuits. Returning IO.fail(httpError) from beforeRequest aborts the call — fetch is never invoked and the error surfaces through the normal .catchTag / .run* paths.
  • Headers passed in are already merged. r.headers reflects defaultHeaders + per-call headersbeforeRequest is the final word.
  • Body is raw at hook time. 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.
  • Compose vs. replace. This is additive to 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).

Response-side composition with afterResponse

Symmetric 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.

Production retry policy

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 pattern

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();

Query parameters

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).

Content-Type Detection

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" });

IO Composition

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));

Body Serialization

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" },
});

API Reference

See full API documentation at functype API docs