Skip to content

Use with Pothos ​

How-to. @unthrown/pothos resolves Pothos GraphQL fields from a Result: Ok is the field's value, each modeled failure becomes a typed member of the field's result union, and a Defect stays an error of the operation, never a refusal.

sh
pnpm add @unthrown/pothos unthrown @pothos/core @pothos/plugin-errors graphql

@pothos/plugin-errors already models expected failures the GraphQL way — a field's error classes become members of its result union — but it learns of a failure by catching a throw, and nothing checks that the classes a field lists are the failures its resolver can raise. With a Result resolver both are settled by the types:

unthrownGraphQL
Ok(value)the field's value
Err(error)the refusal its case maps to, a member of the result union
Defecta thrown error, which the server must mask

Mask errors at the server

A Defect is never answered as a typed refusal, but hiding its message is the server's job. GraphQL Yoga masks unexpected errors by default; executing the schema with graphql-js alone, or with a server that does not mask, returns the defect's message to the client. A defect whose cause is a GraphQLError is rethrown as it is — GraphQL's own signal for an error meant for the client, so Yoga shows it — unless a handled class would claim it: with a base Error in the errors plugin's defaultTypes, it is wrapped like any other defect.

Register the plugin ​

@unthrown/pothos builds on the errors plugin, which the builder lists too:

ts
import SchemaBuilder from "@pothos/core";
import ErrorsPlugin from "@pothos/plugin-errors";
import UnthrownPlugin from "@unthrown/pothos";

const builder = new SchemaBuilder<{ Objects: { Book: Book } }>({
  plugins: [ErrorsPlugin, UnthrownPlugin],
  errors: { directResult: true },
});

Map each failure to a GraphQL error ​

A refusal is a GraphQL error class built from the failure it answers. Register it as an object type, as the errors plugin expects:

ts
class NotFound extends TaggedError("NotFound")<{ readonly title: string }> {}

class BookNotFound extends Error {
  constructor(failure: NotFound) {
    super(`No book titled ${failure.title}`);
  }
}

builder.objectType(BookNotFound, {
  name: "BookNotFound",
  fields: (t) => ({ message: t.exposeString("message") }),
});

Resolve a field from a Result ​

t.resultField is t.field whose resolve answers a Result, with one refusal per case of its failure:

ts
builder.queryField("book", (t) =>
  t.resultField({
    type: "Book",
    args: { title: t.arg.string({ required: true }) },
    refusals: { NotFound: BookNotFound },
    resolve: (_root, { title }) => library.find(title), // AsyncResult<Book, NotFound>
  }),
);

resolve answers a Result or an AsyncResult, never a Promise: an async resolver does not compile, so async work enters through fromPromise and composes with flatMap, as everywhere in unthrown.

A failure names its case by its _tag, or by its code — so an ORPCError from @unthrown/orpc's client maps by the code its procedure declares ({ NOT_FOUND: BookNotFound }).

The record is checked like a matcher: a case left out, a case the failure cannot be, or a class whose constructor takes another case's failure does not compile. Adding a case to the resolver's failure breaks every field that resolves it until the case has its refusal.

The extra-case check is TypeScript's excess-property check, so it holds for the record written inline. A record declared apart keeps it with satisfies:

ts
import type { Refusals } from "@unthrown/pothos";

const bookRefusals = { NotFound: BookNotFound } satisfies Refusals<NotFound>;

A resolver that cannot fail takes refusals: {}. On a builder without defaultTypes its field stays a plain field rather than a one-member union.

Connections ​

@unthrown/pothos/relay adds t.resultConnection, the same for @pothos/plugin-relay's connections — the Ok value is the connection. The relay plugin is an optional peer: install it and list it in the builder's plugins, then import the entry:

sh
pnpm add @pothos/plugin-relay
ts
import RelayPlugin from "@pothos/plugin-relay";
import "@unthrown/pothos/relay";

const builder = new SchemaBuilder<{ Objects: { Book: Book } }>({
  plugins: [ErrorsPlugin, RelayPlugin, UnthrownPlugin],
  errors: { directResult: true },
  relay: {},
});

builder.queryField("books", (t) =>
  t.resultConnection({
    type: "Book",
    refusals: { ShelfClosed: ShelfClosedError },
    resolve: (_root, args) => library.page(args), // AsyncResult<Connection, ShelfClosed>
  }),
);

DataLoaders ​

A loader answers each key with its value or an Error — never a rejection, which would fail the whole batch. outcomeOf gives exactly that from a Result: the value, the refusal its failure maps to, or a Defect as an Error. Pass it the builder's defaultTypes too, so a defect one of them would claim is wrapped rather than answered as a refusal:

Loadable objects come from @pothos/plugin-dataloader, which the builder lists too:

sh
pnpm add @pothos/plugin-dataloader dataloader
ts
import DataloaderPlugin from "@pothos/plugin-dataloader";
import { outcomeOf } from "@unthrown/pothos";

const builder = new SchemaBuilder<{ Objects: { Book: Book } }>({
  plugins: [ErrorsPlugin, DataloaderPlugin, UnthrownPlugin],
  errors: { directResult: true },
});

const BookNode = builder.loadableObject("Book", {
  load: (titles: readonly string[]) =>
    Promise.all(
      titles.map((title) =>
        outcomeOf(
          library.find(title),
          { NotFound: BookNotFound },
          builder.options.errors?.defaultTypes,
        ),
      ),
    ),
  fields: (t) => ({ title: t.exposeString("title") }),
});

A loader can only reject a key with an Error, so with a base Error among the errors plugin's defaultTypes, every defect a loader reports is claimed as that default type — the one case where a defect reaches the union.

Released under the MIT License.