| 1 | --- |
| 2 | title: Effect RPC |
| 3 | description: Schema-first RPC for trust boundaries — declare procedures, construct handler Layers, derive typed clients from the same schema. |
| 4 | sidebar: |
| 5 | order: 2 |
| 6 | --- |
| 7 | |
| 8 | Effect RPC is schema-first: every procedure declares `payload`, |
| 9 | `success`, and `error` Schemas, and every request and response is |
| 10 | validated against them. Use it when data crosses a **trust |
| 11 | boundary** — a web app or an external service calling into your |
| 12 | stack. That validation has a per-request price (a frame parse, a |
| 13 | Schema decode of the payload, a Schema encode of the result — |
| 14 | mirrored on the client), so for internal service-to-service calls it |
| 15 | is **discouraged**: [Schemaless RPC](/apis/schemaless) gives you the |
| 16 | same typed client with no schema, no runtime checking, and no |
| 17 | per-request validation cost. |
| 18 | |
| 19 | ## The pieces |
| 20 | |
| 21 | An RPC API is five small files: |
| 22 | |
| 23 | ```sh |
| 24 | src/ |
| 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 | |
| 32 | The schema files are plain values with no runtime concerns, so both |
| 33 | the 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 | |
| 38 | Domain model and error types — pure schemas: |
| 39 | |
| 40 | ```typescript |
| 41 | // src/Task.ts |
| 42 | import * as Schema from "effect/Schema"; |
| 43 | |
| 44 | export class Task extends Schema.Class<Task>("Task")({ |
| 45 | id: Schema.String, |
| 46 | title: Schema.String, |
| 47 | completed: Schema.Boolean, |
| 48 | }) {} |
| 49 | |
| 50 | export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()( |
| 51 | "TaskNotFound", |
| 52 | { id: Schema.String }, |
| 53 | ) {} |
| 54 | |
| 55 | export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()( |
| 56 | "CreateTaskFailed", |
| 57 | { message: Schema.String }, |
| 58 | ) {} |
| 59 | ``` |
| 60 | |
| 61 | RPC errors are schema-backed tagged classes — the client receives |
| 62 | them as typed values it can `catchTag` on, not raw HTTP status codes. |
| 63 | |
| 64 | ## The procedures |
| 65 | |
| 66 | Each `Rpc.make` declares one procedure; `RpcGroup.make` collects them |
| 67 | into the single value the server and the client will share: |
| 68 | |
| 69 | ```typescript |
| 70 | // src/ApiSchema.ts |
| 71 | import * as Schema from "effect/Schema"; |
| 72 | import { Rpc, RpcGroup } from "effect/rpc"; |
| 73 | import { CreateTaskFailed, Task, TaskNotFound } from "./Task.ts"; |
| 74 | |
| 75 | const getTask = Rpc.make("getTask", { |
| 76 | payload: { id: Schema.String }, |
| 77 | success: Task, |
| 78 | error: TaskNotFound, |
| 79 | }); |
| 80 | |
| 81 | const createTask = Rpc.make("createTask", { |
| 82 | payload: { title: Schema.String }, |
| 83 | success: Task, |
| 84 | error: CreateTaskFailed, |
| 85 | }); |
| 86 | |
| 87 | export 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 |
| 99 | import * as Effect from "effect/Effect"; |
| 100 | import { TaskRpcs } from "./ApiSchema.ts"; |
| 101 | import { Task, TaskNotFound } from "./Task.ts"; |
| 102 | |
| 103 | export 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 |
| 114 | server, so it is safe inside a host's Construction phase, which also |
| 115 | executes at plan time |
| 116 | ([Phases](/infrastructure-as-effects/phases)); each handler receives |
| 117 | the decoded payload and returns |
| 118 | an `Effect` that succeeds or fails with the declared schemas, and the |
| 119 | storage-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 |
| 126 | a serialization: |
| 127 | |
| 128 | ```typescript |
| 129 | // src/ApiHandlers.ts (continued) |
| 130 | import * as Layer from "effect/Layer"; |
| 131 | import { RpcSerialization, RpcServer } from "effect/rpc"; |
| 132 | |
| 133 | export 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 |
| 139 | host serves — it imports nothing cloud-specific. `layerJson` buffers |
| 140 | one body per request and suits plain request/response; streaming |
| 141 | rpcs require `RpcSerialization.layerNdjson` — whichever you pick, |
| 142 | the client's serialization layer must match the server's. A Lambda |
| 143 | Function with `functionUrl: true` or a Cloudflare Worker returns it as |
| 144 | `{ fetch }` — host wiring, storage binding Layers, and deploy live |
| 145 | on [Workers](/cloudflare/apis/effect-rpc) and |
| 146 | [Lambda](/aws/apis/effect-rpc). |
| 147 | |
| 148 | ## A typed client |
| 149 | |
| 150 | A client is the schema combined with a **Fetcher** — a plain URL for |
| 151 | external consumers, or a platform Binding when the caller lives in |
| 152 | the same account (see the Workers page). Here is the URL form: |
| 153 | |
| 154 | ```typescript |
| 155 | // src/ApiClient.ts |
| 156 | import * as Effect from "effect/Effect"; |
| 157 | import * as Layer from "effect/Layer"; |
| 158 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 159 | import { RpcClient, RpcSerialization } from "effect/rpc"; |
| 160 | import { TaskRpcs } from "./ApiSchema.ts"; |
| 161 | |
| 162 | const 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 | |
| 171 | Effect.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 |
| 185 | group value — no codegen — with typed errors (`client.getTask` |
| 186 | returns `Effect<Task, TaskNotFound>`); it runs inside `Effect.scoped` |
| 187 | and 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. |