Effect HTTP API
Effect HTTP is the trust-boundary modality: schema-validated REST endpoints — real URLs, path params, query strings, payloads — that any HTTP client can call, no Effect or TypeScript required on the consumer’s side. This page deploys one to a Cloudflare Worker; the concept home is Effect HTTP, and RPC covers choosing between the modalities.
In the tutorial, you built a Worker with manual
if/else routing and raw request parsing. That works, but as your
API grows you lose type safety at the boundary — request payloads
aren’t validated, response shapes aren’t enforced, and errors slip
through untyped.
Effect’s HttpApi module solves this. You declare endpoints with
schemas for payloads, responses, and errors, then implement handlers
against those schemas. The result is an HttpEffect — the same type
a Worker’s fetch expects — so it plugs in directly.
The mental model we’ll follow is:
- Define the schema and API outside the Worker. Both are pure descriptions and can be imported by clients.
- Construct the service inside the Worker’s Init phase.
The Init phase runs at plan time and runtime, so we only do
pure construction here — we never
yield*something that needs a request to exist. - Return
{ fetch }wherefetchis anHttpEffectproduced byHttpRouter.toHttpEffect. That’s the value Workers invoke on every request. - Bonus: deploy, grab the URL, and call the API from a fully typed client.
1. Define the schema
Section titled “1. Define the schema”Start with a domain model. The schema lives outside the Worker so the same file can be imported by clients without pulling in any runtime code.
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. Schema.TaggedErrorClass gives you a typed error you
can return from handlers and discriminate against on the client —
httpApiStatus: 404 maps it to a 404 response instead of a generic 500.
2. Declare the API
Section titled “2. Declare the API”Endpoints are declarations — they describe (method, path, params, payload, success, error) without implementing anything yet. Putting
them in their own file keeps the spec importable from both the
server and a typed client.
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 decodeTask = Schema.decodeUnknownEffect(Task);export const encodeTask = Schema.encodeUnknownSync(Task);
const TaskParams = Schema.Struct({ id: Schema.String,});
const getTask = HttpApiEndpoint.get("getTask", "/:id", { params: TaskParams, success: Task, error: TaskNotFound,});
const createTask = HttpApiEndpoint.post("createTask", "/", { success: Task, payload: Schema.Struct({ title: Schema.String, }),});
export class TasksGroup extends HttpApiGroup.make("Tasks") .add(getTask) .add(createTask) {}
export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {}Nothing executes yet — TaskApi is purely a value-level description.
The same TaskApi constant is what we’ll hand to the client at the
end of this guide.
3. Build the Worker
Section titled “3. Build the Worker”Now we wire it up. Create src/worker.ts with an empty Init phase:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { return {}; }),);The generator inside Cloudflare.Worker is the Init phase. It
runs both at plan time (when Alchemy builds the deployment graph)
and at runtime (when the Worker boots a fresh isolate). Anything
you yield* here must be safe in both contexts — typically resource
binding factories like R2.ReadWriteBucket(...), never per-request work.
3a. Bind an R2 bucket for storage
Section titled “3a. Bind an R2 bucket for storage”Tasks need to live somewhere durable. Declare an Bucket resource
and bind it inside Init — bind() returns a typed handle whose
get / put / delete / list methods we’ll call from the
handlers below.
import * as Cloudflare from "alchemy/Cloudflare";
export const Tasks = Cloudflare.R2.Bucket("Tasks");import { Tasks } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
return {}; }),);We’ll provide the runtime side of this binding
(Cloudflare.R2.ReadWriteBucketBinding) in step 3c when we wire up the
fetch handler.
3b. Construct the handler group inside Init
Section titled “3b. Construct the handler group inside Init”HttpApiBuilder.group constructs a Layer that wires handlers
into the API spec. It’s pure — it doesn’t run them. That makes it
safe to call inside Init.
Don’t
yield*HttpApiBuilder.layer(TaskApi)here — building the layer is fine, but actually executing the server requires an incoming request. Init only does construction; the work happens later, on eachfetchcall.
Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => handlers .handle("getTask", ({ params }) => Effect.gen(function* () { const object = yield* tasks.get(params.id); if (!object) { return yield* Effect.fail(new TaskNotFound({ id: params.id })); } return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text())); }).pipe(Effect.orDie), ) .handle("createTask", ({ payload }) => Effect.gen(function* () { const task = new Task({ id: crypto.randomUUID(), title: payload.title, completed: false, }); yield* tasks.put(task.id, JSON.stringify(task)); return task; }).pipe(Effect.orDie), ), );
return {};}),Each handler receives a typed request. params.id is a string
because that’s what the endpoint declared, and the return type must
satisfy Task (or fail with TaskNotFound). Mismatches are caught
at compile time.
tasks.get and tasks.put can fail with R2Error, which isn’t in
either endpoint’s declared error set. Effect.orDie converts those
into defects so the HttpApi runtime turns them into 500 responses —
keeping the typed error channel reserved for TaskNotFound.
3c. Return { fetch }
Section titled “3c. Return { fetch }”The return value of Init is the Worker’s surface — for a
fetch-style Worker that means an object with a fetch field. The
value of fetch must be an HttpEffect: an Effect that, given an
HttpServerRequest, produces an HttpServerResponse.
We assemble it in three layers, then convert:
HttpApiBuilder.layer(TaskApi)— the top-level API layer.Layer.provide(tasksGroup)— plug in the handlers we just built.Layer.provide([Etag.layer, HttpPlatformStub, Path.layer])— platform services the builder needs (content negotiation, ETag generation, path handling).yield* HttpRouter.toHttpEffect(...)— build the assembled Layer and yield theHttpEffect.
HttpPlatformStub stands in for HttpPlatform.layer, which requires
a FileSystem and therefore can’t run on Workers — there is no
filesystem in the isolate. Define it once at module scope:
const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), fileWebResponse: () => Effect.die("HttpPlatform.fileWebResponse not supported"),});return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(TaskApi).pipe( Layer.provide(tasksGroup), Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), ), ),};HttpRouter.toHttpEffect returns an Effect that builds the Layer
and hands back the request handler — yielding it once in Init means
route construction happens at boot, not per request.
3d. Enable CORS
Section titled “3d. Enable CORS”HttpRouter.cors(...) returns a Layer that registers a global
router middleware. Plug it in alongside the platform services with
another Layer.provide.
return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(TaskApi).pipe( Layer.provide(tasksGroup), Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), Layer.provide(HttpRouter.cors({ allowedOrigins: ["*"], allowedMethods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["Content-Type"], })), ), ),};The middleware wraps every response — including the automatic
OPTIONS preflight — with the configured Access-Control-* headers.
Because it’s a Layer<never, never, HttpRouter>, it composes the same
way as the rest of the API stack: order doesn’t matter, and there’s
no per-handler plumbing.
Putting it together
Section titled “Putting it together”Here’s the complete src/worker.ts:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Etag from "effect/unstable/http/Etag";import * as HttpPlatform from "effect/unstable/http/HttpPlatform";import * as HttpRouter from "effect/unstable/http/HttpRouter";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import { TaskApi } from "./api.ts";import { Tasks } from "./bucket.ts";import { Task, TaskNotFound } from "./task.ts";
const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), fileWebResponse: () => Effect.die("HttpPlatform.fileWebResponse not supported"),});
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => handlers .handle("getTask", ({ params }) => Effect.gen(function* () { const object = yield* tasks.get(params.id); if (!object) { return yield* Effect.fail(new TaskNotFound({ id: params.id })); } return Schema.decodeUnknownSync(Task)( JSON.parse(yield* object.text()), ); }).pipe(Effect.orDie), ) .handle("createTask", ({ payload }) => Effect.gen(function* () { const task = new Task({ id: crypto.randomUUID(), title: payload.title, completed: false, }); yield* tasks.put(task.id, JSON.stringify(task)); return task; }).pipe(Effect.orDie), ), );
return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(TaskApi).pipe( Layer.provide(tasksGroup), Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), Layer.provide( HttpRouter.cors({ allowedOrigins: ["*"], allowedMethods: ["GET", "POST", "OPTIONS"], allowedHeaders: ["Content-Type"], }), ), ), ), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);4. Add to the Stack
Section titled “4. Add to the Stack”import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import Worker from "./src/worker.ts";
export default Alchemy.Stack( "TaskApi", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const worker = yield* Worker; return { url: worker.url }; }),);5. Deploy
Section titled “5. Deploy”alchemy deployYou can hit it directly with curl:
curl -X POST https://your-worker.workers.dev/ \ -H "Content-Type: application/json" \ -d '{"title": "Write docs"}'# → {"id":"...","title":"Write docs","completed":false}
curl https://your-worker.workers.dev/<id># → {"id":"...","title":"Write docs","completed":false}Invalid payloads get automatic 400 responses with structured validation errors — no manual checking needed.
Bonus: call it from a typed client
Section titled “Bonus: call it from a typed client”Because TaskApi is just a value, the same spec drives a fully
typed client. There’s no codegen step — HttpApiClient.make
produces methods whose argument and return types come straight from
the endpoint schemas.
import * as Effect from "effect/Effect";import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";import { TaskApi } from "../src/api.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" }, }); console.log("Created:", created.id);
const fetched = yield* client.Tasks.getTask({ params: { id: created.id }, }); console.log("Fetched:", fetched.title);});
Effect.runPromise(program);Get the URL from the deploy output and run it:
TASK_API_URL=https://your-worker.workers.dev bun scripts/client.tsclient.Tasks.getTask returns Effect<Task, TaskNotFound | HttpClientError>.
The TaskNotFound branch is a real typed value you can pattern-match
on, not an HTTP status code you have to interpret.
Bonus: route some endpoints to a Durable Object
Section titled “Bonus: route some endpoints to a Durable Object”The same HttpApi machinery can run inside a Durable Object. The
DO becomes a typed sub-API that the Worker delegates to, while the
public TaskApi shape remains a single value clients can import.
Define a DO-side group
Section titled “Define a DO-side group”The DO exposes its own group built from the same endpoint
declarations. Add two more endpoints to the public spec — one for
get and one for create — that the Worker will proxy to the DO,
then make a private group for the DO with just the two original
endpoints:
const getTaskDO = HttpApiEndpoint.get("getTaskDO", "/do/:id", { params: TaskParams, success: Task, error: TaskNotFound,});
const createTaskDO = HttpApiEndpoint.post("createTaskDO", "/do", { success: Task, payload: Schema.Struct({ title: Schema.String }),});
export class TasksGroup extends HttpApiGroup.make("Tasks") .add(getTask) .add(createTask) .add(getTaskDO) .add(createTaskDO) {}
export class TasksDOGroup extends HttpApiGroup.make("TasksDO") .add(getTask) .add(createTask) {}
export class TaskDOApi extends HttpApi.make("TaskDOApi").add(TasksDOGroup) {}Implement the DO
Section titled “Implement the DO”The DO’s Init returns { fetch } — an HttpEffect produced exactly
the same way the Worker produces one, just scoped to its own
TaskDOApi. Storage is the DO’s transactional state.storage
instead of R2.
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Path from "effect/Path";import * as Etag from "effect/unstable/http/Etag";import * as HttpPlatform from "effect/unstable/http/HttpPlatform";import * as HttpRouter from "effect/unstable/http/HttpRouter";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import { TaskDOApi, decodeTask, encodeTask } from "./api.ts";import { Task } from "./task.ts";
const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), fileWebResponse: () => Effect.die("HttpPlatform.fileWebResponse not supported"),});
export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()( "TasksObject", Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const tasksGroup = HttpApiBuilder.group(TaskDOApi, "TasksDO", (handlers) => handlers .handle("getTask", ({ params }) => state.storage.get<Task>(params.id).pipe( Effect.flatMap(decodeTask), Effect.orDie, ), ) .handle("createTask", ({ payload }) => { const task = new Task({ id: crypto.randomUUID(), title: payload.title, completed: false, }); return state.storage.put(task.id, encodeTask(task)).pipe(Effect.as(task)); }), );
return { fetch: yield* HttpRouter.toHttpEffect( HttpApiBuilder.layer(TaskDOApi).pipe( Layer.provide(tasksGroup), Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), ), ), }; }); }),) {}Bridge the DO into a typed client
Section titled “Bridge the DO into a typed client”Inside the Worker’s Init, Cloudflare.toHttpClient(stub) wraps a DO
stub as an HttpClient. Hand that client to HttpApiClient.makeWith
and you get a fully typed client whose every call is a DO fetch
under the hood:
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";import TasksObject, { TaskDOApi } from "./object.ts";
Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); const tasksDO = yield* TasksObject;
const getTaskDOClient = (id: string = "default") => HttpApiClient.makeWith(TaskDOApi, { baseUrl: "http://localhost", httpClient: Cloudflare.toHttpClient(tasksDO.getByName(id)), });The baseUrl is irrelevant — requests never leave the isolate; the
httpClient short-circuits straight to tasksDO.getByName(id).fetch.
Wire the proxy handlers
Section titled “Wire the proxy handlers”The Worker’s two new handlers just construct a per-request DO client and forward the typed call. Errors from the DO already match the public endpoint’s error schema, so there’s no remapping:
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => handlers .handle("getTask", /* ... R2 implementation ... */) .handle("createTask", /* ... R2 implementation ... */) .handle("getTaskDO", ({ params }) => getTaskDOClient().pipe( Effect.flatMap((client) => client.TasksDO.getTask({ params })), ), ) .handle("createTaskDO", ({ payload }) => getTaskDOClient().pipe( Effect.flatMap((client) => client.TasksDO.createTask({ payload })), ), ),);GET /do/:id and POST /do now hit the DO; GET /:id and POST /
still hit R2. Same TaskApi, same client, two different storage
backends behind the scenes.
- The schema (
Task,TaskNotFound) and the API spec (TaskApi) live outside the Worker — they’re pure descriptions. - The handlers are constructed inside the Worker’s Init phase
closure. We build a
LayerwithHttpApiBuilder.groupbut neveryield*the running server — that only makes sense per-request. - The Worker’s surface is
{ fetch }, wherefetchis anHttpEffectyielded once fromHttpRouter.toHttpEffect. - The same
TaskApivalue drives a fully typed client viaHttpApiClient.make— no codegen, no string URLs.
Where next
Section titled “Where next”- Effect HTTP — the concept home for this modality.
- RPC — how to choose between Schemaless RPC, Effect RPC, and Effect HTTP.
- Effect RPC on Workers — the schema-first RPC shape for Effect/TypeScript consumers.
- Schemaless RPC — the default for internal Worker-to-Worker calls.