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
}

Per-request headers always override defaultHeaders when keys conflict.

Content-Type Detection

Response bodies are parsed based on the Content-Type response header:

Content-TypeParse ModeResult type
application/jsonjsonParsed JS object
text/* (any text type)textstring
Anything elserawResponse 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 valueSerialized asContent-Type header added
undefined / nullNo body sentNone
stringPassed through as-isNone (set manually if needed)
Object or ArrayJSON.stringify()application/json
Other primitivesString(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