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, 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).
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. 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)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.
Reference
Section titled “Reference”Implementing handlers (RpcGroup)
Section titled “Implementing handlers (RpcGroup)”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).
group.toLayer
Section titled “group.toLayer”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>group.toHandlers
Section titled “group.toHandlers”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, ...>group.of
Section titled “group.of”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)group.toLayerHandler
Section titled “group.toLayerHandler”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)group.accessHandler
Section titled “group.accessHandler”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" }})Handler metadata (Rpc.ServerClient)
Section titled “Handler metadata (Rpc.ServerClient)”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.
Deferred responses
Section titled “Deferred responses”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 }) } }))Rpc.fork
Section titled “Rpc.fork”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 })})Rpc.uninterruptible
Section titled “Rpc.uninterruptible”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" })})Rpc.wrap
Section titled “Rpc.wrap”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) // => trueRpc.unwrap(result) // => Effect.succeed(42)Streaming handlers
Section titled “Streaming handlers”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 })})RpcServer entry points
Section titled “RpcServer entry points”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.
RpcServer.make
Section titled “RpcServer.make”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>RpcServer.layer
Section titled “RpcServer.layer”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 | ...>RpcServer.layerHttp
Section titled “RpcServer.layerHttp”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})RpcServer.makeNoSerialization
Section titled “RpcServer.makeNoSerialization”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>Server options
Section titled “Server options”make, layer, and layerHttp share these options (the toHttpEffect*
functions accept the same set except concurrency):
concurrency—number | "unbounded"(default"unbounded"); the max number of in-flight requests (forked handlers viaRpc.forkbypass it).disableFatalDefects— whentrue, 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" }})RpcClient surface
Section titled “RpcClient surface”RpcClient.make
Section titled “RpcClient.make”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>Per-call options
Section titled “Per-call options”Every generated method accepts an options object as its second argument:
headers— extra headers merged into the request (Headers.Input).context— aContextmade available to the payload/result codecs.discard— for non-streaming calls: fire-and-forget. The call resolves tovoidand ignores the result/error.asQueue— for streaming calls: return a scopedEffect<Queue.Dequeue<...>>instead of aStream.streamBufferSize— for streaming calls: the queue buffer size (default16).
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" }})RpcClient.makeNoSerialization
Section titled “RpcClient.makeNoSerialization”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" } )})RpcClient.Protocol
Section titled “RpcClient.Protocol”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.
Client types
Section titled “Client types”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 anRpcGroup(the convenient form to use in aContext.Serviceshape).
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))}Client errors (RpcClientError)
Section titled “Client errors (RpcClientError)”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.
Testing: RpcTest.makeClient
Section titled “Testing: RpcTest.makeClient”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))