Effect RPC
Effect RPC exposes a typed, schema-validated surface across a trust boundary — a web app or an external service calling into your Worker. For internal Worker-to-Worker or Worker-to-DO calls, use Schemaless RPC instead; for choosing between the modalities, see RPC.
The HTTP API guide showed how to build REST-style endpoints with schema validation. Effect RPC takes a different angle — you define procedures instead of HTTP endpoints, and you get a fully typed client for free with no URL construction or manual serialization.
The transport is still HTTP under the hood, and both patterns
produce the same HttpEffect type, so the wiring story is
identical to the HTTP API guide:
- Define schemas outside. Domain types and tagged errors, importable by both server and client.
- Construct the service inside the Worker’s Init phase.
RpcGroup.toLayeris pure construction — safe to call at plan time. Don’tyield*the running server; it can’t run without a request. - Return
{ fetch }wherefetchis theHttpEffectproduced byRpcServer.toHttpEffect. - Bonus: deploy and call the procedures from a typed client
that shares the exact same
RpcGroupvalue.
1. Define the schemas
Section titled “1. Define the schemas”Domain model and error types — pure schemas, no runtime concerns:
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 },) {}
export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()( "CreateTaskFailed", { message: Schema.String },) {}RPC errors are schema-backed tagged classes. The client receives
them as typed values you can match on — not raw HTTP status codes.
2. Declare the RPCs
Section titled “2. Declare the RPCs”Each Rpc.make declares one procedure: a name, a payload schema,
a success schema, and an error schema. RpcGroup.make collects them
into a single value that both the server and the client will share.
import * as Schema from "effect/Schema";import { Rpc, RpcGroup } from "effect/unstable/rpc";import { Task, TaskNotFound, CreateTaskFailed } from "./task.ts";
const getTask = Rpc.make("getTask", { success: Task, error: TaskNotFound, payload: { id: Schema.String },});
const createTask = Rpc.make("createTask", { success: Task, error: CreateTaskFailed, payload: { title: Schema.String },});
export class TaskRpcs extends RpcGroup.make(getTask, createTask) {}TaskRpcs is just a value-level description. Nothing executes yet.
3. Build the Worker
Section titled “3. Build the Worker”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 and at runtime. Only do pure construction
or resource-binding factories here; never yield* work that needs
an incoming request.
3a. Bind an R2 bucket for storage
Section titled “3a. Bind an R2 bucket for storage”Tasks need durable storage. 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 handlers inside Init
Section titled “3b. Construct the handlers inside Init”TaskRpcs.toLayer takes an Effect that returns one handler per
procedure and produces a Layer. Like HttpApiBuilder.group, this
is pure construction — it builds a value, it doesn’t run the server.
Don’t
yield* TaskRpcs.toLayer(...)here. Building a layer is fine, but actually executing the procedures requires an incoming request. Init only constructs; the work happens later, on eachfetchcall.
Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const handlersLayer = TaskRpcs.toLayer({ getTask: ({ id }) => Effect.gen(function* () { const object = yield* tasks.get(id); if (!object) { return yield* Effect.fail(new TaskNotFound({ id })); } return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text())); }).pipe(Effect.orDie), createTask: ({ title }) => Effect.gen(function* () { const task = new Task({ id: crypto.randomUUID(), title, completed: false, }); yield* tasks.put(task.id, JSON.stringify(task)); return task; }).pipe( Effect.catchTag("R2Error", (error) => Effect.fail(new CreateTaskFailed({ message: error.message })), ), ), });
return {};}),Each handler receives the typed payload and returns an Effect
that either succeeds with the declared success schema or fails with
the declared error schema. getTask uses Effect.orDie to turn
unexpected R2 failures into 500s — TaskNotFound is the only
client-visible error. createTask maps R2 failures into the
declared CreateTaskFailed error so the client can match on it.
3c. Return { fetch }
Section titled “3c. Return { fetch }”RpcServer.toHttpEffect converts the RpcGroup into an
HttpEffect — exactly the type Workers expect for fetch. We
provide two layers to it via Layer.mergeAll:
- The
handlersLayerwe just built. RpcSerialization.layerNdjson— one frame per line, so streaming procedures work out of the box and the wire format matches whatCloudflare.RpcWorker.bindclients speak. When nothing streams and no bound consumer is involved, the bufferedRpcSerialization.layerJsonis also an option.
Unlike the HTTP API approach, RPC doesn’t need HttpPlatform.layer
or Etag.layer — the RPC server handles message framing internally.
return { fetch: RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide( Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson), ), ),};Putting it together
Section titled “Putting it together”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 { RpcSerialization, RpcServer } from "effect/unstable/rpc";import { Tasks } from "./bucket.ts";import { CreateTaskFailed, Task, TaskNotFound } from "./task.ts";import { TaskRpcs } from "./rpcs.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks);
const handlersLayer = TaskRpcs.toLayer({ getTask: ({ id }) => Effect.gen(function* () { const object = yield* tasks.get(id); if (!object) { return yield* Effect.fail(new TaskNotFound({ id })); } return Schema.decodeUnknownSync(Task)( JSON.parse(yield* object.text()), ); }).pipe(Effect.orDie), createTask: ({ title }) => Effect.gen(function* () { const task = new Task({ id: crypto.randomUUID(), title, completed: false, }); yield* tasks.put(task.id, JSON.stringify(task)); return task; }).pipe( Effect.catchTag("R2Error", (error) => Effect.fail(new CreateTaskFailed({ message: error.message })), ), ), });
return { fetch: RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide( Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson), ), ), }; }).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( "TaskRpc", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const worker = yield* Worker; return { url: worker.url }; }),);5. Deploy
Section titled “5. Deploy”alchemy deployBonus: call it from a typed client
Section titled “Bonus: call it from a typed client”Because TaskRpcs is just a value, the same group drives a fully
typed client — no codegen. client.createTask accepts
{ title: string } and returns Effect<Task, CreateTaskFailed>.
import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";import { RpcClient, RpcSerialization } from "effect/unstable/rpc";import { TaskRpcs } from "../src/rpcs.ts";
const program = Effect.gen(function* () { const client = yield* RpcClient.make(TaskRpcs);
const task = yield* client.createTask({ title: "Write docs" }); console.log("Created:", task.id);
const fetched = yield* client.getTask({ id: task.id }); console.log("Fetched:", fetched.title);});
Effect.runPromise( program.pipe( Effect.scoped, Effect.provide( RpcClient.layerProtocolHttp({ url: process.env.TASK_RPC_URL! }).pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(RpcSerialization.layerNdjson), ), ), ),);Get the URL from the deploy output and run it:
TASK_RPC_URL=https://your-worker.workers.dev bun scripts/client.tsThe errors are typed values: client.getTask returns
Effect<Task, TaskNotFound>, and you can Effect.catchTag( "TaskNotFound", ...) to handle the missing case explicitly.
Bonus: streaming responses
Section titled “Bonus: streaming responses”An RPC’s success doesn’t have to be a single value. Wrapping it in
RpcSchema.Stream(item, error) produces a procedure whose handler
returns a Stream and whose client method also returns a Stream.
The wire format is one frame per item — exactly what the
RpcSerialization.layerNdjson layer we’re already on emits, so the
fetch wiring doesn’t change.
import * as RpcSchema from "effect/unstable/rpc/RpcSchema";
const countTasks = Rpc.make("countTasks", { payload: { upto: Schema.Number }, success: RpcSchema.Stream(Schema.Number, Schema.Never),});
export class TaskRpcs extends RpcGroup.make(getTask, createTask, countTasks) {}The handler returns a Stream<number> directly:
import * as Stream from "effect/Stream";
const handlersLayer = TaskRpcs.toLayer({ getTask: /* ... */, createTask: /* ... */, countTasks: ({ upto }) => Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)), });On the client, client.countTasks({ upto: 5 }) is a Stream<number>
you consume with Stream.runCollect, Stream.runForEach, etc. Each
emitted item arrives as soon as the server flushes its frame.
- Schemas (
Task,TaskNotFound,CreateTaskFailed) and the RPC group (TaskRpcs) live outside the Worker — pure descriptions, importable by clients. - The handlers are constructed inside the Worker’s Init phase
closure via
TaskRpcs.toLayer. We build aLayerbut neveryield*the running server. - The Worker’s surface is
{ fetch }, wherefetchis theHttpEffectproduced byRpcServer.toHttpEffect. - The same
TaskRpcsvalue drives a fully typed client viaRpcClient.make, with errors as typed values rather than HTTP status codes.
Sugar: Cloudflare.RpcWorker
Section titled “Sugar: Cloudflare.RpcWorker”The Cloudflare.Worker(...) + RpcServer.toHttpEffect(...) recipe is
identical for every RPC Worker, so Alchemy ships a thin wrapper that
takes the RpcGroup directly in props and removes the { fetch }
wrapper:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { RpcSerialization, RpcServer } from "effect/unstable/rpc";import { TaskRpcs } from "./rpcs.ts";
export default class Worker extends Cloudflare.RpcWorker<Worker>()( "Worker", { main: import.meta.url, schema: TaskRpcs }, Effect.gen(function* () { const handlers = TaskRpcs.toLayer({ getTask: /* ... */ }); return RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); }),) {}Functionally identical to the long form above — yielding the class
returns the same Worker resource — but props.schema lets a second
Worker bind a typed client without re-importing RpcClient:
// INIT: register the binding, get the typed clientconst tasks = yield* Cloudflare.RpcWorker.bind(TaskWorker);
// PER-REQUEST: just call methods directlyproxyGetTask: ({ id }) => tasks.getTask({ id }),The bind goes over the in-account service binding (not the public
internet) and the client is typed by TaskWorker’s declared schema.
A Proxy defers each call’s underlying RpcClient construction so
Cloudflare’s “no cross-request I/O” rule is satisfied transparently.
For internal Worker-to-Worker calls that don’t need a schema at all,
see Schemaless RPC on Workers.
RpcWorker also supports a modular form that separates the
class declaration from its runtime — useful when a consumer Worker
should be able to import the class for binding without pulling in
the host’s runtime:
// Modular: class declaration carries no impl — just the schema.export class TaskWorker extends Cloudflare.RpcWorker<TaskWorker>()( "TaskWorker", { schema: TaskRpcs },) {}
// Runtime lives in a separate Layer — only the host script imports it.// `make` takes the Worker props (main, etc.) plus the impl.export default TaskWorker.make( { main: import.meta.url }, Effect.gen(function* () { const handlers = TaskRpcs.toLayer({ /* ... */ }); return RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); }),);The class can also declare DOs it publishes via the second type
argument — RpcWorker<Self, Deps>() mirrors
Cloudflare.Worker<Self, Bindings, Deps> so cross-script
Counter.from(TaskWorker) type-checks.
Sugar: Cloudflare.RpcDurableObject
Section titled “Sugar: Cloudflare.RpcDurableObject”The same shape applies to Durable Objects.
Cloudflare.RpcDurableObject<Self>()(...) mirrors the
regular DO class but the inner Effect returns the piped
RpcServer.toHttpEffect(schema) Effect directly:
export default class Counter extends Cloudflare.RpcDurableObject<Counter>()( "Counter", { schema: CounterRpcs }, Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const handlers = CounterRpcs.toLayer({ /* ... */ }); return RpcServer.toHttpEffect(CounterRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); }); }),) {}counters.getByName(id) returns an Effect<RpcClient<CounterRpcs>>
(yield it inside a per-request scope) rather than alchemy’s built-in
DO method bridge. Reach for this when an external client connects to
the DO and speaks RPC over a WebSocket connection, or whenever DO
method return values cross a Schema.Class boundary — the built-in bridge
JSON.stringifys each value and loses class identity, while the RPC
namespace round-trips through the shared RpcSerialization codec.
The concept home for this trade-off is
Effect RPC.
Like RpcWorker, the RPC DO supports a modular form that
separates the class from its runtime so consumer Workers can bind
to it cross-script:
// counter.ts — class declaration carries no impl.export class Counter extends Cloudflare.RpcDurableObject<Counter>()( "Counter", { schema: CounterRpcs },) {}
// Runtime lives in a separate Layer — only the host Worker imports it.export default Counter.make( Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const handlers = CounterRpcs.toLayer({ /* ... */ }); return RpcServer.toHttpEffect(CounterRpcs).pipe( Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerNdjson)), ); }); }),);From a consumer Worker, Counter.from(HostWorker) produces a
typed namespace bound to the host’s running DO instances:
const counters = yield* Counter.from(HostWorker);const stub = yield* counters.getByName("shared");yield* stub.setTitle({ title: "hi" });For internal DO calls that don’t need a schema, see Schemaless RPC on Durable Objects.
Under the hood: the manual DO bridge
Section titled “Under the hood: the manual DO bridge”RpcDurableObject.getByName(id) above returns a wired RpcClient
directly — you rarely need more. This section shows what it does
internally, and doubles as the escape hatch when the RpcServer
runs inside a plain Cloudflare.DurableObject and you build the
bridge yourself: Cloudflare.toHttpClient(stub) turns the DO stub
into an HttpClient that plugs into RpcClient.layerProtocolHttp.
Split the RPCs into two groups
Section titled “Split the RPCs into two groups”Put the procedures the DO implements into one group, and the
Worker-only proxies into another. merge produces the public group
the Worker exposes:
export const InnerRpcs = RpcGroup.make(getTask, createTask, countTasks);
export const DoRpcs = RpcGroup.make( Rpc.make("getTaskDO", { payload: { id: Schema.String }, success: Task, error: TaskNotFound, }), Rpc.make("createTaskDO", { payload: { title: Schema.String }, success: Task, error: CreateTaskFailed, }),);
export class TaskRpcs extends InnerRpcs.merge(DoRpcs) {}Implement the DO
Section titled “Implement the DO”The DO’s Init returns { fetch } produced by
RpcServer.toHttpEffect(InnerRpcs). State lives in
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 Stream from "effect/Stream";import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";import * as RpcServer from "effect/unstable/rpc/RpcServer";import { InnerRpcs } from "./rpcs.ts";import { Task, TaskNotFound } from "./task.ts";
export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()( "TasksObject", Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const handlersLayer = InnerRpcs.toLayer({ getTask: ({ id }) => state.storage.get<Task>(id).pipe( Effect.flatMap((task) => task ? Effect.succeed(task) : Effect.fail(new TaskNotFound({ id })), ), ), createTask: ({ title }) => Effect.sync(() => new Task({ id: crypto.randomUUID(), title, completed: false })) .pipe(Effect.tap((task) => state.storage.put(task.id, task))), countTasks: ({ upto }) => Stream.fromIterable(Array.from({ length: upto }, (_, i) => i + 1)), });
return { fetch: RpcServer.toHttpEffect(InnerRpcs).pipe( Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson)), ), }; }); }),) {}Bridge the DO into the Worker
Section titled “Bridge the DO into the Worker”Cloudflare.toHttpClient(stub) produces an HttpClient whose
execute calls into the DO’s fetch. Plug it into
RpcClient.layerProtocolHttp to get a typed RpcClient<InnerRpcs>
— the group the DO actually serves — then the *DO handlers just
forward the typed call. Here’s the
whole Worker — the R2-backed handlers are unchanged, the new
makeDOClient and getTaskDO / createTaskDO proxies are added:
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); const tasksDO = yield* TasksObject;
// A per-request typed RpcClient that short-circuits to the DO's // `fetch`. The `url` is a placeholder — the request never hits the // network; it routes straight to `tasksDO.getByName(id).fetch`. const makeDOClient = (id: string = "default") => RpcClient.make(InnerRpcs).pipe( Effect.provide( RpcClient.layerProtocolHttp({ url: "http://localhost" }).pipe( Layer.provide( Layer.succeed( HttpClient.HttpClient, Cloudflare.toHttpClient(tasksDO.getByName(id)), ), ), Layer.provide(RpcSerialization.layerNdjson), ), ), );
const handlersLayer = TaskRpcs.toLayer({ // R2-backed handlers — unchanged getTask: /* ... R2 implementation ... */, createTask: /* ... R2 implementation ... */, // DO-backed proxies — forward the typed call to the DO getTaskDO: (payload) => makeDOClient().pipe(Effect.flatMap((client) => client.getTask(payload))), createTaskDO: (payload) => makeDOClient().pipe(Effect.flatMap((client) => client.createTask(payload))), });
return { fetch: RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide( Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson), ), ), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);For streaming RPCs the same proxy pattern works with Stream.unwrap —
declare a streaming countTasksDO in DoRpcs (same shape as
countTasks, with stream: true) and forward:
countTasksDO: (payload) => Stream.unwrap( makeDOClient().pipe(Effect.map((client) => client.countTasks(payload))), ),getTaskDO / createTaskDO now hit the DO; getTask / createTask
still hit R2. One TaskRpcs value, one client, two storage backends.
Choosing a modality
Section titled “Choosing a modality”- Internal calls (Worker-to-Worker, Worker-to-DO) — Schemaless RPC: typed clients with no schema and no per-request validation cost.
- External Effect/TypeScript consumers — this page.
- External plain-HTTP consumers — Effect HTTP: the same typed interface over real REST endpoints.
The full decision guide lives at RPC.