Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

The wire

Dates, BigInts, Maps, and cycles cross intact — a pinned, versioned serializer with byte limits. Binaries stay out of band.

Error definitions describe the encoded value, not an optimistic in-memory type:

export const SaveConflict = error({
  tag: "doc/save-conflict",
  data: wire.object({
    docId: wire.string,
    theirSavedAt: wire.date,      // a real Date on both sides of the wire
    revision: wire.bigint,        // a real BigInt, not a stringified one
  }),
  httpStatus: 409,
})

result-rpc uses a pinned, protocol-versioned devalue transport. Success values and tagged error data transparently preserve:

  • undefined, NaN, infinity, and -0
  • Date, BigInt, RegExp, URL, and URLSearchParams
  • Map, Set, ArrayBuffer, and typed arrays
  • Temporal values when Temporal is available in both runtimes
  • cycles and repeated object identity
const RichDoc = wire.object({
  savedAt: wire.date,
  revision: wire.bigint,
  pattern: wire.regexp,
  homepage: wire.url,
})

For a recursive or otherwise richer application type, validate serializer support at the boundary:

const Graph = wire.serializable<DocGraph>()

Functions, symbols, unsupported class instances, and arbitrary Error causes are rejected. Tagged error constructors perform a real serializer preflight, so a custom wire codec cannot smuggle an unsupported runtime value into an error.

Encoded request, response, hydration, and tagged-error byte limits are enforced at runtime. Invalid values are never reflected back to the client. Custom procedure codecs can enforce finer domain-specific collection, string, and nesting limits.

Binaries are out of band

result-rpc deliberately does not move file bytes over the RPC wire. Every request is the one JSON-shaped protocol content-type — there is no multipart path — which keeps the surface small and, not incidentally, keeps the CSRF defense uniform (a browser cannot send that content-type cross-origin without a preflight).

Modern stacks already handle binaries better out of band. Upload the bytes straight to object storage (R2, S3, a Hono endpoint you mount yourself), usually with a presigned URL, and let the RPC contract carry only the reference — a bucket key:

// 1. A tiny RPC to mint an upload target (or mount a plain POST route for it).
const createUpload = app.procedure()
  .input(wire.object({ contentType: wire.string }))
  .output(wire.object({ uploadUrl: wire.url, key: wire.string }))
  .mutation(async ({ input, context }) =>
    ok(await context.storage.presignPut(input.contentType)))

// 2. The client PUTs the file to uploadUrl directly — bytes never touch RPC.
await fetch(uploadUrl, { method: "PUT", body: file })

// 3. The RPC that finishes the job carries only the reference.
const setAvatar = app.procedure()
  .input(wire.object({ userId: wire.string, key: wire.string }))
  .output(UserCard)
  .errors({ ImageUnprocessable })
  .mutation(async ({ input, context }) =>
    ok(await context.users.setAvatarFromKey(input.userId, input.key)))

The contract stays fully typed and wire-safe, uploads scale on your storage provider instead of your app server, resumable and multipart-to-storage uploads work with no library involvement, and the RPC endpoint keeps its single content-type. A profile picture, a video, an attachment — all the same shape: a reference on the contract, the bytes handled by infrastructure built for bytes.

Was this page helpful?