Skip to content

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.

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, the request headers, and the rpc definition itself — useful for authentication or logging.

import { Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
UserRpcs.toLayer({
GetUser: (payload, { client, requestId, headers, rpc }) =>
Effect.gen(function*() {
// `client.id` identifies the connection; `headers` are the request headers.
yield* Effect.log(`req ${requestId} on client ${client.id} for ${rpc._tag}`)
const auth = headers["authorization"]
// ... authenticate, then return the success value
return { id: payload.id, name: "Ada" }
}),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})

The client is an Rpc.ServerClient — beyond client.id it carries client.annotate(tag, value) for stashing per-request annotations (read by middleware or later handlers on the same connection).

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. The default is websockets; here we use
// request/response HTTP so it pairs with the `layerProtocolHttp` client below.
const RpcRoute = RpcServer.layerHttp({
group: UserRpcs,
path: "/rpc",
protocol: "http"
})
// 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)

RpcClient.make(group) returns a client whose methods mirror the group. It needs a client ProtocolRpcClient.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.

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))
  1. Define the protocol once with Rpc.make and RpcGroup.make (Defining RPCs).

  2. Implement handlers with group.toLayer, depending on whatever services you need.

  3. Serve by combining the handler layer with a transport (RpcServer.layerHttp) and a serialization format.

  4. Consume with RpcClient.make plus 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.


The group’s to* methods all build the same thing — a context entry per RPC tag, keyed so the server can look up the handler when a request arrives. They differ only in what they return (a Layer, a Context, or the identity of a handlers object) and in granularity (whole group vs. a single tag).

Implements every handler in the group and returns a Layer<Rpc.ToHandler<Rpcs>>. Pass either a plain handlers object or an Effect that builds one (use the effectful form when handlers need services acquired up front). Scope from the build effect is consumed by the layer, so the layer’s requirements never include Scope.

import { Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
// Plain object form (no setup needed).
const HandlersLayer = UserRpcs.toLayer({
GetUser: ({ id }) => Effect.succeed({ id, name: "Ada" }),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})
// => Layer<Rpc.ToHandler<...>, never, ...handler requirements>

Same as toLayer but returns the underlying Context inside an Effect instead of a Layer. Reach for it when you want to inspect or compose the handler context directly rather than provide it as a layer.

import { Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
const handlersEffect = UserRpcs.toHandlers({
GetUser: ({ id }) => Effect.succeed({ id, name: "Ada" }),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})
// => Effect<Context<Rpc.ToHandler<...>>, never, ...>

The identity function, typed as the group’s handlers object. Use it to author and type-check a handlers object on its own (e.g. in another module) before passing it to toLayer.

import { Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
// Fully type-checked against the group, but not yet turned into a layer.
const handlers = UserRpcs.of({
GetUser: ({ id }) => Effect.succeed({ id, name: "Ada" }),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})
const HandlersLayer = UserRpcs.toLayer(handlers)

Implements a single handler by tag and returns a Layer<Rpc.Handler<Tag>>. Compose several of these (and merge them) when you want to split a large group’s handlers across files.

import { Effect, Layer } from "effect"
import { UserRpcs } from "./handlers.ts"
const GetUserLayer = UserRpcs.toLayerHandler(
"GetUser",
({ id }) => Effect.succeed({ id, name: "Ada" })
)
const CreateUserLayer = UserRpcs.toLayerHandler(
"CreateUser",
({ name }) => Effect.succeed({ id: "2", name })
)
// Merge the per-handler layers into the full handler set.
const AllHandlers = Layer.mergeAll(GetUserLayer, CreateUserLayer)

Retrieves the implemented handler function for a tag from the environment, with its handler context already provided. Handy for invoking one handler from another, or for bespoke dispatch outside RpcServer.

import { Effect } from "effect"
import { RequestId } from "effect/unstable/rpc/RpcMessage"
import { Rpc } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const program = Effect.gen(function*() {
const getUser = yield* UserRpcs.accessHandler("GetUser")
const result = yield* getUser(
{ id: "1" },
{ client: new Rpc.ServerClient(0), requestId: RequestId(0n), headers: {} }
)
// => { id: "1", name: "Ada" }
})

Every handler’s second argument is { client, requestId, headers, rpc }. The client is an Rpc.ServerClient: client.id is the numeric connection id and client.annotate(key, value) records a per-request annotation, returning the same client for chaining.

import { Context, Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
const RequestStart = Context.Reference<number>("app/RequestStart", {
defaultValue: () => 0
})
UserRpcs.toLayer({
GetUser: ({ id }, { client }) => {
client.annotate(RequestStart, Date.now()) // => the same ServerClient
return Effect.succeed({ id, name: "Ada" })
},
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})

Handler result wrappers (deferred responses, fork, uninterruptible)

Section titled “Handler result wrappers (deferred responses, fork, uninterruptible)”

A handler’s return value is interpreted by the server. Beyond the obvious cases there are three power features, all in the Rpc module.

A non-streaming handler may return an Effect that succeeds with a Deferred<Success, Error> instead of the value itself. The server keeps the request open and replies only when the deferred completes — useful for handing the response off to another fiber (a queue worker, a webhook callback, etc.) without blocking the handler fiber.

import { Deferred, Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
UserRpcs.toLayer(
Effect.gen(function*() {
return {
// Return a Deferred: the request stays open until it is completed elsewhere.
GetUser: ({ id }) =>
Effect.gen(function*() {
const deferred = yield* Deferred.make<{ id: string; name: string }>()
// hand `deferred` to a worker; complete it later
yield* Effect.forkScoped(
Deferred.succeed(deferred, { id, name: "Ada" })
)
return deferred // => server replies when the deferred resolves
}),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
}
})
)

Wraps a handler result so the server runs it concurrently, bypassing the server’s concurrency limit. Use it for long-lived or independent work that should not occupy a concurrency permit.

import { Effect } from "effect"
import { Rpc } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
UserRpcs.toLayer({
// Runs regardless of the configured server concurrency.
GetUser: ({ id }) => Rpc.fork(Effect.succeed({ id, name: "Ada" })),
CreateUser: ({ name }) => Effect.succeed({ id: "2", name })
})

Wraps a handler result so it runs in an uninterruptible region — the request will not be interrupted by a client interrupt or server shutdown mid-flight.

import { Effect } from "effect"
import { Rpc } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
UserRpcs.toLayer({
CreateUser: ({ name }) =>
Rpc.uninterruptible(Effect.succeed({ id: "2", name })),
GetUser: ({ id }) => Effect.succeed({ id, name: "Ada" })
})

The general form behind fork/uninterruptible: Rpc.wrap({ fork?, uninterruptible? }) returns a function that wraps a result with both flags at once. Companion helpers: Rpc.unwrap (strip the wrapper), Rpc.wrapMap (map the inner value, preserving flags), and Rpc.isWrapper (guard).

import { Effect } from "effect"
import { Rpc } from "effect/unstable/rpc"
const wrapBoth = Rpc.wrap({ fork: true, uninterruptible: true })
const result = wrapBoth(Effect.succeed(42))
Rpc.isWrapper(result) // => true
Rpc.unwrap(result) // => Effect.succeed(42)

A streaming RPC’s handler may return either a Stream or an Effect that produces a Queue.Dequeue of chunks. Protocols that support acknowledgements (socket, worker, stdio — but not HTTP) wait for client acks between chunks to provide back pressure.

import { Effect, Schema, Stream } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
class Logs extends RpcGroup.make(
Rpc.make("Tail", { payload: {}, success: Schema.String, stream: true })
) {}
Logs.toLayer({
// Return a Stream...
Tail: () => Stream.make("line 1", "line 2")
})
Logs.toLayer({
// ...or an Effect producing a Dequeue of chunks (scoped — the queue is
// torn down when the request completes).
Tail: () => Stream.toQueue(Stream.make("line 1", "line 2"), { capacity: 16 })
})

These start a server for a group given a server Protocol in the environment. The transport Protocol constructors (makeProtocol* / layerProtocol* for HTTP, websocket, socket, stdio, and workers) live on the transports page.

Runs the server loop using the current Protocol, returning an Effect<never, never, Protocol | Rpc.ToHandler | Rpc.Middleware | Rpc.ServicesServer> that never completes. Fork it yourself; layer is usually more convenient.

import { Effect } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const server = RpcServer.make(UserRpcs, { concurrency: 10 })
// => Effect<never, never, Protocol | handlers | middleware | schema services>

Wraps make in a scoped, forked Layer<never> — the standard way to run a server. Requires a Protocol (provided by a transport layer) plus the handlers.

import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const ServerLayer = RpcServer.layer(UserRpcs, { spanPrefix: "UserRpc" })
// => Layer<never, never, Protocol | handlers | ...>

The route-mounting convenience used above: takes { group, path, protocol? } and internally provides an HTTP or websocket Protocol (default "websocket", protocol: "http" for request/response). Still needs RpcSerialization, HttpRouter, and the handlers in the environment.

import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
RpcServer.layerHttp({ group: UserRpcs, path: "/rpc", protocol: "http" })
// => Layer<never, never, RpcSerialization | HttpRouter | handlers | ...>

RpcServer.toHttpEffect / toHttpEffectWebsocket

Section titled “RpcServer.toHttpEffect / toHttpEffectWebsocket”

Start a server and hand back a standalone HTTP app Effect (an Effect<HttpServerResponse, never, Scope | HttpServerRequest>) instead of mounting a route — wire it into any HTTP handler yourself. toHttpEffect serves the request/response HTTP protocol; toHttpEffectWebsocket upgrades to websockets.

import { Effect } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const make = Effect.gen(function*() {
const httpApp = yield* RpcServer.toHttpEffect(UserRpcs)
// => use `httpApp` as an HttpApp (e.g. with HttpServer.serveEffect)
return httpApp
})

For already-decoded channels: returns an RpcServer<Rpcs> with write(clientId, message) and disconnect(clientId), taking an onFromServer callback for decoded responses. This is the seam RpcTest builds on; you rarely call it directly.

import { Effect } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const make = RpcServer.makeNoSerialization(UserRpcs, {
onFromServer: (response) => Effect.log(response._tag)
})
// => Effect<RpcServer<...>, never, handlers | middleware | Scope>

make, layer, and layerHttp share these options (the toHttpEffect* functions accept the same set except concurrency):

  • concurrencynumber | "unbounded" (default "unbounded"); the max number of in-flight requests (forked handlers via Rpc.fork bypass it).
  • disableFatalDefects — when true, handler defects stay ordinary request exits instead of being sent as protocol-level defects.
  • disableTracing — turn off per-request spans.
  • spanPrefix — prefix for request span names (default "RpcServer").
  • spanAttributes — extra attributes attached to every request span.
import { RpcServer } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
RpcServer.layer(UserRpcs, {
concurrency: 25,
disableFatalDefects: true,
spanPrefix: "UserRpc",
spanAttributes: { service: "users" }
})

Builds a typed client from a group using the current client Protocol. Returns a scoped Effect — run it inside a Layer.effect/Effect.scoped. With { flatten: true } it returns an RpcClient.Flat instead (see below).

import { Effect } from "effect"
import { RpcClient } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const acquire = RpcClient.make(UserRpcs)
// => Effect<RpcClient<UserRpcs, RpcClientError>, never, Protocol | ... | Scope>

Every generated method accepts an options object as its second argument:

  • headers — extra headers merged into the request (Headers.Input).
  • context — a Context made available to the payload/result codecs.
  • discard — for non-streaming calls: fire-and-forget. The call resolves to void and ignores the result/error.
  • asQueue — for streaming calls: return a scoped Effect<Queue.Dequeue<...>> instead of a Stream.
  • streamBufferSize — for streaming calls: the queue buffer size (default 16).
import { Effect } from "effect"
import { UserRpcs } from "./handlers.ts"
import { RpcClient } from "effect/unstable/rpc"
const program = Effect.gen(function*() {
const client = yield* RpcClient.make(UserRpcs)
// Fire-and-forget: resolves to void.
yield* client.CreateUser({ name: "Grace" }, { discard: true })
// => void
// Per-call header.
yield* client.GetUser({ id: "1" }, { headers: { authorization: "Bearer t" } })
})

Flattened clients ({ flatten: true }, RpcClient.Flat)

Section titled “Flattened clients ({ flatten: true }, RpcClient.Flat)”

With { flatten: true }, the client is a single function called as client(tag, payload, options?) rather than an object of methods. Useful for generic dispatch where the tag is dynamic.

import { Effect } from "effect"
import { RpcClient } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const program = Effect.gen(function*() {
const client = yield* RpcClient.make(UserRpcs, { flatten: true })
const user = yield* client("GetUser", { id: "1" })
// => { id: "1", name: "Ada" }
})

Returns { client, write } for an already-decoded channel: client is the typed client and write(message) feeds it server messages. This is the in-process seam that RpcTest uses.

import { Effect } from "effect"
import { RpcClient } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const make = RpcClient.makeNoSerialization(UserRpcs, {
onFromClient: ({ message }) => Effect.log(message._tag)
})
// => Effect<{ client, write }, never, Scope | client middleware>

Headers: RpcClient.CurrentHeaders and RpcClient.withHeaders

Section titled “Headers: RpcClient.CurrentHeaders and RpcClient.withHeaders”

CurrentHeaders is a Context.Reference<Headers> merged into every outgoing request on the current fiber. withHeaders(effect, headers) runs effect with those headers merged in — a scoped way to attach auth tokens to a block of calls.

import { Effect } from "effect"
import { RpcClient } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
const program = Effect.gen(function*() {
const client = yield* RpcClient.make(UserRpcs)
// Every call inside this block carries the header.
yield* RpcClient.withHeaders(
client.GetUser({ id: "1" }),
{ authorization: "Bearer token" }
)
})

The client transport service. Provided by a layerProtocol* layer (HTTP, socket, worker — see transports). It declares supportsAck and supportsTransferables; HTTP supports neither, so client acknowledgements and streaming back pressure are only available over socket and worker transports.

  • RpcClient.RpcClient<Rpcs, E> — the object-shaped client (one method per tag).
  • RpcClient.From<Rpcs, E> — the underlying mapped object type.
  • RpcClient.Flat<Rpcs, E> — the flattened (tag, payload, options?) function type.
  • RpcClient.FromGroup<Group, E> — the object client derived from an RpcGroup (the convenient form to use in a Context.Service shape).
import { Context, Layer } from "effect"
import { RpcClient, RpcClientError } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
class UserClient extends Context.Service<
UserClient,
RpcClient.FromGroup<typeof UserRpcs, RpcClientError.RpcClientError>
>()("app/UserClient") {
static readonly layer = Layer.effect(UserClient)(RpcClient.make(UserRpcs))
}

A call can fail in two distinct ways. Declared typed errors (the RPC’s error schema, e.g. UserNotFound) are decoded from the response exit and land in the error channel by tag. Transport-level failures surface as RpcClientError, which carries a reason union: WorkerErrorReason | SocketErrorReason | HttpClientErrorSchema | RpcClientDefect. Server defects are sent as protocol messages and normally fail the call as defects (not as declared errors).

Inspect error.reason._tag to decide whether to retry, reconnect, or surface a protocol/codec mismatch (RpcClientDefect).

import { Effect } from "effect"
import { RpcClientError } from "effect/unstable/rpc"
import { UserRpcs } from "./handlers.ts"
import { RpcClient } from "effect/unstable/rpc"
const program = Effect.gen(function*() {
const client = yield* RpcClient.make(UserRpcs)
yield* client.GetUser({ id: "1" }).pipe(
// Declared domain error — recovered by its own tag.
Effect.catchTag("UserNotFound", (e) => Effect.succeed({ id: e.id, name: "?" })),
// Transport failure — a single RpcClientError, inspect its reason.
Effect.catchTag("RpcClientError", (e: RpcClientError.RpcClientError) => {
switch (e.reason._tag) {
case "RpcClientDefect":
return Effect.die(e) // protocol/codec mismatch
default:
return Effect.fail(e) // transport issue: HTTP/socket/worker
}
})
)
})

RpcClientError.RpcClientDefect ({ message, cause }) is the bucket custom protocols use to put invalid client-side protocol state in the same public error channel as the built-in transports.

RpcTest.makeClient(group, options?) connects a generated client straight to the server handlers for the same group over the no-serialization path — exercising routing, handler lookup, middleware, headers, typed errors, interrupts, and streaming, but skipping bytes entirely. It requires Scope, Rpc.ToHandler, Rpc.Middleware, and Rpc.MiddlewareClient from the environment (i.e. provide the handler layer and any middleware layers, inside a scoped test). The flatten option matches RpcClient.makeNoSerialization for a flattened client.

import { Effect } from "effect"
import { RpcTest } from "effect/unstable/rpc"
import { UserRpcs, UserRpcsLayer } from "./handlers.ts"
const test = Effect.gen(function*() {
// Flattened in-memory client.
const client = yield* RpcTest.makeClient(UserRpcs, { flatten: true })
const user = yield* client("GetUser", { id: "1" })
// => { id: "1", name: "Ada" }
})
test.pipe(Effect.scoped, Effect.provide(UserRpcsLayer))