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.
The pieces
Section titled “The pieces”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:
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 URLsThe 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.
Schema
Section titled “Schema”src/Task.ts owns the value that crosses the wire and the error
that crosses with it:
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.
Endpoints
Section titled “Endpoints”src/ApiSchema.ts declares the contract — endpoints, a group, and
the API value:
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.
Handlers
Section titled “Handlers”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.
Serve it
Section titled “Serve it”Assemble the API Layer and convert it into the HttpEffect that
fetch expects:
// src/ApiService.ts — end of the Init phaseimport * as Layer from "effect/Layer";import * as HttpRouter from "effect/unstable/http/HttpRouter";
// platform = the host's platform service Layers — differs per hostreturn { 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).
Plain HTTP for everyone else
Section titled “Plain HTTP for everyone else”Deploy it and the payoff over Effect RPC is immediate: the wire format is ordinary REST, so consumers need nothing but an HTTP client.
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.
A typed client
Section titled “A typed client”For consumers that are Effect programs, the same TaskApi value
drives a fully typed client — no codegen, no string URLs:
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.
Where next
Section titled “Where next”- Effect HTTP on Workers — full
Worker wiring: R2 storage, the
HttpPlatformstub, 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.