Skip to content

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.

The mental model we’ll follow is:

  1. Define the schema and API outside the Worker. Both are pure descriptions and can be imported by clients.
  2. 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.
  3. Return { fetch } where fetch is an HttpEffect. That’s the value Workers invoke on every request.
  4. Bonus: deploy, grab the URL, and call the API from a fully typed client.

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.

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.TaggedClass<TaskNotFound>()(
"TaskNotFound",
{ id: Schema.String },
) {}

Schema.Class gives you a runtime-validated class with an inferred TypeScript type. Schema.TaggedClass gives you a typed error you can return from handlers and discriminate against on the client.

Endpoints are declarations — they describe (method, path, 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.

src/api.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";
const getTask = HttpApiEndpoint.get("getTask", "/:id", {
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.

Now we wire it up. Create src/worker.ts with an empty Init phase:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.path },
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 R2Bucket.bind(...), never per-request work.

Tasks need to live somewhere durable. Declare an R2Bucket resource and bind it inside Init — bind() returns a typed handle whose get / put / delete / list methods we’ll call from the handlers below.

src/bucket.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Tasks = Cloudflare.R2Bucket("Tasks");
import {
import Tasks
Tasks
} from "./bucket.ts";
export default
any
Cloudflare
.
any
Worker
(
"Worker",
{
main: string
main
: import.

The type of import.meta.

If you need to declare that a given property exists on import.meta, this type may be augmented via interface merging.

meta
.
ImportMeta.path: string

Absolute path to the source file

path
},
any
Effect
.
any
gen
(function* () {
const
const tasks: any
tasks
= yield*
any
Cloudflare
.
any
R2Bucket
.
any
bind
(
import Tasks
Tasks
);
return {};
}),
);

We’ll provide the runtime side of this binding (Cloudflare.R2BucketBindingLive) 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 each fetch call.

any
Effect
.
any
gen
(function* () {
const
const tasks: any
tasks
= yield*
any
Cloudflare
.
any
R2Bucket
.
any
bind
(
any
Tasks
);
const
const tasksGroup: any
tasksGroup
=
any
HttpApiBuilder
.
any
group
(
any
TaskApi
, "Tasks", (
handlers: any
handlers
) =>
handlers: any
handlers
.
any
handle
("getTask", ({
path: any
path
}) =>
any
Effect
.
any
gen
(function* () {
const
const object: any
object
= yield*
const tasks: any
tasks
.
any
get
(
path: any
path
.
any
id
);
if (!
const object: any
object
) {
return yield*
any
Effect
.
any
fail
(new
any
TaskNotFound
({
id: any
id
:
path: any
path
.
any
id
}));
}
return
any
Schema
.
any
decodeUnknownSync
(
any
Task
)(
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

Converts a JavaScript Object Notation (JSON) string into an object.

@paramtext A valid JSON string.

@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.

@throws{SyntaxError} If text is not valid JSON.

parse
(yield*
const object: any
object
.
any
text
()));
}).
any
pipe
(
any
Effect
.
any
orDie
),
)
.
any
handle
("createTask", ({
payload: any
payload
}) =>
any
Effect
.
any
gen
(function* () {
const
const task: any
task
= new
any
Task
({
id: `${string}-${string}-${string}-${string}-${string}`
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}` (+1 overload)
randomUUID
(),
title: any
title
:
payload: any
payload
.
any
title
,
completed: boolean
completed
: false,
});
yield*
const tasks: any
tasks
.
any
put
(
const task: any
task
.
any
id
,
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.

@throws{TypeError} If a circular reference or a BigInt value is found.

stringify
(
const task: any
task
));
return
const task: any
task
;
}).
any
pipe
(
any
Effect
.
any
orDie
),
),
);
return {};
}),

Each handler receives a typed request. path.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.

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:

  • HttpApiBuilder.layer(TaskApi) — the top-level API layer.
  • Layer.provide(tasksGroup) — plug in the handlers we just built.
  • Layer.provide([HttpPlatform.layer, Etag.layer]) — platform services the builder needs (content negotiation, ETag generation).
  • HttpRouter.toHttpEffect — convert the assembled layer into an HttpEffect.
return {
fetch: any
fetch
:
any
HttpApiBuilder
.
any
layer
(
any
TaskApi
).
any
pipe
(
any
Layer
.
any
provide
(
any
tasksGroup
),
any
Layer
.
any
provide
([
any
HttpPlatform
.
any
layer
,
any
Etag
.
any
layer
]),
any
HttpRouter
.
any
toHttpEffect
,
),
};

Here’s the complete src/worker.ts:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
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";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.path },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2Bucket.bind(Tasks);
const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) =>
handlers
.handle("getTask", ({ path }) =>
Effect.gen(function* () {
const object = yield* tasks.get(path.id);
if (!object) {
return yield* Effect.fail(new TaskNotFound({ id: path.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: HttpApiBuilder.layer(TaskApi).pipe(
Layer.provide(tasksGroup),
Layer.provide([HttpPlatform.layer, Etag.layer]),
HttpRouter.toHttpEffect,
),
};
}).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),
);
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 };
}),
);
Terminal window
alchemy deploy

You can hit it directly with curl:

Terminal window
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.

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.

scripts/client.ts
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({
path: { id: created.id },
});
console.log("Fetched:", fetched.title);
});
Effect.runPromise(program);

Get the URL from the deploy output and run it:

Terminal window
TASK_API_URL=https://your-worker.workers.dev bun scripts/client.ts

client.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.

  • 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 Layer with HttpApiBuilder.group but never yield* the running server — that only makes sense per-request.
  • The Worker’s surface is { fetch }, where fetch is an HttpEffect produced by HttpRouter.toHttpEffect.
  • The same TaskApi value drives a fully typed client via HttpApiClient.make — no codegen, no string URLs.