Skip to content

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.

Create a domain model and error types. Create src/task.ts:

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.

Create src/rpcs.ts with two procedures:

src/rpcs.ts
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.

Add the implementation to src/rpcs.ts:

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.

Create src/worker.ts:

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.

Create alchemy.run.ts:

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,
};
}),
);
Terminal window
alchemy deploy

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.

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.make declares procedures with typed payloads, success schemas, and error schemas
  • RpcGroup.make collects procedures and toLayer implements them
  • RpcServer.toHttpEffect converts the group into an HttpEffect — the same type a Worker’s fetch expects
  • RpcClient.make gives you a fully typed client with no code generation
  • Errors are schema-backed tagged classes, not HTTP status codes