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 } where fetch is an HttpEffect.
That’s the value Workers invoke on every request.
Bonus: deploy, grab the URL, and call the API from a fully
typed client.
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.
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*asCloudflarefrom"alchemy/Cloudflare";
import*asEffectfrom"effect/Effect";
exportdefaultCloudflare.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*asCloudflarefrom"alchemy/Cloudflare";
exportconstTasks=Cloudflare.R2Bucket("Tasks");
import {
import Tasks
Tasks } from"./bucket.ts";
exportdefault
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
consttasks: 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.
HttpApiBuilder.groupconstructs 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
consttasks: any
tasks=yield*
any
Cloudflare.
any
R2Bucket.
any
bind(
any
Tasks);
const
consttasksGroup: 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
constobject: any
object=yield*
consttasks: any
tasks.
any
get(
path: any
path.
any
id);
if (!
constobject: any
object) {
returnyield*
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.
Converts a JavaScript Object Notation (JSON) string into an object.
@param ― text A valid JSON string.
@param ― reviver 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.
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
@param ― value A JavaScript value, usually an object or array, to be converted.
@param ― replacer A function that transforms the results.
@param ― space 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(
consttask: any
task));
return
consttask: 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.
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.
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.