The client
Every call resolves Result with the complete union; batching, cancellation, and wire-parity server calls.
import { createBrowserClient, batchFetchTransport } from "result-rpc/client";
import { appContract } from "../shared/contract";
export const client = createBrowserClient({
contract: appContract,
transport: batchFetchTransport({ url: "/rpc" }),
});
const result = await client.doc.byId({ id: "doc_123" });
Calls issued in the same microtask share one HTTP request. Every batch item
keeps its own status, decoder, rich-value envelope, and tagged Result. Use
fetchTransport when batching is not wanted; the client API is unchanged.
Because the batch itself is one successful protocol exchange, its outer HTTP
status is 200; an item’s domain status (for example 409) remains in that item
and is reported by the server hooks, but browser network panels show the outer
batch status.
batchFetchTransport also implements the streaming path used by
subscriptions. Use this same client instance beneath ResultRpcProvider; a
separate fetch client is neither required nor compatible with that runtime’s
cache and ownership context.
One deliberate carve-out from “every call resolves Result”: input that the
procedure’s own input codec rejects throws a TypeError at the call site
and never reaches the wire. That is a programmer error — the types prevent it
for structural codecs, and for runtime-validating codecs (wire.standard)
your form validates the human first (see Forms and the
wire). Errors-as-values is a contract about outcomes of
operations, not a net under code the compiler already rejects.
The direct client is the honest base: it always resolves the complete union.
Narrowing is a property of where a call is rendered, and the direct client
is not rendered anywhere, so it never subtracts anything. Every failure in the
union is already a reified TaggedError: definition .is() guards,
instanceof Error, toJSON, and direct yield* all work after transport.
Result<
Doc,
| Unauthorized
| DocNotFound
| ServerInternal
| Offline
| NetworkFailure
| Timeout
| HttpFailure
| ProtocolViolation
| DecodeFailure
>;
Handle the union with an ordinary switch (result.error satisfies never in
the default arm keeps it exhaustive), or build a reusable projection — a
message catalog, a metrics mapper — once, from the same definition map
middleware and shells use:
import { errorCatalog } from "result-rpc";
const message = errorCatalog(
{ DocNotFound, Unauthorized },
{
"doc/not-found": (e) => `Doc ${e.data.docId} is gone`,
"auth/unauthorized": () => "Sign in to continue",
},
);
Adding a definition to the map breaks every catalog missing the new tag. For
inline one-offs, matchError(result.error, { ...handlers }) gives the same
exhaustiveness on a single value. At an unknown boundary, use the catalog’s
exact guard before projecting:
if (message.is(caught)) return message(caught);
The full composition surface — gen with yield*, tryPromise, all, the
transform family — is covered in
Result composition.
Cancellation is not an operation error
Query cancellation updates lifecycle state without producing an Err,
consuming a retry, or entering an error boundary.
Direct calls accept an AbortSignal:
const controller = new AbortController();
const pending = client.doc.byId({ id: "doc_123" }, { signal: controller.signal });
controller.abort();
Internally result-rpc uses a tagged control/cancelled sentinel so
cancellation does not depend on platform-specific AbortError identity. It is
control flow and is excluded from the recoverable error union. Its sibling,
control/claimed, is what a shell-claimed mutation rejects with — the same
family, one catch at the call site, and isCancelled/isClaimed to tell
the two events apart when the UX differs.
Server-side calls stay in-process
import { createServerClient } from "result-rpc/server";
import { appRouter } from "./router";
const serverClient = createServerClient(appRouter, {
context,
});
const result = await serverClient.doc.byId({ id: "doc_123" });
The server client still runs middleware and input/output codecs, but it does
not manufacture browser failures that cannot happen in-process. Use
createParityClient from result-rpc/testing when a test should cross the
serializer, protocol envelope, fetch handler, and browser decoder.