Skip to content

Effect HTTP

Effect HTTP (effect/unstable/httpapi) is the same idea as Effect RPC: define a Schema, construct handler Layers, return an HttpEffect from fetch, call it through an rpc-like typed interface. The difference is what goes on the wire — real HTTP endpoints, with URLs, path params, query strings, headers, bodies, and content types.

The use case is the same trust boundary — data that needs validating and sanitizing on the way in, exposed to a web app or an external service. Choose Effect HTTP over Effect RPC when your consumers aren’t Effect (or TypeScript) programs and want a plain HTTP client. The cost is also the same: every request pays for a schema decode and encode, so it’s equally discouraged for internal service-to-service calls — that’s what schemaless RPC is for.

Four files. Three are the API — domain model, endpoint declarations, and the host that serves them (a Cloudflare Worker or Lambda Function) — and the fourth is a client that derives everything from the second:

Terminal window
src/
├── Task.ts # domain model + typed 404 (Schema.Class, TaggedErrorClass)
├── ApiSchema.ts # endpoint declarations — one HttpApi value, imported by server AND clients
├── ApiService.ts # the host — a Worker or Lambda Function; wired on the hub pages — returns { fetch }
└── ApiClient.ts # typed client derived from ApiSchema — no codegen, no string URLs

The load-bearing file is src/ApiSchema.ts: the server implements it, every typed client is derived from it, and plain HTTP consumers get its validation for free.

src/Task.ts owns the value that crosses the wire and the error that crosses with it:

src/Task.ts
import * as Schema from "effect/Schema";
export class Task extends Schema.Class<Task>("Task")({
id: Schema.String,
title: Schema.String,
completed: Schema.Boolean,
}) {}
export class TaskNotFound extends Schema.TaggedErrorClass<TaskNotFound>()(
"TaskNotFound",
{ id: Schema.String },
{ httpApiStatus: 404 },
) {}

Schema.Class gives you a runtime-validated class with an inferred TypeScript type — one definition imported by both sides — and httpApiStatus: 404 maps the tagged error to a real 404 over plain HTTP instead of a generic 500.

src/ApiSchema.ts declares the contract — endpoints, a group, and the API value:

src/ApiSchema.ts
import * as Schema from "effect/Schema";
import * as HttpApi from "effect/unstable/httpapi/HttpApi";
import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
import { Task, TaskNotFound } from "./Task.ts";
export const getTask = HttpApiEndpoint.get("getTask", "/:id", {
params: Schema.Struct({ id: Schema.String }),
success: Task,
error: TaskNotFound,
});
export const createTask = HttpApiEndpoint.post("createTask", "/", {
payload: Schema.Struct({ title: Schema.String }),
success: Task,
});
export class TasksGroup extends HttpApiGroup.make("Tasks")
.add(getTask)
.add(createTask) {}
export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {}

Endpoints are pure declarations of (method, path, schemas): the /:id path parameter declared under params is what makes params.id a typed string in both the handler and the client, request bodies are decoded against the payload Schema before your handler runs (anything that doesn’t match gets an automatic 400 with a structured validation error), and TaskApi is a plain value — importable by the server and every client without dragging any runtime code across.

HttpApiBuilder.group wires handlers into the spec:

// src/ApiService.ts — inside the host's Init phase (a Cloudflare Worker or Lambda Function)
import * as Effect from "effect/Effect";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { TaskApi } from "./ApiSchema.ts";
import { Task, TaskNotFound } from "./Task.ts";
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
handlers
.handle("getTask", ({ params }) =>
// look up the task in storage — the hub guides wire R2/DynamoDB
Effect.fail(new TaskNotFound({ id: params.id })),
)
.handle("createTask", ({ payload }) =>
Effect.succeed(
new Task({
id: crypto.randomUUID(),
title: payload.title,
completed: false,
}),
),
),
);

HttpApiBuilder.group constructs a handler Layer — it runs nothing, which is what makes it safe at init/plan time — and each handler receives the typed request its endpoint declared: params.id and payload.title are strings, the handler must return a Task, and the only allowed failure is TaskNotFound — a wrong shape or an undeclared error is a compile error, not a runtime surprise.

Assemble the API Layer and convert it into the HttpEffect that fetch expects:

// src/ApiService.ts — end of the Init phase
import * as Layer from "effect/Layer";
import * as HttpRouter from "effect/unstable/http/HttpRouter";
// platform = the host's platform service Layers — differs per host
return {
fetch: yield* HttpRouter.toHttpEffect(
HttpApiBuilder.layer(TaskApi).pipe(
Layer.provide(tasksGroup),
Layer.provide(platform),
),
),
};

HttpRouter.toHttpEffect builds the Layer once at boot and yields the request handler — the same { fetch } shape a Worker or a Lambda Function with url: true serves. The platform Layers are the host-specific part of the wiring — see Workers and Lambda for the exact set, plus storage bindings and deploy (and CORS, on Workers).

Deploy it and the payoff over Effect RPC is immediate: the wire format is ordinary REST, so consumers need nothing but an HTTP client.

Terminal window
curl -X POST https://<your-api-url>/ \
-H "Content-Type: application/json" \
-d '{"title": "Write docs"}'
# → {"id":"...","title":"Write docs","completed":false}
curl https://<your-api-url>/<id>
# → {"id":"...","title":"Write docs","completed":false}

Invalid payloads get automatic 400 responses with structured validation errors, and a missing task comes back as a real 404 — the httpApiStatus declared on TaskNotFound — in any language, with any HTTP library, against the same validated contract.

For consumers that are Effect programs, the same TaskApi value drives a fully typed client — no codegen, no string URLs:

src/ApiClient.ts
import * as Effect from "effect/Effect";
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
import { TaskApi } from "./ApiSchema.ts";
const program = Effect.gen(function* () {
const client = yield* HttpApiClient.make(TaskApi, {
baseUrl: process.env.TASK_API_URL!,
});
const created = yield* client.Tasks.createTask({
payload: { title: "Write docs" },
});
const task = yield* client.Tasks.getTask({
params: { id: created.id },
});
});

Request keys mirror the declarations — payload for bodies, params for path parameters — and client.Tasks.getTask returns Effect<Task, TaskNotFound | HttpClientError>: the 404 arrives as a typed value you pattern-match on, not a status code you interpret. The client can also ride a Binding instead of a URL — see the Durable Object bridge on the Workers page.

  • Effect HTTP on Workers — full Worker wiring: R2 storage, the HttpPlatform stub, CORS, and the DO sub-API bridge.
  • Effect HTTP on Lambda — full Lambda wiring: DynamoDB bindings, IAM, and a typed end-to-end test.
  • Effect RPC — same trust boundary, leaner wire protocol, for consumers that are Effect programs.
  • Schemaless RPC — the default for internal calls: typed clients with no schema and no per-request validation.
  • REST API (API Gateway v1) — put API Gateway in front instead of a Function URL.