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

Contract and procedures

Procedures, middleware, and routers: the shared contract both sides close over.

import { rpc, wire, type InputOf, type ProcedureError, type ProcedureOutput } from "result-rpc";
import { DocNotFound, Unauthorized } from "./errors";

interface AppContext {
  docs: DocRepository;
  auth: AuthService;
}

export const app = rpc.context<AppContext>();

const DocCodec = wire.object({
  id: wire.string,
  title: wire.string,
  savedAt: wire.date,
});
type Doc = InputOf<typeof DocCodec>;

export const getDocContract = app
  .procedure()
  .input(wire.object({ id: wire.string }))
  .output(DocCodec)
  .errors({ Unauthorized, DocNotFound })
  .query();

export const appContract = app.contract({
  doc: {
    byId: getDocContract,
  },
});

type DocOutput = ProcedureOutput<typeof getDocContract>;
type GetDocFailure = ProcedureError<typeof getDocContract>;

One honest difference from tRPC: tRPC ships the router’s type to the client (import type AppRouter), and in exchange the client can neither decode rich values nor validate anything. result-rpc ships a small value — the contract: codecs, tags, and policies, no middleware or handler code, safe in any browser bundle. It is the one place this library costs you a file tRPC doesn’t, and it is what pays for Date/Map/BigInt over the wire and codecs on both sides.

Browser clients are built from this contract. Implemented routers belong to createFetchHandler and createServerClient.

ProcedureOutput and ProcedureError are the direct helpers when code names one procedure. For forms, loaders, test fixtures, or adapters spanning a whole application, RouterInputs, RouterOutputs, and RouterErrors preserve the router’s nested shape so a path can be indexed without rebuilding a codec or union. All six helpers work on the shared contract; the nested helpers also work on an implemented router in server-only code.

Implement the contract on the server

import { err, ok } from "result-rpc";
import { serverRpc } from "result-rpc/server";
import { getDocContract, type AppContext } from "./contract";

const server = serverRpc.context<AppContext>();
export const getDoc = server
  .implement(getDocContract)
  .use(authenticated)
  .handler(async ({ input, errors, context }) => {
    const doc = await context.docs.find(input.id);

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

    return ok(doc);
  });

The handler must return the declared Result:

Result<Doc, Unauthorized | DocNotFound>;

Returning a different tag is a type error. Smuggling an undeclared or malformed tag at runtime does not make it public: result-rpc logs the defect and emits a sanitized server/internal value.

Middleware participates in the same union

The tRPC footgun here is quiet: middleware that throws TRPCError({ code: "UNAUTHORIZED" }) adds a failure mode the procedure’s type never mentions. Here, middleware declares what it contributes, and the contribution lands in the union:

import { err } from "result-rpc";
import { serverRpc } from "result-rpc/server";
import type { AppContext } from "./contract";
import { Unauthorized } from "./errors";

const server = serverRpc.context<AppContext>();
const authenticated = server
  .middleware<{ user: User }>()
  .errors({ Unauthorized })
  .use(async ({ context, errors, next }) => {
    const user = await context.auth.user();

    if (!user) {
      return err(errors.Unauthorized());
    }

    return next({
      context: { user },
    });
  });

export const getDoc = server.implement(getDocContract).use(authenticated).handler(/* ... */);

The procedure now returns:

Result<Doc, Unauthorized | DocNotFound>;

Builders are immutable, so a base forks freely — the protectedProcedure pattern is one line:

const protectedProcedure = server.procedure().use(authenticated);

const renameDoc = protectedProcedure
  .input(RenameInput)
  .output(DocCodec)
  .errors({ DocNotFound, DocLocked }) // only its own domain errors;
  .mutation(/* ... */); // the auth union rides in with the base

Middleware definitions also join the handler’s errors bag, so a handler can return a middleware-contributed error without re-importing it — but choose deliberately there: errors.Unauthorized() from an ownership check would hand a 403-shaped outcome to whatever shell owns the auth union (whose reaction is a sign-in redirect). Not-the-owner is its own domain error.

In contract-first code, middleware errors must already be present in the shared contract; server.implement(...).use(...) rejects an undeclared contribution. The code-first convenience form unions middleware definitions automatically. Duplicate tags with different definitions are rejected rather than silently overridden.

Create the router and server

import { createFetchHandler, serverRpc } from "result-rpc/server";
import type { AppContext } from "./contract";
import { getDoc } from "./doc";

const server = serverRpc.context<AppContext>();
export const appRouter = server.router({
  doc: {
    byId: getDoc,
  },
});

export type AppRouter = typeof appRouter;

// Nested inference helpers mirror the router's shape — works on contracts too:
// type Inputs = RouterInputs<AppRouter>;  Inputs["doc"]["byId"]  → { id: string }
// type Outputs = RouterOutputs<AppRouter>; Outputs["doc"]["byId"] → Doc
// type Errors = RouterErrors<AppRouter>;   Errors["doc"]["byId"]  → declared union

export const handleRpc = createFetchHandler({
  router: appRouter,
  endpoint: "/rpc",
  createContext: ({ request }) => ({
    request,
    auth,
    docs,
  }),
  onInternalError: ({ incidentId, phase, cause, procedurePath }) => {
    logger.error({ incidentId, phase, cause, procedurePath });
  },
});

onError is the observability tap: it fires for every declared error that crosses the wire — domain errors, bad requests, sanitized internals — with the error value, its policy (severity, retry, status), and the procedure path, so one hook feeds metrics and logging:

onError: ({ error, policy, procedurePath, httpStatus }) => {
  metrics.count(error._tag, { severity: policy?.severity });
};

Malformed input is the client’s fault, not an incident: it becomes a public server/bad-request (400) carrying path-and-message issues — never values — while onInternalError stays reserved for genuine defects.

Unknown exceptions receive an incident ID and are passed to onInternalError when configured. The client receives only:

{
  _tag: "server/internal",
  data: { incidentId: "inc_..." },
}

Exception messages, stacks, causes, queries, and response bodies are not reflected over the wire.

Was this page helpful?