Effect HTTP API
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.
Define a schema
Section titled “Define a schema”Start with a domain model. Create 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,}) {}Schema.Class gives you a runtime-validated class with an inferred
TypeScript type. You’ll use Task as the success schema for your
GET endpoint.
Define the API
Section titled “Define the API”Create src/api.ts with two endpoints — one to create a task and
one to retrieve it:
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 } from "./task.ts";
const getTask = HttpApiEndpoint.get("getTask", "/:id", { success: Task,});
const createTask = HttpApiEndpoint.post("createTask", "/", { success: Task, payload: Schema.Struct({ title: Schema.String, }),});
export const TaskApi = HttpApi.make("TaskApi").add( HttpApiGroup.make("Tasks").add(getTask, createTask),);HttpApiEndpoint.get and .post declare the HTTP method, path,
success schema, and (optionally) payload / query / error schemas.
HttpApiGroup groups related endpoints, and HttpApi.make combines
groups into a full API definition.
Nothing executes yet — this is purely a description.
Implement the handlers
Section titled “Implement the handlers”Now wire up the handlers. For simplicity, we’ll use an in-memory
Map:
import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Schema from "effect/Schema";import * as HttpApi from "effect/unstable/httpapi/HttpApi";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Task } from "./task.ts";
const getTask = HttpApiEndpoint.get("getTask", "/:id", { success: Task,});
const createTask = HttpApiEndpoint.post("createTask", "/", { success: Task, payload: Schema.Struct({ title: Schema.String, }),});
export const TaskApi = HttpApi.make("TaskApi").add( HttpApiGroup.make("Tasks").add(getTask, createTask),);
const tasks = new Map<string, Task>();
export const TaskApiLive = HttpApiBuilder.layer(TaskApi).pipe( Layer.provide( HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => Effect.gen(function* () { return handlers .handle( "getTask", Effect.fn(function* (req) { const task = tasks.get(req.path.id); if (!task) { return HttpServerResponse.text("Not found", { status: 404 }); } return task; }), ) .handle( "createTask", Effect.fn(function* (req) { const task = new Task({ id: crypto.randomUUID(), title: req.payload.title, completed: false, }); tasks.set(task.id, task); return task; }), ); }), ), ),);HttpApiBuilder.group gives you a handlers object with a
.handle(endpointName, effectFn) method for each endpoint in the
group. The request type is inferred from the endpoint schema —
req.path.id and req.payload.title are fully typed.
Wire into a Worker
Section titled “Wire into a Worker”The key step: convert the API layer into an HttpEffect and assign
it to the Worker’s fetch. Create src/worker.ts:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";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 { TaskApiLive } from "./api.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.path }, HttpRouter.toHttpEffect(TaskApiLive).pipe( Effect.map((fetch) => ({ fetch })), Effect.provide(Layer.mergeAll(HttpPlatform.layer, Etag.layer)), ),);HttpRouter.toHttpEffect takes the API layer and returns an
HttpEffect — an Effect that reads HttpServerRequest, can fail
with HttpServerError | HttpBodyError, and succeeds with
HttpServerResponse. This is the exact type a Worker’s fetch
expects, so Effect.map((fetch) => ({ fetch })) is all it takes to
bridge them.
Add to the Stack
Section titled “Add to the Stack”Create alchemy.run.ts:
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(), }, Effect.gen(function* () { const worker = yield* Worker;
return { url: worker.url, }; }),);Deploy and test
Section titled “Deploy and test”alchemy deployUse curl to create and retrieve a task:
# Create a taskcurl -X POST https://your-worker.workers.dev/ \ -H "Content-Type: application/json" \ -d '{"title": "Write docs"}'# → {"id":"...","title":"Write docs","completed":false}
# Retrieve itcurl https://your-worker.workers.dev/<id># → {"id":"...","title":"Write docs","completed":false}Invalid payloads get automatic 400 responses with validation errors — no manual checking needed.
HttpApiEndpointdeclares endpoints with schemas for payloads, responses, and errorsHttpApiGroupandHttpApi.makecompose endpoints into an API definitionHttpApiBuilder.groupimplements handlers with full type inferenceHttpRouter.toHttpEffectconverts the API layer into anHttpEffect— the same type a Worker’sfetchexpects- The API validates requests and serializes responses automatically