Skip to content

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")
) {}

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 a Schema.Struct for you, or pass a full schema directly. Defaults to Schema.Void (no payload).
  • success — the value the handler returns on success. Defaults to Schema.Void.
  • error — the typed failure schema, surfaced in the client’s error channel. Defaults to Schema.Never (the call cannot fail with a domain error). Use a Schema.Union of tagged errors for several failure modes.
  • stream — set true to make the success a stream of values rather than a single value (see below).

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
})
) {}

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.

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.