1---
2title: Effect RPC
3description: Schema-first RPC for trust boundaries — declare procedures, construct handler Layers, derive typed clients from the same schema.
4sidebar:
5 order: 2
6---
7
8Effect RPC is schema-first: every procedure declares `payload`,
9`success`, and `error` Schemas, and every request and response is
10validated against them. Use it when data crosses a **trust
11boundary** — a web app or an external service calling into your
12stack. That validation has a per-request price (a frame parse, a
13Schema decode of the payload, a Schema encode of the result —
14mirrored on the client), so for internal service-to-service calls it
15is **discouraged**: [Schemaless RPC](/apis/schemaless) gives you the
16same typed client with no schema, no runtime checking, and no
17per-request validation cost.
18
19## The pieces
20
21An RPC API is five small files:
22
23```sh
24src/
25├── Task.ts # domain model + tagged errors — imported by server AND client
26├── ApiSchema.ts # one Rpc.make per procedure, collected into an RpcGroup
27├── ApiHandlers.ts # RpcGroup.toLayer handlers + the HttpEffect — host-agnostic
28├── ApiService.ts # the host — a Lambda Function or Cloudflare Worker; wired on the hub pages
29└── ApiClient.ts # a typed client — the same schema + a Fetcher
30```
31
32The schema files are plain values with no runtime concerns, so both
33the server and every client import them directly. Only
34`ApiService.ts` names a cloud — everything above it is host-agnostic.
35
36## The domain types
37
38Domain model and error types — pure schemas:
39
40```typescript
41// src/Task.ts
42import * as Schema from "effect/Schema";
43
44export class Task extends Schema.Class<Task>("Task")({
45 id: Schema.String,
46 title: Schema.String,
47 completed: Schema.Boolean,
48}) {}
49
50export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()(
51 "TaskNotFound",
52 { id: Schema.String },
53) {}
54
55export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()(
56 "CreateTaskFailed",
57 { message: Schema.String },
58) {}
59```
60
61RPC errors are schema-backed tagged classes — the client receives
62them as typed values it can `catchTag` on, not raw HTTP status codes.
63
64## The procedures
65
66Each `Rpc.make` declares one procedure; `RpcGroup.make` collects them
67into the single value the server and the client will share:
68
69```typescript
70// src/ApiSchema.ts
71import * as Schema from "effect/Schema";
72import { Rpc, RpcGroup } from "effect/rpc";
73import { CreateTaskFailed, Task, TaskNotFound } from "./Task.ts";
74
75const getTask = Rpc.make("getTask", {
76 payload: { id: Schema.String },
77 success: Task,
78 error: TaskNotFound,
79});
80
81const createTask = Rpc.make("createTask", {
82 payload: { title: Schema.String },
83 success: Task,
84 error: CreateTaskFailed,
85});
86
87export class TaskRpcs extends RpcGroup.make(getTask, createTask) {}
88```
89
90`TaskRpcs` is a value-level description — nothing executes yet.
91
92## Handlers
93
94`TaskRpcs.toLayer` takes one handler per procedure and produces a
95`Layer`:
96
97```typescript
98// src/ApiHandlers.ts
99import * as Effect from "effect/Effect";
100import { TaskRpcs } from "./ApiSchema.ts";
101import { Task, TaskNotFound } from "./Task.ts";
102
103export const TaskRpcsLive = TaskRpcs.toLayer({
104 // stubs — the per-cloud guides back these with real storage Bindings
105 getTask: ({ id }) => Effect.fail(new TaskNotFound({ id })),
106 createTask: ({ title }) =>
107 Effect.succeed(
108 new Task({ id: crypto.randomUUID(), title, completed: false }),
109 ),
110});
111```
112
113`toLayer` is pure construction — it builds a value and never runs the
114server, so it is safe inside a host's Construction phase, which also
115executes at plan time
116([Phases](/infrastructure-as-effects/phases)); each handler receives
117the decoded payload and returns
118an `Effect` that succeeds or fails with the declared schemas, and the
119storage-backed bodies live on the hub pages — R2 in
120[Effect RPC on Workers](/cloudflare/apis/effect-rpc), DynamoDB in
121[Effect RPC on Lambda](/aws/apis/effect-rpc).
122
123## Serve it
124
125`RpcServer.toHttpEffect` needs exactly two layers — the handlers and
126a serialization:
127
128```typescript
129// src/ApiHandlers.ts (continued)
130import * as Layer from "effect/Layer";
131import { RpcSerialization, RpcServer } from "effect/rpc";
132
133export const ApiHttpEffect = RpcServer.toHttpEffect(TaskRpcs).pipe(
134 Effect.provide(Layer.mergeAll(TaskRpcsLive, RpcSerialization.layerJson)),
135);
136```
137
138`toHttpEffect` compiles the group into the `{ fetch }` value every
139host serves — it imports nothing cloud-specific. `layerJson` buffers
140one body per request and suits plain request/response; streaming
141rpcs require `RpcSerialization.layerNdjson` — whichever you pick,
142the client's serialization layer must match the server's. A Lambda
143Function with `functionUrl: true` or a Cloudflare Worker returns it as
144`{ fetch }` — host wiring, storage binding Layers, and deploy live
145on [Workers](/cloudflare/apis/effect-rpc) and
146[Lambda](/aws/apis/effect-rpc).
147
148## A typed client
149
150A client is the schema combined with a **Fetcher** — a plain URL for
151external consumers, or a platform Binding when the caller lives in
152the same account (see the Workers page). Here is the URL form:
153
154```typescript
155// src/ApiClient.ts
156import * as Effect from "effect/Effect";
157import * as Layer from "effect/Layer";
158import * as FetchHttpClient from "effect/http/FetchHttpClient";
159import { RpcClient, RpcSerialization } from "effect/rpc";
160import { TaskRpcs } from "./ApiSchema.ts";
161
162const program = Effect.gen(function* () {
163 const client = yield* RpcClient.make(TaskRpcs);
164
165 const task = yield* client.createTask({ title: "Write the docs" });
166
167 const fetched = yield* client.getTask({ id: task.id });
168 console.log(fetched.title);
169});
170
171Effect.runPromise(
172 program.pipe(
173 Effect.scoped,
174 Effect.provide(
175 RpcClient.layerProtocolHttp({ url: process.env.TASK_API_URL! }).pipe(
176 Layer.provide(FetchHttpClient.layer),
177 Layer.provide(RpcSerialization.layerJson),
178 ),
179 ),
180 ),
181);
182```
183
184`RpcClient.make(TaskRpcs)` derives the whole client from the same
185group value — no codegen — with typed errors (`client.getTask`
186returns `Effect<Task, TaskNotFound>`); it runs inside `Effect.scoped`
187and the serialization layer must match the server's.
188
189## Where next
190
191- [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — full Worker
192 wiring: R2-backed handlers, streaming rpcs, the `RpcWorker` and
193 `RpcDurableObject` sugar, typed in-account bindings, the DO bridge.
194- [Effect RPC on Lambda](/aws/apis/effect-rpc) — full Lambda wiring:
195 DynamoDB bindings, IAM, Function URL, deploy.
196- [Effect HTTP](/apis/effect-http) — the same trust boundary as real
197 REST endpoints, for non-Effect consumers.
198- [Schemaless RPC](/apis/schemaless) — the default for internal
199 calls.