Client and server
A group of RPC definitions is interpreted at two ends. On
the server, group.toLayer asks you to implement one handler per procedure
and produces a Layer of those handlers; RpcServer.layer attaches a transport
and starts serving. On the client, RpcClient.make reads the same group and
hands you an object whose methods are the procedures — calling
client.GetUser({ id }) encodes the payload, sends it, and decodes the typed
result or error back into an Effect.
Throughout, the group is the single source of truth: handler signatures and client method signatures are both derived from it, so they cannot disagree.
Implementing handlers
Section titled “Implementing handlers”group.toLayer takes an Effect that builds an object keyed by RPC tag. Each
handler receives the decoded payload as its first argument and returns an
Effect (or a Stream for streaming RPCs). Building the handlers
inside an Effect.gen lets you pull in services first — here a Database
service the handlers depend on.
import { Context, Effect, Layer, Schema } from "effect"import { Rpc, RpcGroup } from "effect/unstable/rpc"
class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()( "UserNotFound", { id: Schema.String }) {}
const User = Schema.Struct({ id: Schema.String, name: Schema.String })
export class UserRpcs extends RpcGroup.make( Rpc.make("GetUser", { payload: { id: Schema.String }, success: User, error: UserNotFound }), Rpc.make("CreateUser", { payload: { name: Schema.String }, success: User })) {}
// A service the handlers depend on. Handlers are wired up like any other Effect// code, so they can require services from the environment.class Database extends Context.Service<Database, { readonly find: (id: string) => Effect.Effect<{ id: string; name: string }, UserNotFound> readonly insert: (name: string) => Effect.Effect<{ id: string; name: string }>}>()("app/Database") { static readonly layer = Layer.succeed(Database)( Database.of({ find: (id) => id === "1" ? Effect.succeed({ id, name: "Ada" }) : Effect.fail(new UserNotFound({ id })), insert: (name) => Effect.succeed({ id: "2", name }) }) )}
// `toLayer` returns a Layer that satisfies the handler requirements for every// procedure in the group. The build effect runs once at construction, so this is// the place to acquire shared resources.export const UserRpcsLayer = UserRpcs.toLayer( Effect.gen(function*() { const db = yield* Database
return { // The first argument is the decoded payload. Return an Effect whose success // matches `success` and whose error matches `error`. GetUser: ({ id }) => db.find(id), CreateUser: ({ name }) => db.insert(name) } })).pipe(Layer.provide(Database.layer))The second argument to each handler (omitted above) carries per-request
metadata: the connected client, the requestId, and the request headers —
useful for authentication or logging.
Serving over a transport
Section titled “Serving over a transport”The handler layer is transport-agnostic. To expose it, combine it with a
transport and a serialization format. RpcServer.layerHttp mounts the group on
an HTTP router path, and RpcSerialization.layerNdjson (or
layerJson) frames the messages.
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"import { Layer } from "effect"import { HttpRouter } from "effect/unstable/http"import { RpcSerialization, RpcServer } from "effect/unstable/rpc"import { createServer } from "node:http"import { UserRpcs, UserRpcsLayer } from "./handlers.ts"
// Mount the RPC group at /rpc. By default it uses websockets; pass// `protocol: "http"` for request/response HTTP.const RpcRoute = RpcServer.layerHttp({ group: UserRpcs, path: "/rpc"})
// Assemble the server: the route needs the handler implementations, a// serialization format, and an HTTP router/server to mount onto.const ServerLayer = HttpRouter.serve(RpcRoute).pipe( Layer.provide(UserRpcsLayer), // the handlers Layer.provide(RpcSerialization.layerNdjson), // wire framing Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })))
Layer.launch(ServerLayer).pipe(NodeRuntime.runMain)Calling from a typed client
Section titled “Calling from a typed client”RpcClient.make(group) returns a client whose methods mirror the group. It needs
a client Protocol — RpcClient.layerProtocolHttp points it at the server URL —
and the same serialization format the server uses. Wrapping the client in a
Context.Service is the idiomatic way to make it injectable.
import { NodeRuntime } from "@effect/platform-node"import { Context, Effect, Layer } from "effect"import { FetchHttpClient } from "effect/unstable/http"import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc"import { UserRpcs } from "./handlers.ts"
// Expose the generated client as a service so the rest of the app can depend on// it without knowing about transports. `RpcClient.make` is a scoped Effect;// `Layer.effect` runs it in the layer's scope and excludes `Scope` from the// requirements. Calls can fail with `RpcClientError` (transport-level failures),// which is part of the derived client type.export class UserClient extends Context.Service< UserClient, RpcClient.FromGroup<typeof UserRpcs, RpcClientError.RpcClientError>>()("app/UserClient") { static readonly layer = Layer.effect(UserClient)(RpcClient.make(UserRpcs))}
// The client Protocol: HTTP transport pointed at the server, NDJSON framing, and// a concrete HttpClient implementation.const ClientLayer = UserClient.layer.pipe( Layer.provide(RpcClient.layerProtocolHttp({ url: "http://localhost:3000/rpc" })), Layer.provide(RpcSerialization.layerNdjson), Layer.provide(FetchHttpClient.layer))
const program = Effect.gen(function*() { const client = yield* UserClient
// A method call IS the RPC. The payload type, the `User` success type, and the // `UserNotFound` error type all come from the group — fully checked here. const created = yield* client.CreateUser({ name: "Grace" }) yield* Effect.log(`created ${created.name}`)
// Typed errors land in the normal error channel and can be recovered by tag. const user = yield* client.GetUser({ id: "404" }).pipe( Effect.catchTag("UserNotFound", (e) => Effect.succeed({ id: e.id, name: "unknown" })) ) yield* Effect.log(`resolved ${user.name}`)})
program.pipe(Effect.provide(ClientLayer), NodeRuntime.runMain)A streaming RPC works the same way, except the method returns a
Stream instead of an Effect — you consume it with the usual
Stream operators.
Testing handlers in-memory
Section titled “Testing handlers in-memory”You usually don’t want HTTP in a unit test. RpcTest.makeClient wires a client
directly to your handlers through the same routing and middleware machinery, but
skips serialization and the network entirely. Provide the handler layer, acquire
the client in a scoped test, and call it exactly as you would the real one.
import { Effect } from "effect"import { RpcTest } from "effect/unstable/rpc"import { UserRpcs, UserRpcsLayer } from "./handlers.ts"
const test = Effect.gen(function*() { // An in-memory client backed by the real handlers from the environment. const client = yield* RpcTest.makeClient(UserRpcs)
const user = yield* client.GetUser({ id: "1" }) // assert user.name === "Ada"
// Failures surface as typed errors, just like over a real transport. const result = yield* client.GetUser({ id: "404" }).pipe(Effect.flip) // assert result._tag === "UserNotFound"})
// Scope is required because the client is tied to the in-memory connection.test.pipe(Effect.scoped, Effect.provide(UserRpcsLayer))The full loop
Section titled “The full loop”-
Define the protocol once with
Rpc.makeandRpcGroup.make(Defining RPCs). -
Implement handlers with
group.toLayer, depending on whatever services you need. -
Serve by combining the handler layer with a transport (
RpcServer.layerHttp) and a serialization format. -
Consume with
RpcClient.makeplus a client protocol layer — calling the generated methods like local functions.
Because all four steps read the same group, changing a payload or success schema in step 1 immediately surfaces as a type error in the handler and at every call site — the guarantee that makes RPC worth reaching for.