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

Errors

Namespaced tagged errors with wire codecs and policy — declared once, shared by both sides, registered by the router.

Throwing TRPCError({ code: "NOT_FOUND" }) for an anticipated outcome routes that branch through a shared exception channel rather than making it part of the procedure’s exact return type. A custom formatter can enrich the channel, but the caller still does not receive a closed, procedure-specific E union.

Here an error is a definition: a namespaced tag, a wire codec for its data, and its policy (HTTP status, retry, visibility) — declared once, shared by both sides:

import { error, wire } from "result-rpc";

export const DocNotFound = error({
  tag: "doc/not-found",
  data: wire.object({ docId: wire.string }),
  httpStatus: 404,
});

export const Unauthorized = error({ tag: "auth/unauthorized", httpStatus: 401 });

export type DocNotFound = ReturnType<typeof DocNotFound>;
export type Unauthorized = ReturnType<typeof Unauthorized>;

Or declare a whole namespace at once — keys become tags, so the tag string is never written twice and cannot drift from the name:

export const docErrors = defineErrors("doc", {
  notFound: { data: wire.object({ docId: wire.string }), httpStatus: 404 },
  locked: { data: wire.object({ lockedBy: wire.string }), httpStatus: 409 },
});

docErrors.notFound({ docId }); // TaggedError<"doc/not-found", { docId: string }>

The key→tag rule is mechanical: camelCase keys become kebab-case tag segments under the namespace — notFounddoc/not-found, titleTakendoc/title-taken. Shells index definitions by tag and tests assert tags, so knowing the derivation beats guessing it at a distance.

Public definitions use the same map shape everywhere: procedure .errors(), middleware .errors(), and shell claims: all take a map of definitions, so one exported map is declared once and reused on both sides of the wire. pickErrors(docErrors, "locked") selects the subset a procedure actually declares. Grouping is always by these values, never by matching on the tag string — the namespace prefix exists only so tags stay unique and readable. Four namespaces are reserved for the framework’s own errors: client/, server/, protocol/, and control/; error() rejects tags that use them.

retry defaults to "never", visibility to "public", and data to an empty object codec. Data-free definitions are called with no arguments: Unauthorized(). httpStatus is an optional HTTP-adapter projection; when omitted, the HTTP adapter carries the application failure in a neutral 200 RPC envelope. When supplied, it accepts the common vocabulary by name — "not-found", "conflict", "too-many-requests" — or any 4xx/5xx number.

Calling a definition creates the complete error value:

const failure = DocNotFound({ docId: "doc_123" });

failure instanceof Error; // true
DocNotFound.is(failure); // true
failure.name; // "doc/not-found"
failure.data.docId; // "doc_123"
failure.visibility; // "public"
failure.toJSON(); // { _tag: "doc/not-found", data: { docId: "doc_123" } }

The definition is the runtime identity and the namespaced tag is its portable wire identity. A shape-compatible object is intentionally insufficient:

err({ _tag: "doc/not-found", data: { docId: "doc_123" } });
//  ^ type error — not a reified TaggedError

Each definition creates its own TaggedError subclass. On the server, DocNotFound.is(value) verifies that exact definition’s instance. Across the wire, result-rpc downgrades the instance to its canonical { _tag, data } form, validates the tag against the procedure registry, decodes data, and constructs a fresh instance from the same definition on the client:

const result = await client.doc.byId({ id: "missing" });

if (result.status === "error" && DocNotFound.is(result.error)) {
  result.error instanceof Error; // true, after the wire
  result.error.data.docId; // string
}

The client instance is faithful, not literally the server object: server stack and cause are never transmitted. HTTP status and retry behavior remain projections of the definition’s identity. There is no public shallow Result.serialize() — values cross the RPC boundary only through the definition’s actual encoder and decoder.

Expected failures and unexpected exceptions

Expected failures are part of the contract. Unexpected exceptions are not. If a caller can anticipate an outcome and make a useful decision from it, return the declared tagged value through explicit control flow:

if (!doc) return err(errors.DocNotFound({ docId }));

Throwing that same outcome is not an alternate encoding. An exception escapes the handler’s declared Result, so the server boundary treats it as unexpected: the private cause is sent to onInternalError, correlated with an incident ID, and projected to the client as a sanitized server/internal.

throw new Error("database connection disappeared");
// server observability: original cause + incident ID
// client Result: ServerInternal({ incidentId })

This keeps the two paths useful. Declared failures produce structured Result events suitable for product metrics, retries, and UI ownership. Unexpected exceptions remain high-signal incidents with their causes available only on the server. Throwing is still used for programmer errors, cancellation, deliberate boundary escalation, and failures that were not adopted into a declared domain error.

Use tryPromise at a throwing dependency boundary when the failure is an anticipated part of the operation. Its catch mapper must construct a tagged error, making the decision to expose, fold, or keep a provider failure private visible in code.

Public and private errors

Visibility is part of the definition and the resulting instance type. Omitted means "public". Use visibility: "private" for server-only failures that are valuable while composing Results but unsafe or meaningless as client API:

const UniqueConstraint = error({
  tag: "db/unique-constraint",
  data: wire.object({ constraint: wire.string }),
  visibility: "private",
});

r.procedure().errors({ UniqueConstraint });
//                    ^ type error: private errors cannot enter an RPC contract

Fold that failure into a public domain error before returning from the handler. Private definitions cannot declare httpStatus; the public error owns that projection if it needs one. The type boundary covers procedures, middleware, and layers. Runtime sanitization remains as defense in depth for JavaScript and unsafe casts. Visibility is not a tree-shaking annotation: define private adapters in a server-only module so their database or vendor imports never enter the client bundle graph.

The client-wide public union

Every client carries a flattened, _tag-discriminated union of all public domain and framework errors in its contract:

import type { ClientErrors } from "result-rpc/client";

type AppError = ClientErrors<typeof client>;

if (client.$errors.is(unknownFailure)) {
  unknownFailure satisfies AppError;
  unknownFailure.visibility; // "public"
}

client.$errors.definitions; // runtime registry of the same public definitions

This is derived from the contract. Private definitions are absent by type, and the client never trusts a visibility claim sent by the server.

This guarantee belongs to the result-rpc wire. JSON.parse(JSON.stringify(error)) produces the canonical plain representation, not another instance; DocNotFound.is(...) correctly returns false until DocNotFound.decode(...) upgrades it again. The same warning applies to framework RPC systems and component-prop serializers that know nothing about the result-rpc contract.

Result is total — partial availability is a value

Result<T, E> cannot say “the doc loaded, but its author panel is unavailable.” That is deliberate. GraphQL spent a decade with nullable fields as ambient partial failure and is now retrofitting field-level error semantics (Relay’s @catch/@throwOnFieldError) — a directive on the query, deciding per call site how much failure to tolerate.

Here the same fact is modeled where every other fact lives: in the output type. If a field can be independently unavailable, say so in the schema —

.output(wire.object({
  doc: DocView,
  author: wire.union([
    User.pick("id", "name", "avatarUrl"),
    wire.object({ unavailable: wire.literal(true) }),
  ]),
}))

— and the component branches on a value, exhaustively, like everything else. The operation still resolves one Result: the call succeeded, and “the author service was down” is part of what it successfully learned. No directive vocabulary, no per-call-site tolerance policy, no nullable-means- maybe-failed ambiguity: a partial outcome is a declared shape on the wire, visible in the contract diff like any other API decision.

The router is the error registry

One tag maps to exactly one definition across the whole application. Two procedures reusing a tag must share the definition — the same reference — and server.router(...) rejects a tag redeclared with a different definition at build time. This is what makes tags safe as global registry keys: a shell uses the tag to find a candidate and the exact definition to prove ownership, so a tag can never mean two different things in one app. The registry is inspectable at runtime:

// tag → definition. Keyed by `string`: this is the map the client decodes
// against and a devtools panel can enumerate, not a typed lookup.
appRouter.errors;

TypeScript catches incompatible duplicate declarations when their tag, codec, or visibility types differ. It cannot mint a fresh nominal type for each call, so two separate error() calls with exactly the same structural signature are indistinguishable statically. Router construction therefore always performs the final reference-identity check. Share exported definition constants; do not redeclare a tag and rely on structural equality.

The key stays string on purpose. ReadonlyMap is invariant in its key type, so narrowing it to the tag union would make a concrete router stop satisfying AnyRouter — the erased runtime bound every function here accepts. For compile-time exhaustiveness over declared errors, reach for errorCatalog instead; that is the typed door, and this is the runtime window.

Retry policy follows the tag

Retry behavior is declared with the error rather than reconstructed from a message or overlapping status code:

export const ServiceUnavailable = error({
  tag: "search/service-unavailable",
  httpStatus: 503,
  retry: "transient",
});

export const RateLimited = error({
  tag: "search/rate-limited",
  data: wire.object({
    retryAfterMs: wire.integer({ min: 0, max: 60_000 }),
  }),
  httpStatus: 429,
  retry: "after",
});

Mutations are stricter by default. A query retries transient and after tags freely — reads are idempotent. A mutation whose connection died mid-flight is ambiguous: the server may have processed it, and a blind retry is the double-side-effect bug. So by default a mutation retries only two failures: client/offline (the transport short-circuits before sending — the request provably never left the client) and policy retry: "after" (the server responded and scheduled the retry, so it chose not to process the attempt). Everything else — network failure, timeout, 5xx — surfaces immediately. Idempotent mutations can opt back in with retry:; idempotency keys are the roadmap item that will make full retry the safe default. The retryable field on client/network-failure means “provably never left the client” — and a fetch rejection cannot prove that, so it is honest and false.

The query runtime owns query retry. A transport retry loop does not silently run underneath it. Direct calls can opt into the same policy:

const result = await client.search.run(input, {
  retry: "from-error-policy",
});

Was this page helpful?