Skip to content
Esc
navigateopen⌘Jpreview
Typed RPC and failure ownership for React

Errors are values.
Values have owners.

Errors accumulate along the call path and discharge along the component tree. Every operation has one closed, wire-safe failure union; React shells claim failures owned above a component, leaving that component with only the cases it must handle.

Built on better-result — the errors-as-values Result runtime — with one rule added: only declared, serializable tagged errors cross the wire.

// the complete operation union
const query = useResultQuery(client.doc.byId, { id })
// DocNotFound | Unauthorized | ServerInternal | Offline |
// NetworkFailure | Timeout | HttpFailure |
// ProtocolViolation | DecodeFailure | Stale

// enclosing shells subtract the failures they own
const query = AuthShell.useQuery(client.doc.byId, { id })
// DocNotFound — and a missing case fails to compile
The full union remains on the operation. This component sees a narrower union because mounted shells own the other tags.

Every failure your mutation can produce has an owner above it — except the errors you actually want to worry about.

server · the procedure

export const rename = server  .procedure()  .use(requireViewer)        // + auth/session-expired  .input(wire.object({ id: wire.string, title: wire.string }))  .output(DocView)  .errors({ TitleTaken })           // + doc/title-taken  .mutation(({ input, ctx }) =>    docs.rename(ctx.viewer, input.id, input.title),  );

client · the component

function RenameDoc({ id }: { id: string }) {  const rename = AuthShell.useMutation(client.doc.rename);   return (    <TitleField      pending={rename.state === "pending"}      error={rename.error}      onSubmit={(title) => rename.mutate({ id, title })}    />  );}
rename.error2
Result<DocTitleTakenSessionExpired>
Q3 planning notes

One procedure. One union.

The union grows as the procedure is declared. `.use(requireViewer)` contributes its failure to every call — the handler was never edited.

How an operation stays typed end to end.

01 · Accumulation and discharge

Failure ownership follows the component tree.

Expected failures are part of the contract. Unexpected exceptions are not. Procedures return anticipated failures as tagged values. Unexpected server exceptions are reported through server-only observability with their private cause and exposed to the client only as a sanitized server/internal failure.

Procedure and middleware failures accumulate with server and client boundary failures to form the operation's closed union.

A shell claims tags at a position in the React tree. Shells form an explicit parent chain, so a derived shell carries every claim made above it. Its hooks subtract those tags, leaving the component with only its remaining failures; unclaimed failures stay in the union and must be handled.

How shells claim failures
// SERVER — the layer proves write access
const rename = server.procedure()
  .use(requireWriteAccess)
  // context.writer + WriteAccessRequired
  .errors({ DocLocked })
  .mutation(({ context, input }) =>
    context.docs.rename(input, context.writer))

// REACT — the shell owns the access failure
const rename = WriteAccessShell.useMutation(
  client.doc.rename,
)
// this component handles DocLocked

02 · The wire contract

Values are validated at the wire.

Codecs validate inputs, outputs, and error data on both sides. They also encode Date, Map, Set, BigInt, cycles, and repeated references without changing the application type.

Models add identity where it is useful. A model projection can sit inside a one-off output shape without a global graph schema or selection set, and the client can patch matching model instances by key.

Rich types on the wire
const search = server.procedure()
  .output(wire.object({
    hotel:  Hotel.pick("id", "name"), // entity — patches by id
    openAt: wire.date,                // a real Date, not a string
    from:   wire.number,              // a one-off aggregate
  }))
  .query(handler)

// client receives: { hotel, openAt: Date, from }
// clean types. no codegen. no selection set.

03 · The client boundary

The contract exists at runtime.

The client consumes a runtime contract containing codecs, error definitions, and policies. That runtime value is what validates and reconstructs rich values and tagged errors after they cross the wire.

Shared contract modules remain free of server implementation code. The browser client imports that contract; handlers, database drivers, and environment access stay behind the server entry.

The client boundary rule
// ✅ build the client from the CONTRACT —
//    a runtime of codecs, and nothing else
import { appContract } from "./contract"
export const client = createBrowserClient({
  contract: appContract, transport,
})

// Avoid importing the ROUTER into browser code:
// it retains handlers and their server imports.

04 · Client freshness

Mutation effects are declared once.

Returning a modeled entity patches matching cached instances by key. A mutation can use .affects() to declare query memberships that may change, while a handler can touch() identities affected by server-side cascades.

These effects are declared with the mutation instead of repeated by each consumer. TanStack Query core remains the internal scheduler and cache engine for staleness, retries, refetching, and garbage collection.

The entity cache
// FREE — return the entity, every view patches by id
rename: server.procedure()
  .output(User.pick("id", "name")).mutation(handler)

// MEMBERSHIP — which lists change, declared once
comment: server.procedure().affects(commentsFor).mutation(handler)

// CASCADES the output can't name — the server touches them
archive: server.procedure().mutation(({ touch }) => {
  touch(Task, id); /* … */
})

Frequently asked questions.

Does it work with React Server Components?

Yes. There's a dehydrate/hydrate bridge and a nestable <ResultRpcHydrationBoundary>: prefetch on the server, and the client renders on first paint with zero requests. Hydrated entities are indexed too, so a client mutation patches a server-rendered row in place. There are working examples for Waku, Next.js App Router, and TanStack Start.

Why Result? Why not just throw?

Anticipated failures that callers can respond to belong in the procedure contract, not in exceptional control flow. Result makes those branches explicit and closed; throwing remains available for unexpected failures, programmer errors, cancellation, and deliberate boundary escalation.

A tagged error is validated by its codec and reconstructed after crossing the network. The client therefore receives the same Result<T, E> API and tagged-error definitions used by server code.

Declared failures produce structured Result events. Unexpected server exceptions remain incident signals with private causes and cross the wire only as server/internal.

How is this different from returning Result from a tRPC procedure?

Returning a Result from tRPC leaves domain errors inside the data envelope while transport and framework errors remain on query.error. result-rpc places application, transport, protocol, and stale-client failures in one closed operation union, then lets React shells subtract the cases owned above a component.

Is it inspired by GraphQL?

GraphQL is an influence in two narrower ways. Normalized clients inspired entity identity and the partially normalized client cache: keyed models let a returned entity patch its occurrences across cached procedure results, while those results remain the source of truth. Separately, some GraphQL schemas model expected failures as members of a returned union. That is a schema-design pattern, not behavior GraphQL or Graphcache enforces. result-rpc makes those failures explicit in each procedure's Result<T, E>.

Do I have to model my whole data graph first?

No, and there's no point where a shape has to be classified. Every query starts as a plain wire.object — the same output you'd have written in tRPC. You mint a model later, when you notice the same keyed thing showing up in several places, and only for the fields that are true about it in every query.

Relationships are never declared. There's no relation schema to keep in sync — a relationship is just the shape of one query's output, discovered by walking the decoded result. So you compose whatever a screen needs: an entity nested in a one-off wrapper, an aggregate sitting next to a model, a projection with nothing but an id and a rank.

The curve is incremental in both directions. Under-modeling costs nothing — an unmodeled node behaves exactly like invalidate-and-refetch, which is where you already are today. And there's no demotion event: aggregates never go inside models, so adding one can't break a model you already have.

Do I have to use Drizzle?

No. Models are plain wire codecs and result-rpc has no ORM adapter. The optional .$satisfies<Source>() check can prove that a model projection fits any source type without importing source code at runtime. That source can be a Drizzle row type, a domain type, or anything else structural.

Which server framework does it need?

None in particular. The handler is a standard Request → Response function, so it mounts on Hono, Next.js route handlers, Bun, Deno, Cloudflare Workers — anything that speaks the Web platform.

Is it realtime? Does it sync across clients?

Subscriptions push server→client over SSE, and the entity cache patches from those events. But there's no peer-to-peer sync and no authoritative stream fanned out to every client — that's a different product (look at Rocicorp's Zero). This is one client, kept incrementally in shape on TanStack Query core.

Can I keep my Zod / Valibot schemas? And file uploads?

Validators plug in through Standard Schema, for inputs and for forms. Binaries stay out of band on purpose: upload to object storage (a presigned PUT) and put a bucket reference on the contract — which keeps the wire small and the CSRF surface uniform.

Is it production-ready?

0.3.0 is published to npm with provenance. The implementation is exercised by runnable examples, packed-consumer tests, and end-to-end tests, but the project is pre-1.0 and its API can still move between minor releases. Adopt it with that constraint in mind.

Migrate per router.
Start with the auth layer.