You are an Effect TypeScript expert specializing in defining shared RPC contracts with Rpc, RpcGroup, RpcSchema, and RpcMiddleware from effect/unstable/rpc.
This skill covers the contract layer: the definitions that client and server packages both import. Wiring handlers into a server is the effect-rpc-server skill; constructing clients and protocols is the effect-rpc-client skill; distributed entities are the effect-rpc-cluster skill.
Effect Source Reference
In rc.112 schema encoding is selected by the transport's codecFor, rather
than being fixed to canonical JSON for every protocol. Preserve the schemas and
their decoding/encoding requirements in shared contracts; custom protocol
implementations must supply codecFor (see effect-rpc-client /
effect-rpc-server). Existing JSON, NDJSON, JSON-RPC, and MsgPack formats keep
their wire representation; the new SchemaBinary transport has its own codecs.
The Effect v4 source is at ~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/. Read it directly when in doubt — these modules are under unstable and move between betas.
Key files:
packages/effect/src/unstable/rpc/Rpc.ts—Rpc.make,Rpc.custom, per-rpc combinators,exitSchema,Wrapper(fork/uninterruptible/wrap),ServerClient, every type helper (Payload,Success,Error,Exit,ToHandlerFn,ResultFrom, ...)packages/effect/src/unstable/rpc/RpcGroup.ts— group construction and composition (add/merge/omit/prefix/middleware), group vs per-rpc annotations, handler-conversion surface (toLayer/toHandlers/toLayerHandler/accessHandler/of)packages/effect/src/unstable/rpc/RpcSchema.ts— theStreamschema marker,isStreamSchema,ClientAbortcause annotationpackages/effect/src/unstable/rpc/RpcMiddleware.ts—Serviceconstructor,layerClient,ForClient,ApplyServicesand the middleware function shapespackages/effect/src/unstable/rpc/RpcMessage.ts— wire envelopes:Request,Ack,Interrupt,Eof,Ping,ResponseChunk,ResponseExit,ExitEncoded,RequestIdpackages/effect/src/unstable/rpc/RpcClientError.ts— the transport error type referenced when typing shared client aliasespackages/effect/src/unstable/rpc/index.ts— public exports of the rpc namespacepackages/platform/node/test/fixtures/rpc-schemas.ts— the best real-world contract fixture: rpcs, streaming, middleware, deferred responsespackages/effect/test/rpc/Rpc.test.ts—exitSchema, custom defect schemas,getStreamSchemassemantics
Core Model
An Rpc is a value-level contract for one procedure:
interface Rpc<
Tag extends string,
Payload extends Schema.Top = Schema.Void,
Success extends Schema.Top = Schema.Void,
Error extends Schema.Top = Schema.Never,
Middleware extends RpcMiddleware.AnyService = never,
Requires = never
> // _tag, payloadSchema, successSchema, errorSchema, defectSchema,
// annotations: Context.Context<never>, middlewares: ReadonlySet<Middleware>
It records a tag, four schemas (payload, success, error, defect), a set of middleware service keys, and a Context of annotations. An RpcGroup<R> is an immutable ReadonlyMap<tag, Rpc> plus group-level annotations. Neither does any I/O — servers interpret them into handlers, clients into methods, entities into mailboxes. Define them once in a shared module and import them everywhere.
Both Rpc and RpcGroup declare new (_: never): {}, so both const X = Rpc.make(...) and class X extends Rpc.make(...) {} are valid (same for groups). All combinators return new values — these structures are immutable.
Imports used throughout (all from the effect package; there is no @effect/rpc in v4):
import { Context, Schema } from 'effect';
import { Rpc, RpcGroup, RpcMiddleware, RpcSchema } from 'effect/unstable/rpc';
Deep subpath imports also work (the package exports a ./* wildcard), e.g. import * as RpcSchema from 'effect/unstable/rpc/RpcSchema' or import { RpcClientError } from 'effect/unstable/rpc/RpcClientError'.
1. Defining RPCs — Rpc.make
Rpc.make(tag, {
payload?: Schema.Top | Schema.Struct.Fields, // default Schema.Void
success?: Schema.Top, // default Schema.Void
error?: Schema.Top, // default Schema.Never
defect?: Rpc.DefectSchema, // default Schema.Defect()
stream?: boolean, // default false
primaryKey?: (payload) => string // only with struct-fields payload
});
Two declaration styles, both official:
import { Schema } from 'effect';
import { Rpc } from 'effect/unstable/rpc';
export class User extends Schema.Class<User>('User')({
id: Schema.String,
name: Schema.String
}) {}
export class UserNotFound extends Schema.TaggedError<UserNotFound>()('UserNotFound', {
id: Schema.String
}) {}
// Style A — const value. Compact; good when listing many rpcs in one file.
export const Ping = Rpc.make('Ping', { success: Schema.String });
// Style B — class extends. Nominal identity; good for rpcs imported widely.
export class GetUser extends Rpc.make('GetUser', {
payload: { id: Schema.String },
success: User,
error: UserNotFound
}) {}
With class style, the class itself is the rpc value (RpcGroup.make(GetUser, ...)) and typeof GetUser works with all Rpc.* type helpers.
Payload: struct fields vs schema
// Inline fields — Rpc.make builds Schema.Struct({ ... }) for you
Rpc.make('CreateUser', {
payload: { name: Schema.String, email: Schema.String },
success: User
});
// Named schema — reuse a Schema.Class (or any Schema.Top)
class CreateUserInput extends Schema.Class<CreateUserInput>('CreateUserInput')({
name: Schema.String,
email: Schema.String
}) {}
Rpc.make('CreateUser', { payload: CreateUserInput, success: User });
With the default Schema.Void payload, the generated client method takes no payload argument (client.Ping()).
defect — controlling defect serialization
Defects (Effect.die) cross the wire through defectSchema. The default Schema.Defect() round-trips defects as unknown, JSON-encoding Error values as { name, message, cause? } and stripping stack traces for security. Options:
// Keep stack traces across the wire
Rpc.make('Risky', { defect: Schema.Defect({ includeStack: true }) });
// Also available: { excludeCause: true } to drop Error.cause
// Or any schema satisfying Rpc.DefectSchema (decodes/encodes with no services):
Rpc.make('RiskyRaw', { defect: Schema.Any });
Note Schema.Defect() normalizes: a non-Error object like { message: 'boom' } decodes back as an Error; non-JSON values fall back to a formatted string.
primaryKey — deterministic payload identity
primaryKey is only available when payload is given as struct fields (the option is typed never for schema payloads). It makes Rpc.make build a Schema.Class for the payload that implements PrimaryKey.symbol, giving each payload value a deterministic string identity. The cluster layer uses it to dedupe retried sends of persisted messages:
export const Charge = Rpc.make('Charge', {
payload: { invoiceId: Schema.String, amountCents: Schema.Int },
success: Schema.Boolean,
primaryKey: ({ invoiceId }) => invoiceId
});
Clients still pass plain objects; the class is an implementation detail of the payload schema. See the effect-rpc-cluster skill for persistence semantics.
2. Streaming Contracts — stream: true and RpcSchema.Stream
stream: true changes how the success and error options are interpreted:
successSchemabecomesRpcSchema.Stream(success, error)—successis the element schema,erroris the stream error schemaerrorSchemais set toSchema.Never(the declarederrormoved into the stream)
export class StreamUsers extends Rpc.make('StreamUsers', {
payload: { since: Schema.DateTimeUtc },
success: User, // element schema, not the Effect success
error: UserNotFound, // stream error schema
stream: true
}) {}
Consequences for both sides of the contract:
- the handler must return
Stream<User, UserNotFound, R>— orEffect<Queue.Dequeue<User, UserNotFound | Cause.Done>, ..., R>to drive the queue itself - the client sees
Stream<User, UserNotFound | RpcClientError>(or aQueue.Dequeuewith{ asQueue: true }— see theeffect-rpc-clientskill) - the rpc's terminal
Exitsuccess isvoid; elements travel separately asChunkmessages
RpcSchema.Stream is itself a schema, so Rpc.make('X', { success: RpcSchema.Stream(User, UserNotFound) }) is equivalent — stream: true is just the ergonomic spelling. Introspect with:
RpcSchema.isStreamSchema(StreamUsers.successSchema); // true
// streamSchema.success / streamSchema.error hold the element/error schemas
RpcSchema.ClientAbort
ClientAbort is a Cause annotation marking interrupts that originated from a client abort: when a client interrupts a call, the server interrupts the handler fiber with it attached to the Interrupt reason. Servers distinguish client cancellation from shutdown by checking for RpcSchema.ClientAbort.key on Interrupt reasons — the handler-side detection snippet lives in the effect-rpc-server skill.
3. Per-RPC Combinators
Every Rpc is Pipeable and exposes instance methods, each returning a new rpc:
Rpc.make('GetUser', { payload: { id: Schema.String }, success: User })
.setSuccess(Schema.Option(User)) // swap success schema
.setError(UserNotFound) // swap error schema
.setPayload({ id: Schema.String, tenant: Schema.String }) // fields or Schema.Top
.middleware(AuthMiddleware) // attach a middleware service key
.prefix('users.') // tag becomes 'users.GetUser'
.annotate(SomeKey, value) // add one annotation
.annotateMerge(someContext); // merge a Context.Context<I>
Notes:
prefixchanges_tag, which is the wire identity of the procedure (and the key handlers are registered under). Prefix at definition time, before anything depends on the tag.annotate/annotateMergeattach metadata read by servers, clusters, and proxies (e.g.ClusterSchema.Persisted). Annotations are an openContext, so anyContext.Keyworks — including your own.middlewareaccumulates into aReadonlySet; attaching also threads the middleware'sprovides/requiresthrough the rpc'sRequirestype parameter (section 6).
4. Composing Contracts — RpcGroup
RpcGroup.make(...rpcs) is variadic:
export const UsersGroup = RpcGroup.make(GetUser, CreateUser, StreamUsers);
The rpcs are reachable at group.requests (ReadonlyMap<string, Rpc>) — useful for contract introspection tests and codegen; Rpc.isRpc(u) guards individual values.
Combinators
UsersGroup
.add(DeleteUser, UpdateUser) // append rpcs (same-tag add replaces)
.merge(OrdersGroup, PaymentsGroup) // union of groups
.omit('DeleteUser', 'UpdateUser') // remove by tag (variadic)
.prefix('v2.') // re-tag every rpc
.middleware(AuthMiddleware); // attach to every rpc currently in the group
Sharp edges, verified in source:
mergeis last-wins on duplicate rpc tags and duplicate group-annotation keys. No error, silent override.middlewareand theannotateRpcs*methods only affect rpcs already in the group. Rpcs added afterwards via.add(...)are not covered — re-apply, or attach on the rpc itself before adding.omitremoves the rpc from the group but does not return it; keep the original rpc value if you need it elsewhere.
Group-level vs per-rpc annotations
group.annotate(SomeKey, value); // on the group itself
group.annotateMerge(context); // merge Context into the group
group.annotateRpcs(SomeKey, value); // on every rpc currently in the group
group.annotateRpcsMerge(context); // merge Context into every rpc
annotateRpcs* does not override an annotation already set on an individual rpc — the implementation merges as Context.merge(context, rpc.annotations), so the rpc's own value wins. Use this deliberately: set per-rpc exceptions first, then apply the group default.
Handler-conversion surface (pointer)
RpcGroup also carries the methods that turn a contract into a server implementation — toLayer(handlers | Effect<handlers>), toLayerHandler(tag, handler), toHandlers(handlers), accessHandler(tag), and the identity type-checker of(handlers). Their option signatures and wiring belong to the effect-rpc-server skill; what matters at the contract level is that the handler shape is fully derived from the rpc definitions (section 8), so a contract change is a compile error in every handler and client.
5. Errors, Defects, and the Exit Schema
An rpc's effective error union is derived, not declared in one place:
Rpc.Error<R> = declared error schema | every attached middleware's error schema
On top of that, clients add transport errors (RpcClientError) and any middleware clientError types — see the effect-rpc-client skill. Never re-declare a middleware's error in the rpc's error option; attaching the middleware adds it automatically.
Use schema error classes so errors are tagged, yieldable, and serializable:
export class RateLimited extends Schema.TaggedError<RateLimited>()('RateLimited', {
retryAfterMillis: Schema.Number
}) {}
export class OrderError extends Schema.TaggedError<OrderError>()('OrderError', {
reason: Schema.Literals(['empty-cart', 'payment-failed'])
}) {}
const PlaceOrder = Rpc.make('PlaceOrder', {
payload: { cartId: Schema.String },
success: Schema.String,
error: Schema.Union([OrderError, RateLimited]) // unions take an array in v4
});
Rpc.exitSchema
Rpc.exitSchema(rpc) builds the Schema.Exit that servers use to encode results and clients use to decode them:
- success side: the success schema — or
Schema.Voidfor streaming rpcs - failure side:
Schema.Union([...])of the rpc error schema, the stream error schema (if streaming), and every middleware error schema - defect side: the rpc's
defectSchema
The result is cached per rpc value (WeakMap). Useful for serialization tests and generic envelope tooling:
import { Exit } from 'effect';
const schema = Rpc.exitSchema(PlaceOrder);
const encoded = Schema.encodeSync(schema)(Exit.fail(new RateLimited({ retryAfterMillis: 100 })));
const roundTripped = Schema.decodeSync(schema)(encoded);
6. Middleware Definition — RpcMiddleware.Service
A middleware is declared as a Context.Service class with two parameter slots:
import { Context, Schema } from 'effect';
import { RpcMiddleware } from 'effect/unstable/rpc';
export class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
export class Unauthorized extends Schema.TaggedError<Unauthorized>()('Unauthorized', {}) {}
export class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
provides: CurrentUser; // services injected into wrapped handlers
requires: never; // services this middleware needs from the outer context
clientError: never; // error type only the client-side wrapper can produce
}>()('AuthMiddleware', {
error: Unauthorized, // wire-error schema; default Schema.Never
requiredForClient: true // default false
}) {}
Exact shape, verified in source:
- The config type parameter carries
{ requires?, provides?, clientError? }— all optional, all defaulting tonever. A bareRpcMiddleware.Service<TimingMiddleware>()('TimingMiddleware')is a pure observer. - The options object has exactly two keys:
error(a schema; becomes part of the rpc error union, section 5) andrequiredForClient(boolean). There is nooptional,wrap,failure, orprovidesoption key — those were v3 spellings. - The class statics expose
.errorand.requiredForClient, and the class itself is theContext.Keyyou attach withrpc.middleware(AuthMiddleware)/group.middleware(AuthMiddleware).
What attaching does to types
rpc.middleware(M) computes Requires_new = Exclude<Requires_old, Provides<M>> | Requires<M> (RpcMiddleware.ApplyServices). So:
- handlers for the rpc receive
Provides<M>in their environment for free (CurrentUserabove) - a middleware whose
requiresnames a service is satisfied by attaching a provider middleware after it — unfulfilledrequiressurface as extra requirements on the handlers layer requiredForClient: trueaddsRpcMiddleware.ForClient<M>to the client's required context, making a missing client implementation a compile-time error. Without it, clients silently skip middleware that has no client layer provided.
The server-side function shape (for reference)
The service value is a function (effect, options) => Effect — options: { client, requestId, rpc, payload, headers } (rpc is typed Rpc.AnyWithProps). It wraps handler execution: it cannot change the success value (opaque SuccessValue), but it can provide services, fail with the wire error, and observe the exit. Execution order: middlewares apply in attachment order with the last attached outermost, on both server and client. The Layer implementation (Layer.succeed(AuthMiddleware)(AuthMiddleware.of(...))) belongs to the effect-rpc-server skill.
The client-side counterpart — RpcMiddleware.layerClient
Defined alongside the contract so client packages can provide it. The function receives { rpc, request, next } and must call next (with the original or a modified request):
import { Headers } from 'effect/unstable/http';
export const AuthClient = RpcMiddleware.layerClient(AuthMiddleware, ({ next, request }) =>
next({
...request,
headers: Headers.set(request.headers, 'authorization', `Bearer ${getToken()}`)
})
);
// Layer<RpcMiddleware.ForClient<AuthMiddleware>>
layerClient also accepts an Effect that builds the function (for middleware needing services); the layer's environment is captured and merged into every invocation. Failures here surface as the middleware's error type or the clientError type from the config.
7. Custom Constructors — Rpc.custom
Rpc.custom builds an Rpc.make-alike that transforms every rpc's success/error schemas — encode a convention once (paginated lists, response envelopes) instead of repeating it per rpc:
import { Schema } from 'effect';
import { Rpc } from 'effect/unstable/rpc';
// Type-level definition: how success/error transform
export interface RpcWithPagination extends Rpc.Custom {
readonly out: Rpc.Custom.Out<Paginated<this['success']>, this['error']>;
}
export interface Paginated<S extends Schema.Top> extends
Schema.Struct<{
readonly offset: Schema.Number;
readonly total: Schema.Number;
readonly results: Schema.$Array<S>;
}>
{}
// Value-level implementation: receives { success, error, defect }, returns the transformed set
export const makePaginated = Rpc.custom<RpcWithPagination>((schemas) => ({
...schemas,
success: Schema.Struct({
offset: Schema.Number,
total: Schema.Number,
results: Schema.Array(schemas.success)
})
}));
// Used exactly like Rpc.make — payload, stream, primaryKey all still work
export const ListUsers = makePaginated('listUsers', { success: User });
// success type: { offset: number, total: number, results: readonly User[] }
The transformation applies to success/error/defect only; payload, stream, and primaryKey pass through unchanged. With stream: true, the transformed success becomes the stream element schema.
8. The Handler Contract and Wrappers
The contract fully determines the handler signature (Rpc.ToHandlerFn):
(
payload: Rpc.Payload<R>,
options: {
readonly client: Rpc.ServerClient; // client.id: number; client.annotate(key, value)
readonly requestId: RequestId;
readonly headers: Headers;
readonly rpc: R;
}
) => Rpc.WrapperOr<Rpc.ResultFrom<R, Services>>
Rpc.ResultFrom — what a handler may return:
- non-stream rpc:
Effect<Success | Deferred<Success, Error>, Error, R>. Succeeding with aDeferreddefers the terminal exit until the deferred completes — invisible on the wire, useful when the result arrives from another fiber/webhook later. - stream rpc:
Stream<Element, StreamError, R>orEffect<Queue.Dequeue<Element, StreamError | Cause.Done>, ..., R>(end-of-stream isCause.Donein the queue's error channel).
Scope is always available to handlers (the server scopes each request), and services in any attached middleware's provides are excluded from the handler's requirements.
Wrappers — Rpc.fork, Rpc.uninterruptible, Rpc.wrap
Execution-mode hints are wrappers around the handler's return value, not Rpc.make options:
GetCount: () => Ref.get(count).pipe(Rpc.fork); // bypass server concurrency limit
Charge: (payload) => chargeOnce(payload).pipe(Rpc.uninterruptible); // must complete
Both: (payload) => work(payload).pipe(Rpc.wrap({ fork: true, uninterruptible: true }));
wrap on an already-wrapped value inherits unspecified options from the existing wrapper. Introspection helpers: Rpc.isWrapper(u), Rpc.unwrap(value), Rpc.wrapMap(value, f) (maps the inner value, preserving options).
9. Wire-Format Awareness — RpcMessage
You rarely touch RpcMessage directly, but the envelope explains several contract-level facts:
| Direction | Decoded messages | Purpose |
|---|---|---|
| client → server | decoded Request, Ack, Interrupt, Eof; encoded RequestEncoded, AckEncoded, InterruptEncoded, Ping, Eof |
call, stream backpressure ack, cancellation, end-of-input, keepalive |
| server → client | decoded ResponseChunk, ResponseExit, ResponseDefect, ClientEnd; encoded ResponseChunkEncoded, ResponseExitEncoded, ResponseDefectEncoded, Pong, ClientProtocolError, RequestEncoded |
stream elements, terminal exit, defects, lifecycle, and server-originated requests/notifications |
Key facts:
- A
RequestEncodedcarries{ _tag: 'Request', id: string | number, tag: string, payload: unknown, headers: Array<[string, string]>, isNotification?, traceId?, spanId?, sampled? }. The rpc's_tagis the wire identity — renaming or re-prefixing an rpc is a breaking protocol change for deployed clients. RequestIdis a brandedstring | number; construct it withRequestId(1)orRequestId('1')(fromeffect/unstable/rpc/RpcMessage). You need it when invoking handlers manually in tests viaaccessHandler.bigintis not accepted.- Terminal results travel as
ExitEncoded—Successwith a value, orFailurewith a cause array ofFail(your error union, encoded),Die(viadefectSchema), andInterruptentries. This is exactly whatRpc.exitSchemaencodes/decodes. - Stream elements travel as batched
Chunkmessages, acknowledged byAckon ack-capable protocols (sockets, workers); the HTTP protocol declaressupportsAck: false. Incremental delivery requires a serialization with framing (e.g. ndjson) — with non-framed json over HTTP the chunks are buffered and returned in one final batch (see serialization notes in theeffect-rpc-clusterskill). - Servers represent server-originated calls and notifications with
RequestEncodedinFromServerEncoded. SetisNotification: truefor a notification; JSON-RPC serialization then omitsid. Buffered, unframed JSON-RPC HTTP cannot deliver notifications and drops them, while framed HTTP, sockets, stdio, and workers support them.
10. Type Helpers for Shared Contract Packages
Rpc.* type extractors (all take the rpc type, e.g. typeof GetUser):
Rpc.Tag<R>; // 'GetUser'
Rpc.Payload<R>; // decoded payload type
Rpc.PayloadConstructor<R>; // input accepted by payload construction
Rpc.Success<R>; // decoded success (Stream<...> for streaming rpcs)
Rpc.SuccessEncoded<R>; // encoded success
Rpc.SuccessChunk<R>; // stream element type (never for non-stream)
Rpc.Error<R>; // declared error | middleware errors (decoded)
Rpc.Exit<R>; // Exit<SuccessExit, ErrorExit> — stream rpcs have void success
Rpc.Middleware<R>; // middleware service identifiers
Rpc.MiddlewareClient<R>; // ForClient<...> for requiredForClient middleware
Rpc.Services<R>; // schema en/decoding services (both sides)
Rpc.ServicesClient<R>; // client-side schema services
Rpc.ServicesServer<R>; // server-side schema services
Rpc.ExtractTag<R, 'GetUser'>; // select one rpc from a union
Rpc.ToHandler<R>; // the Handler service type servers require
For generic utilities, constrain on Rpc.Any (tag + annotations only) or Rpc.AnyWithProps (all schemas/middleware visible — the type of options.rpc in middleware); groups use RpcGroup.Any. Niche helpers (SuccessSchema/ErrorSchema, SuccessExit/ErrorExit, IsStream, Prefixed, AddError, AddMiddleware, ExtractProvides/ExtractRequires/ExcludeProvides) also live in Rpc.ts — read the source when writing generic tooling.
RpcGroup.* helpers:
RpcGroup.Rpcs<typeof UsersGroup>; // union of the group's rpc definitions
RpcGroup.HandlersFrom<R>; // { [tag]: handler fn } object type
RpcGroup.HandlerFrom<R, Tag>; // one handler fn type
The canonical use — typing a client service in the shared package without constructing anything:
import type { RpcClient } from 'effect/unstable/rpc';
import type { RpcClientError } from 'effect/unstable/rpc/RpcClientError';
export type UsersClient = RpcClient.RpcClient<
RpcGroup.Rpcs<typeof UsersGroup>,
RpcClientError
>;
Key Patterns
A complete shared contract module
Everything client and server need, with zero runtime wiring:
// contracts/users.ts — imported by both server and client packages
import { Context, Schema } from 'effect';
import { Rpc, RpcGroup, RpcMiddleware } from 'effect/unstable/rpc';
// --- domain schemas ---
export class User extends Schema.Class<User>('User')({
id: Schema.String,
name: Schema.String,
createdAt: Schema.DateTimeUtc
}) {}
// --- errors ---
export class UserNotFound extends Schema.TaggedError<UserNotFound>()('UserNotFound', {
id: Schema.String
}) {}
export class Unauthorized extends Schema.TaggedError<Unauthorized>()('Unauthorized', {}) {}
// --- middleware contract ---
export class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
export class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
provides: CurrentUser;
}>()('AuthMiddleware', {
error: Unauthorized,
requiredForClient: true
}) {}
// --- procedures ---
export class GetUser extends Rpc.make('GetUser', {
payload: { id: Schema.String },
success: User,
error: UserNotFound
}) {}
export class CreateUser extends Rpc.make('CreateUser', {
payload: { name: Schema.String },
success: User,
primaryKey: ({ name }) => name // idempotent under cluster persistence
}) {}
export class WatchUsers extends Rpc.make('WatchUsers', {
payload: { since: Schema.DateTimeUtc },
success: User, // stream element
error: UserNotFound, // stream error
stream: true
}) {}
// Public group: everything requires auth
export const UsersGroup = RpcGroup.make(GetUser, CreateUser, WatchUsers)
.middleware(AuthMiddleware);
The server package implements UsersGroup.toLayer(...) and a Layer.succeed(AuthMiddleware)(...) (see effect-rpc-server); the client package builds RpcClient.make(UsersGroup) plus RpcMiddleware.layerClient(AuthMiddleware, ...) (see effect-rpc-client).
Versioning and namespacing with prefix + merge
const V1 = RpcGroup.make(GetUser, CreateUser).prefix('v1.');
const V2 = RpcGroup.make(GetUserV2, CreateUser, WatchUsers).prefix('v2.');
// One group served at one endpoint; tags are 'v1.GetUser', 'v2.GetUser', ...
export const ApiGroup = V1.merge(V2);
Handler objects key by the prefixed tag (quoted keys: 'v1.GetUser': (payload) => ...). Remember merge is last-wins on tag collisions — prefix before merging to make collisions impossible.
Trimming a group for a restricted surface
// Internal group has admin procedures; public surface omits them
export const AdminGroup = RpcGroup.make(GetUser, CreateUser, DeleteUser, PurgeAll);
export const PublicGroup = AdminGroup.omit('DeleteUser', 'PurgeAll').middleware(AuthMiddleware);
Marking a group for the cluster
Contracts double as entity protocols — annotate, then hand to Entity.fromRpcGroup:
import { ClusterSchema } from 'effect/unstable/cluster';
export const DurableUsers = UsersGroup.annotateRpcs(ClusterSchema.Persisted, true);
// Entity.fromRpcGroup('Users', DurableUsers) — see the effect-rpc-cluster skill
Exit-schema round-trip test for a contract
import { assert, it } from '@effect/vitest';
import { Exit, Schema } from 'effect';
import { Rpc } from 'effect/unstable/rpc';
it('GetUser exits round-trip', () => {
const schema = Rpc.exitSchema(GetUser);
const exit = Exit.fail(new UserNotFound({ id: 'u1' }));
const decoded = Schema.decodeSync(schema)(Schema.encodeSync(schema)(exit));
assert(Exit.isFailure(decoded));
});
Common Mistakes
- Importing from
@effect/rpc. The package does not exist in v4 — everything iseffect/unstable/rpc(deep subpaths likeeffect/unstable/rpc/RpcMessagealso work). - Reaching for
Rpc.fromTaggedRequest/Schema.TaggedRequest. Neither exists in v4. The tag isRpc.make's first argument; the payload schema carries no_tagfield — the wire envelope transports the tag separately. - Expecting
errorto stay the Effect error withstream: true. It becomes the stream error schema and the rpc'serrorSchemais set toSchema.Never. Likewisesuccessbecomes the element schema. - Passing
primaryKeywith a schema payload. It is typedneverunlesspayloadis inline struct fields — wrap the fields inline or dropprimaryKey. - Using v3 middleware option keys.
RpcMiddleware.Serviceoptions are exactly{ error?, requiredForClient? };provides/requires/clientErrorgo in the second type parameter, andoptional/wrap/failuredo not exist (v4 middleware always wraps). - Re-declaring a middleware's error in the rpc's
erroroption. Attaching the middleware already unions itserrorschema intoRpc.ErrorandexitSchema— declaring it twice bloats the wire union. - Assuming
group.middleware(...)/annotateRpcs(...)cover later additions. They snapshot the rpcs currently in the group; rpcs added afterwards via.add(...)are unaffected. - Expecting
annotateRpcsto override per-rpc annotations. Per-rpc values win (Context.merge(context, rpc.annotations)); the group call only fills in missing keys. - Relying on
mergeto detect tag collisions. It is silent last-wins for both rpc tags and group annotation keys —prefixbefore merging. - Treating
Rpc.fork/Rpc.uninterruptibleasRpc.makeoptions. They are wrappers applied to a handler's return value:effect.pipe(Rpc.fork). - Mutating in place.
annotate,prefix,middleware,setSuccess, etc. all return newRpc/RpcGroupvalues; discarding the return value is a no-op. - Renaming or re-prefixing rpcs after deployment.
_tagis the wire identity; old clients will send tags the server no longer knows. Treat tag changes like breaking schema changes. - Passing a union to
Schema.Unionvariadically. v4 takes an array:Schema.Union([OrderError, RateLimited]), notSchema.Union(OrderError, RateLimited). - Declaring errors as plain
Schema.Structs. UseSchema.TaggedError(orSchema.Errorwith aSchema.tagfield) so errors are yieldable,catchTag-able, and carry a stable_tagon the wire. The formerSchema.TaggedErrorClass/Schema.ErrorClassnames were removed in beta.104. - Expecting stack traces in remote defects. The default
Schema.Defect()strips stacks; opt in per rpc withdefect: Schema.Defect({ includeStack: true }).