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 decode decoder 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 decode: Decoder<T> to get HttpResponse<T>. Decoder.fromThrowing adapts any parser that throws, such as Zod’s parse
  • 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. Pass encode: Encoder<TReq> to control the wire shape yourself
  • Configurable clients with base URLs and default headers

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

Basic Usage

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

Validation

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

Using Different Validators

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

Request bodies

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

Custom wire shape with encode

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

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

Error Handling

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

Pattern Matching with HttpError.match

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

Catching Specific Error Tags

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

Type Guards

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

Http.client()

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

HttpClientConfig

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.

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", {
    decode: Decoder.fromThrowing((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 headers — beforeRequest 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.
  • decode 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.orNull(), // 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",
  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" });

IO Composition

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

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

API Reference

See full API documentation at functype API docs