Effect RPC
The HTTP API guide showed how to build REST-style endpoints with schema validation. Effect RPC takes a different approach — you define procedures instead of HTTP endpoints. The transport is still HTTP under the hood, but you get a fully typed client for free. No URL construction, no manual serialization.
Both patterns produce the same HttpEffect type, so wiring into a
Worker is identical.
Define the schemas
Section titled “Define the schemas”Create a domain model and error types. 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,}) {}
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 — not raw HTTP status codes.
Define the RPCs
Section titled “Define the RPCs”Create src/rpcs.ts with two procedures:
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) {}Rpc.make declares a procedure with a name, success schema, error
schema, and payload schema. RpcGroup.make collects them into a
group — similar to HttpApiGroup, but for RPC.
Implement the handlers
Section titled “Implement the handlers”Add the implementation to src/rpcs.ts:
import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { Rpc, RpcGroup, RpcSerialization, RpcServer,} 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) {}
const tasks = new Map<string, Task>();
export const TaskRpcsLive = TaskRpcs.toLayer( Effect.gen(function* () { return { getTask: ({ id }) => { const task = tasks.get(id); if (!task) { return Effect.fail(new TaskNotFound({ id })); } return Effect.succeed(task); }, createTask: ({ title }) => Effect.gen(function* () { const task = new Task({ id: crypto.randomUUID(), title, completed: false, }); tasks.set(task.id, task); return task; }), }; }),);
export const TaskRpcHttpEffect = RpcServer.toHttpEffect(TaskRpcs).pipe( Effect.provide(TaskRpcsLive), Effect.provide(RpcSerialization.layerJson),);TaskRpcs.toLayer takes an Effect that returns an object with one
handler function per procedure. Each handler receives the typed
payload and returns an Effect that can fail with the declared error
type.
RpcServer.toHttpEffect converts the RPC group into an HttpEffect
— the same type a Worker’s fetch expects.
RpcSerialization.layerJson tells the server to encode/decode
messages as JSON.
Wire into a Worker
Section titled “Wire into a Worker”Create src/worker.ts:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { TaskRpcHttpEffect } from "./rpcs.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.path }, TaskRpcHttpEffect.pipe(Effect.map((fetch) => ({ fetch }))),);The pattern is the same as the HTTP API guide:
RpcServer.toHttpEffect returns an HttpEffect, and
Effect.map((fetch) => ({ fetch })) wraps it into the shape a
Worker expects.
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( "TaskRpc", { providers: Cloudflare.providers(), }, Effect.gen(function* () { const worker = yield* Worker;
return { url: worker.url, }; }),);Deploy
Section titled “Deploy”alchemy deployCall from a typed client
Section titled “Call from a typed client”The main advantage of RPC over a REST API: you get a fully typed client with no code generation.
import * as Effect from "effect/Effect";import * as HttpClient from "effect/unstable/http/HttpClient";import { RpcClient, RpcSerialization, RpcServer } from "effect/unstable/rpc";import { TaskRpcs } from "./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);});The client methods are typed end-to-end — client.createTask
accepts { title: string } and returns Effect<Task, CreateTaskFailed>.
Errors are typed values, not HTTP status codes you have to interpret.
HTTP API vs RPC
Section titled “HTTP API vs RPC”Both approaches produce the same HttpEffect and wire into a Worker
the same way. The difference is in how clients interact with your
service:
- HTTP API — standard REST endpoints, any HTTP client can call them, good for public APIs
- RPC — typed procedures, ideal for service-to-service communication where both sides share the schema definitions
You can even combine them in the same Worker — serve an HTTP API for external consumers and RPC for internal services.
Rpc.makedeclares procedures with typed payloads, success schemas, and error schemasRpcGroup.makecollects procedures andtoLayerimplements themRpcServer.toHttpEffectconverts the group into anHttpEffect— the same type a Worker’sfetchexpectsRpcClient.makegives you a fully typed client with no code generation- Errors are schema-backed tagged classes, not HTTP status codes