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-0Date,BigInt,RegExp,URL, andURLSearchParamsMap,Set,ArrayBuffer, and typed arrays- Temporal values when
Temporalis 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.