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

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.

Was this page helpful?