Skip to content

Resolve GraphQL fields from oRPC procedures ​

How-to. Expose an oRPC procedure as a GraphQL field: its input validated by its own contract, its errors answered as typed union members, and an access refusal kept an error of the operation.

Install @btravstack/pothos beside the schema's own packages — every one of them a peer:

sh
pnpm add @btravstack/pothos @pothos/core @pothos/plugin-errors @pothos/plugin-relay \
  @unthrown/pothos@^0.1.2 graphql unthrown zod \
  @orpc/client@2.0.0-beta.34 @orpc/contract@2.0.0-beta.34

The schema is any server's: serve it with @btravstack/graphql-server or another one.

Register a refusal per error code ​

A procedure's contract declares its errors by code. The builder's orpc: { refusals } maps each code to the GraphQL error class built from that failure, plus InvalidInput, the contract refusing a field's input before the call:

ts
import OrpcPlugin, { type InvalidInput } from "@btravstack/pothos";
import type { ORPCError } from "@orpc/client";
import { oc } from "@orpc/contract";
import SchemaBuilder from "@pothos/core";
import ErrorsPlugin from "@pothos/plugin-errors";
import RelayPlugin from "@pothos/plugin-relay";
import UnthrownPlugin from "@unthrown/pothos";
import type { AsyncResult } from "unthrown";
import { z } from "zod";

const Book = z.object({ title: z.string(), pages: z.number() });

const getBook = oc
  .input(z.object({ title: z.string().min(1) }))
  .output(Book)
  .errors({ NOT_FOUND: { data: z.object({ reason: z.string() }) } });

class NotFound extends Error {
  public constructor(failure: { readonly data: { readonly reason: string } }) {
    super(failure.data.reason);
  }
}

class BadRequest extends Error {
  public constructor(failure: InvalidInput) {
    super(failure.message);
  }
}

const refusals = { NOT_FOUND: NotFound, InvalidInput: BadRequest } as const;

type Catalogue = {
  readonly getBook: (input: {
    readonly title: string;
  }) => AsyncResult<unknown, ORPCError<"NOT_FOUND", { readonly reason: string }>>;
};

const builder = new SchemaBuilder<{
  Context: { readonly catalogue: Catalogue };
  Objects: { Book: z.output<typeof Book> };
  Refusals: typeof refusals;
}>({
  plugins: [RelayPlugin, ErrorsPlugin, UnthrownPlugin, OrpcPlugin],
  errors: { directResult: true },
  orpc: { refusals },
});

Catalogue stands for the service's client as the context carries it — an oRPC client whose calls answer an AsyncResult, such as @unthrown/orpc's.

Expose the procedure as a field ​

Each refusal class is an object type, and procedureField takes the procedure, the call, how the field's arguments become its input, and how its answer is decoded:

ts
builder.objectType(NotFound, {
  name: "NotFound",
  fields: (t) => ({ message: t.exposeString("message") }),
});
builder.objectType(BadRequest, {
  name: "BadRequest",
  fields: (t) => ({ message: t.exposeString("message") }),
});
builder.objectType("Book", {
  fields: (t) => ({ title: t.exposeString("title"), pages: t.exposeInt("pages") }),
});

builder.queryType({
  fields: (t) => ({
    book: t.procedureField(getBook, {
      type: "Book",
      args: { title: t.arg.string({ required: true }) },
      call: ({ catalogue }) => catalogue.getBook,
      input: ({ title }) => ({ title }),
      output: Book,
    }),
  }),
});

The field's type is the union Book | NotFound | BadRequest. What happens on each path:

  • The input is the contract's. It is parsed by the procedure's own input schema — its defaults applied, its codecs run — and encoded back for the wire before call runs; a schema with a .transform() has no wire spelling, so call gets the input as given. An input the contract refuses is BadRequest, and the service is never called.
  • A declared error is its registered member. A registry that misses a code call can fail with does not compile: the options demand an uncovered property naming the code.
  • An access refusal is an error of the operation. UNAUTHORIZED and FORBIDDEN, declared or not, become a GraphQLError with that code in extensions.code, which a server masking unexpected errors passes through.
  • Anything else is a defect, an answer the output schema refuses included: the field fails and the server masks it.

tap sees each value a field answers, with the context, to prime a request's cache after a read or a write.

Page through a connection ​

procedureConnection takes the same options, and connection folds the decoded page into Relay's shape; connectionType adds fields of your own beside its edges and page info.

Load through a DataLoader ​

A field batched through a DataLoader cannot answer a Result: the batch function answers a value or an Error per key. loadProcedure(refusals, procedure, call, input, output) is that answer, the refusal instance for a declared error so one key's failure never fails the batch, and procedureErrors(refusals, procedure) is the field's errors option, the same union procedureField would have built.

Released under the MIT License.