Defining RPCs
Every RPC starts as a definition: a tag that names the procedure plus the
schemas that describe the values crossing the wire. Rpc.make produces one such
definition, and RpcGroup.make collects definitions into a group that clients
and servers interpret. Because the definition is plain data, you can share it
between packages — the backend imports it to implement handlers, the frontend
imports the very same group to call them.
import { Schema } from "effect"import { Rpc, RpcGroup } from "effect/unstable/rpc"
// Typed, schema-encoded errors. Modeling failures as data (rather than thrown// exceptions) means they survive the trip across the wire and show up in the// client's error channel.class UserNotFound extends Schema.TaggedErrorClass<UserNotFound>()( "UserNotFound", { id: Schema.String }) {}
class EmailTaken extends Schema.TaggedErrorClass<EmailTaken>()("EmailTaken", { email: Schema.String}) {}
// A reusable success shape for a user record.const User = Schema.Struct({ id: Schema.String, name: Schema.String, email: Schema.String})
// `RpcGroup.make` collects procedures under their tags. Extending a class gives// the group a stable identity you can import and reference everywhere.export class UserRpcs extends RpcGroup.make( // A request/response procedure: payload in, `User` out, `UserNotFound` on // failure. Rpc.make("GetUser", { payload: { id: Schema.String }, success: User, error: UserNotFound }),
// Multiple fields in the payload become a `Schema.Struct` automatically. // This RPC can fail in two different ways; both are tracked in the type. Rpc.make("CreateUser", { payload: { name: Schema.String, email: Schema.String }, success: User, error: EmailTaken }),
// A procedure with no payload and no result. `success` defaults to // `Schema.Void` and `error` defaults to `Schema.Never`, so this one can't // fail in the RPC error channel. Rpc.make("Ping")) {}How Rpc.make reads its options
Section titled “How Rpc.make reads its options”Rpc.make(tag, options) takes a string tag and an optional object. Each field
has a sensible default, so you only specify what a given procedure needs:
payload— what the client sends. Pass plain struct fields ({ id: Schema.String }) and they are wrapped in aSchema.Structfor you, or pass a full schema directly. Defaults toSchema.Void(no payload).success— the value the handler returns on success. Defaults toSchema.Void.error— the typed failure schema, surfaced in the client’s error channel. Defaults toSchema.Never(the call cannot fail with a domain error). Use aSchema.Unionof tagged errors for several failure modes.stream— settrueto make the success a stream of values rather than a single value (see below).
Streaming RPCs
Section titled “Streaming RPCs”Set stream: true and the success and error schemas describe the elements
of a stream rather than a single response. The client receives a
Stream, and the server handler returns one. This is ideal for
log tailing, progress events, or any result that arrives incrementally.
import { Schema } from "effect"import { Rpc, RpcGroup } from "effect/unstable/rpc"
class StreamClosed extends Schema.TaggedErrorClass<StreamClosed>()( "StreamClosed", {}) {}
export class EventRpcs extends RpcGroup.make( // `success` is the type of each emitted element; `error` is the type a failing // stream terminates with. The client will see a `Stream<string, StreamClosed>`. Rpc.make("Subscribe", { payload: { channel: Schema.String }, success: Schema.String, error: StreamClosed, stream: true })) {}Composing and refining groups
Section titled “Composing and refining groups”Groups are immutable; every transformation returns a new group whose type tracks the change. This lets you split a large protocol into feature groups and merge them, or apply cross-cutting changes to the procedures defined so far.
import { Schema } from "effect"import { Rpc, RpcGroup } from "effect/unstable/rpc"
class Account extends RpcGroup.make( Rpc.make("GetBalance", { success: Schema.Number })) {}
class Billing extends RpcGroup.make( Rpc.make("Charge", { payload: { cents: Schema.Number } })) {}
// `merge` combines protocols; the resulting group exposes every procedure from// both, fully typed.export class Api extends Account.merge(Billing) {}
// You can also refine a single definition before adding it. The instance methods// return a new, more specific `Rpc`.const GetBalance = Rpc.make("GetBalance") .setSuccess(Schema.Number) // narrow the success schema .prefix("account.") // tag becomes "account.GetBalance"Other group methods follow the same pattern: add appends procedures, omit
removes them by tag, prefix namespaces every tag, and middleware /
annotateRpcs attach behavior or metadata to the procedures added so far.
Composition order matters — middleware and annotateRpcs affect only the RPCs
already in the group, and a duplicate tag from add or merge replaces the
earlier definition.
What the group does not do
Section titled “What the group does not do”A group records the protocol; it does not provide an implementation or a transport. The schemas still carry whatever services they need to encode and decode, but grouping alone never satisfies those requirements. To actually run the procedures you implement handlers and pick a transport, which is the subject of Client and server.