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.
HttpApiError.InternalServerError is Effect’s built-in 500 error.
Declaring it on every endpoint reserves a typed slot for “something
went wrong on the server” — much better than the alternative
(Effect.orDie everywhere, which drops the cause into a generic
500 with no logging). We’ll wire handlers to it in step 3b.
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.
First, define a single helper that converts any unexpected failure
into the typed InternalServerError we just declared, logging the
underlying cause on the way out:
any
Effect.
any
gen(function* () {
const
consttasks: any
tasks=yield*
any
Cloudflare.
any
R2Bucket.
any
bind(
any
Tasks);
const
consttoInternalError: (cause: unknown) => any
toInternalError= (
cause: unknown
cause: unknown) =>
any
Effect.
any
logError("Worker handler failed",
cause: unknown
cause).
any
pipe(
any
Effect.
any
andThen(
any
Effect.
any
fail(new
any
HttpApiError.
any
InternalServerError())),
);
Now build the handlers. Each one ends in Effect.catch(toInternalError)
so any unexpected error is logged and turned into a 500 — while typed
errors like TaskNotFound stay in the error channel and reach the
client unchanged:
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*
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
catch(
any
toInternalError)),
),
);
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 | InternalServerError).
Mismatches are caught at compile time.
:::tip Prefer Effect.catch(toInternalError) over Effect.orDieEffect.orDie converts failures into defects, which the HttpApi
runtime turns into a 500 — but the cause vanishes into the void
unless you’ve wired up a separate logger. Effect.catch keeps the
failure on the error channel: you log it once on the way out and
fail with a typed InternalServerError that the client can
discriminate against just like 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 | InternalServerError | HttpClientError>.
Each branch is a real typed value you can pattern-match on with
Effect.catchTag — not an HTTP status code you have to interpret.
InternalServerError is exactly what you’d otherwise have to
guess from a bare 500.
The schema (Task, TaskNotFound) and the API spec
(TaskApi) live outside the Worker — they’re pure descriptions.
Every endpoint declares
error: HttpApiError.InternalServerError (or a union with it),
so unexpected failures have a typed home — never Effect.orDie.
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.
Each handler ends in Effect.catch(toInternalError) so unexpected
errors are logged and wrapped in InternalServerError.
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. Errors come
back as discriminated typed values, including
InternalServerError for the server-side 500 case.