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: Schema.Union([EmailTaken, UserNotFound])
}),
// 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 Schema.Union([...]) of tagged errors for several failure modes.
  • defect — the schema used for unexpected failures (bugs, thrown exceptions). Defaults to Schema.Defect. A custom defect schema must be a Rpc.DefectSchema: it must decode and encode without services (DecodingServices and EncodingServices are never).
  • stream — set true to make the success a stream of values rather than a single value (see below).
  • primaryKey — only valid when payload is struct fields. Provide a function (payload) => string and the payload becomes a Schema.Class with a derived PrimaryKey, enabling request deduplication and batching in runtimes that key requests (for example Effect Cluster).

When several callers issue the same logical request, a primary key lets the runtime collapse them into one. primaryKey turns the struct-field payload into a payload class whose PrimaryKey is derived from the value.

import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
// `primaryKey` is only allowed when `payload` is struct fields. The resulting
// payload is a Schema.Class with a PrimaryKey of `user:<id>`, so two concurrent
// `GetUser({ id: "1" })` calls can be deduplicated.
const GetUser = Rpc.make("GetUser", {
payload: { id: Schema.String },
success: Schema.Struct({ id: Schema.String, name: Schema.String }),
primaryKey: (payload) => `user:${payload.id}`
})

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

Under the hood stream: true rewrites the schemas: the element and stream-error schemas are stored inside an RpcSchema.Stream<success, error> marker on the success schema, and the RPC’s ordinary error schema becomes Schema.Never.

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.


The common case is Rpc.make + RpcGroup.make. This section enumerates the rest of the surface: the refinement methods on an Rpc, the Rpc.custom constructor factory, every RpcGroup method, and the helper types you reach for when writing client and handler code.

Each instance method on an Rpc returns a new definition with one piece changed; the original is untouched. Use them to build a definition up incrementally or to adjust a definition you imported.

Replaces the payload schema. Accepts either struct fields (wrapped into a Schema.Struct for you) or a full schema.

import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const GetUser = Rpc.make("GetUser").setPayload({ id: Schema.String })
// => payload schema is now Schema.Struct({ id: Schema.String })

Replaces the success schema.

import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const GetCount = Rpc.make("GetCount").setSuccess(Schema.Number)
// => success schema is now Schema.Number (was Schema.Void)

Replaces the error schema. Use Schema.Union([...]) for several failure modes.

import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {}) {}
const GetUser = Rpc.make("GetUser").setError(NotFound)
// => error schema is now NotFound (was Schema.Never)

Attaches an RpcMiddleware service to the procedure. Middleware can add to the error channel and require services; both are tracked in the type. See Client and server for defining and applying middleware.

import { Rpc } from "effect/unstable/rpc"
// declare const Authenticated: RpcMiddleware service tag
const GetUser = Rpc.make("GetUser")
// .middleware(Authenticated) // procedure now requires the middleware

Prepends a string to the tag: Tag becomes `${Prefix}${Tag}`.

import { Rpc } from "effect/unstable/rpc"
const GetUser = Rpc.make("GetUser").prefix("users.")
// => GetUser._tag is "users.GetUser"

Attaches a single annotation (a Context.Key and its value) to the procedure. Annotations are metadata read by higher-level runtimes; they do not affect the wire.

import { Context } from "effect"
import { Rpc } from "effect/unstable/rpc"
const Audit = Context.Reference<boolean>("Audit", { defaultValue: () => false })
const DeleteUser = Rpc.make("DeleteUser").annotate(Audit, true)
// => DeleteUser carries the Audit=true annotation

Merges a whole Context of annotations onto the procedure at once.

import { Context } from "effect"
import { Rpc } from "effect/unstable/rpc"
const Audit = Context.Reference<boolean>("Audit", { defaultValue: () => false })
const annotations = Context.make(Audit, true)
const DeleteUser = Rpc.make("DeleteUser").annotateMerge(annotations)
// => DeleteUser carries every annotation from `annotations`

Rpc.custom builds a reusable constructor that transforms the success and error schemas before the RPC is created. This lets you encode a cross-cutting shape — pagination, envelope wrappers, result metadata — once and apply it like Rpc.make. The constructor function receives the original { success, error, defect } schemas and returns the transformed schemas through out.

import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
// Create a custom Rpc wrapper definition by transforming the success and error
// schemas. The `out` type describes how the constructor reshapes them.
export interface RpcWithPagination extends Rpc.Custom {
readonly out: Rpc.Custom.Out<
Paginated<this["success"]>,
this["error"]
>
}
// The type definition for the transformed success schema.
export interface Paginated<S extends Schema.Top> extends
Schema.Struct<{
readonly offset: Schema.Number
readonly total: Schema.Number
readonly results: Schema.$Array<S>
}>
{}
// Implement the schema transformation with `Rpc.custom`. The value-level
// transform must match the type-level `out`.
export const makePaginated = Rpc.custom<RpcWithPagination>((schemas) => ({
...schemas,
success: Schema.Struct({
offset: Schema.Number,
total: Schema.Number,
results: Schema.Array(schemas.success)
})
}))
// Use the custom constructor exactly like `Rpc.make`. The declared `success`
// (here `Schema.String`) is the *element* type; the actual success schema
// becomes the paginated wrapper around it.
export const listAllRpc = makePaginated("listAll", {
success: Schema.String
})
// => listAllRpc success: { offset: number; total: number; results: string[] }

The type-level contract:

  • Rpc.Custom — the interface a custom definition extends. It exposes the incoming success, error, and defect schemas and the produced out.
  • Rpc.Custom.Out<Success, Error> — the transformed { success, error, defect } shape your out returns.
  • Rpc.Custom.OutDefaultOut<Schema.Top, Schema.Top>; the erased default output shape and the argument type of the transform function.
  • Rpc.Custom.Kind<Def, Success, Error> — applies a custom definition to concrete Success/Error schemas to compute the resulting out. This is how Rpc.custom derives the final success/error schemas at each call site.

RpcGroup.make(...rpcs) is the entry point. Every other method returns a new, immutable group whose type reflects the change.

Creates a group from one or more RPC definitions, keyed by their tags.

import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Group = RpcGroup.make(
Rpc.make("Ping"),
Rpc.make("Echo", { payload: { msg: Schema.String }, success: Schema.String })
)
// => RpcGroup with tags "Ping" and "Echo"

Appends one or more procedures, returning a wider group type.

import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Base = RpcGroup.make(Rpc.make("Ping"))
const Group = Base.add(Rpc.make("Pong", { success: Schema.String }))
// => tags "Ping" | "Pong"

Combines this group with one or more other groups. Annotations from the merged groups are carried over.

import { Rpc, RpcGroup } from "effect/unstable/rpc"
const A = RpcGroup.make(Rpc.make("A"))
const B = RpcGroup.make(Rpc.make("B"))
const AB = A.merge(B)
// => tags "A" | "B"

Removes procedures by tag. The resulting type narrows via Exclude, so the removed tags are no longer callable.

import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Group = RpcGroup.make(
Rpc.make("Public"),
Rpc.make("Internal")
)
const PublicOnly = Group.omit("Internal")
// => tags narrowed from "Public" | "Internal" to just "Public"

Adds a string prefix to every procedure tag in the group.

import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Group = RpcGroup.make(Rpc.make("Get"), Rpc.make("Set")).prefix("kv.")
// => tags "kv.Get" | "kv.Set"

Attaches middleware to all procedures added so far. Because it only touches the current members, call it after the procedures it should cover.

import { Rpc, RpcGroup } from "effect/unstable/rpc"
// declare const Authenticated: RpcMiddleware service tag
const Group = RpcGroup.make(Rpc.make("GetUser"))
// .middleware(Authenticated) // GetUser now requires the middleware

Annotates the group itself with a single Context.Key/value pair. Useful for runtime-level metadata (for example, the entity name for a cluster).

import { Context } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServiceName = Context.Reference<string>("ServiceName", {
defaultValue: () => "unknown"
})
const Group = RpcGroup.make(Rpc.make("Ping")).annotate(ServiceName, "users")
// => the group carries ServiceName="users"

Merges a whole Context of annotations onto the group.

import { Context } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServiceName = Context.Reference<string>("ServiceName", {
defaultValue: () => "unknown"
})
const Group = RpcGroup.make(Rpc.make("Ping")).annotateMerge(
Context.make(ServiceName, "users")
)
// => the group carries every annotation in the provided Context

Annotates each procedure added so far (not the group) with a single annotation. Like middleware, it only affects current members.

import { Context } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Tier = Context.Reference<string>("Tier", { defaultValue: () => "free" })
const Group = RpcGroup.make(Rpc.make("Ping"), Rpc.make("Pong")).annotateRpcs(
Tier,
"premium"
)
// => both Ping and Pong carry Tier="premium"

Merges a Context of annotations onto each procedure added so far.

import { Context } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const Tier = Context.Reference<string>("Tier", { defaultValue: () => "free" })
const Group = RpcGroup.make(Rpc.make("Ping")).annotateRpcsMerge(
Context.make(Tier, "premium")
)
// => Ping carries every annotation in the provided Context

These type-level helpers extract the derived TypeScript shapes from an Rpc. You reach for them when writing client calls or handler implementations by hand.

import type { Rpc } from "effect/unstable/rpc"
import { Rpc as R } from "effect/unstable/rpc"
import { Schema } from "effect"
const GetUser = R.make("GetUser", {
payload: { id: Schema.String },
success: Schema.Struct({ id: Schema.String, name: Schema.String })
})
type GetUser = typeof GetUser
type P = Rpc.Payload<GetUser> // => { readonly id: string }
type S = Rpc.Success<GetUser> // => { readonly id: string; readonly name: string }
type E = Rpc.Error<GetUser> // => never (no error schema set)
type X = Rpc.Exit<GetUser> // => Exit<{ id: string; name: string }, never>
type T = Rpc.Tag<GetUser> // => "GetUser"
  • Rpc.Payload<R> — the decoded payload type the handler receives.
  • Rpc.Success<R> — the decoded success value type.
  • Rpc.Error<R> — the decoded error type, including middleware errors.
  • Rpc.Exit<R> — the Exit produced for the RPC, using its exit success and exit error types (for streams, exit success is void).
  • Rpc.Tag<R> — the procedure’s tag string.
  • Rpc.ToHandler<R> — the Rpc.Handler type the RPC maps to (server side).
  • Rpc.ToHandlerFn<Current, R> — the function signature you implement for a handler: (payload, options) => result, where result matches the RPC’s success/error shape.

For groups, two helpers describe handler implementations:

  • RpcGroup.Rpcs<Group> — the union of Rpc definitions in a group.
  • RpcGroup.HandlersFrom<R> — the object type mapping each tag to its handler function (the shape toLayer/toHandlers expect).
  • RpcGroup.HandlerFrom<R, Tag> — the single handler function type for one tag.
import type { RpcGroup } from "effect/unstable/rpc"
import { Rpc, RpcGroup as G } from "effect/unstable/rpc"
import { Schema } from "effect"
const Group = G.make(
Rpc.make("GetCount", { success: Schema.Number })
)
type Group = typeof Group
type AllRpcs = RpcGroup.Rpcs<Group> // => the "GetCount" Rpc
type Handlers = RpcGroup.HandlersFrom<RpcGroup.Rpcs<Group>>
// => { readonly GetCount: (payload, options) => Effect<number, ...> | ... }

The streaming marker and its guard live in RpcSchema:

  • RpcSchema.Stream(success, error) — builds the stream schema marker that Rpc.make(..., { stream: true }) installs for you. It stores the element schema as success and the stream-error schema as error.
  • RpcSchema.isStreamSchema(schema) — returns true when a schema is a stream marker; protocol code branches on it to handle one-shot vs streaming responses.
import { Schema } from "effect"
import { RpcSchema } from "effect/unstable/rpc"
const elements = RpcSchema.Stream(Schema.String, Schema.Never)
RpcSchema.isStreamSchema(elements) // => true
RpcSchema.isStreamSchema(Schema.String) // => false