Errors plugin

Errors plugin docs for Pothos

Represent expected failures as GraphQL result unions. Register error classes as object types, then list them in a field's errors option. The plugin catches matching errors and returns an error object that clients can query with fragments. Errors that do not match still become GraphQL errors.

Usage

Install

npm install --save @pothos/plugin-errors

Example Usage

import SchemaBuilder from '@pothos/core';
import ErrorsPlugin from '@pothos/plugin-errors';
const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin],
  errors: {
    defaultTypes: [],
    // onResolvedError: (error) => console.error('Handled error:', error),
  },
});

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

builder.queryType({
  fields: (t) => ({
    hello: t.string({
      errors: {
        types: [Error],
      },
      args: {
        name: t.arg.string({ required: false }),
      },
      resolve: (parent, { name }) => {
        if (name && name.slice(0, 1) !== name.slice(0, 1).toUpperCase()) {
          throw new Error('name must be capitalized');
        }

        return `hello, ${name || 'World'}`;
      },
    }),
  }),
});

The above example will produce a GraphQL schema that looks like:

type Error {
  message: String!
}

type Query {
  hello(name: String): QueryHelloResult
}

union QueryHelloResult = Error | QueryHelloSuccess

type QueryHelloSuccess {
  data: String!
}

This field can be queried using fragments like:

query {
  hello(name: "World") {
    __typename
    ... on Error {
      message
    }
    ... on QueryHelloSuccess {
      data
    }
  }
}

This plugin works by wrapping fields that define error options in a union type. This union consists of an object type for each error type defined for the field, and a Success object type that wraps the returned data. If the fields resolver throws an instance of one of the defined errors, the errors plugin will automatically resolve to the corresponding error object type.

Expected and unexpected errors

Only errors matching a field's registered classes become typed results. With the imports and builder setup above, this alternative query definition catches NameTooShort and exposes its message and minimum fields. Successful greetings use QueryGreetingSuccess; a plain Error remains a GraphQL error because this field does not register that class.

class NameTooShort extends Error {
  minimum = 3;
  constructor() {
    super('Use at least three characters');
  }
}
builder.objectType(NameTooShort, {
  name: 'NameTooShort',
  fields: (t) => ({ message: t.exposeString('message'), minimum: t.exposeInt('minimum') }),
});

builder.queryType({
  fields: (t) => ({
    greeting: t.string({
      args: { name: t.arg.string({ required: true }), simulateFailure: t.arg.boolean() },
      errors: { types: [NameTooShort] },
      resolve: (_, { name, simulateFailure }) => {
        if (simulateFailure) {
          throw new Error('Service unavailable');
        }
        if (name.length < 3) {
          throw new NameTooShort();
        }
        return `Hello, ${name}`;
      },
    }),
  }),
});

Builder options

  • defaultTypes: An array of Error classes to include in every field with error handling.
  • directResult: Sets the default for directResult option on fields (only affects non-list fields)
  • onResolvedError: A callback function that is called when an error is handled by the plugin
  • defaultResultOptions: Defaults for generated success object types.
  • defaultUnionOptions: Defaults for generated result union types.
  • defaultItemResultOptions: Defaults for per-item success object types.
  • defaultItemUnionOptions: Defaults for per-item result union types.
  • unsafelyHandleInputErrors: Also catch input mapping and validation errors; see With validation plugin.

The four type-options objects accept a name function receiving { parentTypeName, fieldName }. Use distinct names for success objects and unions:

const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin],
  errors: {
    defaultTypes: [Error],
    defaultResultOptions: {
      name: ({ parentTypeName, fieldName }) => `${parentTypeName}_${fieldName}_Success`,
    },
    defaultUnionOptions: {
      name: ({ parentTypeName, fieldName }) => `${parentTypeName}_${fieldName}_Result`,
    },
  },
});

Options on Fields

  • types: An array of Error classes to catch and handle as error objects in the schema. Will be merged with defaultTypes from builder.
  • union: An options object for the union type. Can include any normal union type options, and name option for setting a custom name for the union type.
  • result: An options object for result object type. Can include any normal object type options, and name option for setting a custom name for the result type.
  • dataField: An options object for the data field on the result object. This field will be named data by default, but can be renamed by passing a custom name option.
  • directResult: Boolean, can only be set to true for non-list fields. This will directly include the fields type in the union rather than creating an intermediate Result object type. This will throw at build time if the type is not an object type.

A shared Error interface

  1. Set up an Error interface
  2. Create a BaseError object type
  3. Include the Error interface in any custom Error types you define
  4. Include the BaseError type in the defaultTypes in the builder config

This pattern will allow you to consistently query your schema using a ... on Error { message } fragment since all Error classes extend that interface. If your client wants to query details of more specialized error types, they can just add a fragment for the errors it cares about. This pattern should also make it easier to make future changes without unexpected breaking changes for your clients.

This is a separate setup from the first example:

import SchemaBuilder from '@pothos/core';
import ErrorsPlugin from '@pothos/plugin-errors';
const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin],
  errors: {
    defaultTypes: [Error],
  },
});

const ErrorInterface = builder.interfaceRef<Error>('Error').implement({
  fields: (t) => ({
    message: t.exposeString('message'),
  }),
});

builder.objectType(Error, {
  name: 'BaseError',
  interfaces: [ErrorInterface],
});

class LengthError extends Error {
  minLength: number;

  constructor(minLength: number) {
    super(`string length should be at least ${minLength}`);

    this.minLength = minLength;
    this.name = 'LengthError';
  }
}

builder.objectType(LengthError, {
  name: 'LengthError',
  interfaces: [ErrorInterface],
  fields: (t) => ({
    minLength: t.exposeInt('minLength'),
  }),
});

builder.queryType({
  fields: (t) => ({
    // Simple error handling just using base error class
    hello: t.string({
      errors: {},
      args: {
        name: t.arg.string({ required: true }),
      },
      resolve: (parent, { name }) => {
        if (!name.startsWith(name.slice(0, 1).toUpperCase())) {
          throw new Error('name must be capitalized');
        }

        return `hello, ${name || 'World'}`;
      },
    }),
    // Handling custom errors
    helloWithMinLength: t.string({
      errors: {
        types: [LengthError],
      },
      args: {
        name: t.arg.string({ required: true }),
      },
      resolve: (parent, { name }) => {
        if (name.length < 5) {
          throw new LengthError(5);
        }

        return `hello, ${name || 'World'}`;
      },
    }),
  }),
});

With validation plugin

The validation plugin runs before field resolution. Set unsafelyHandleInputErrors: true to return its failures as typed results. A validation failure then returns without running field authorization hooks, so only enable this when those error details may be returned before authorization.

This standalone example registers the validation error and its issues. Valid input reaches the resolver; invalid input returns InputValidationError.

import SchemaBuilder from '@pothos/core';
import ErrorsPlugin from '@pothos/plugin-errors';
import ValidationPlugin, {
  InputValidationError,
  type StandardSchemaV1,
} from '@pothos/plugin-validation';
import { z } from 'zod';

const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin, ValidationPlugin],
  errors: { unsafelyHandleInputErrors: true },
});

const InputValidationIssue = builder
  .objectRef<StandardSchemaV1.Issue>('InputValidationIssue')
  .implement({
    fields: (t) => ({
      message: t.exposeString('message'),
      path: t.stringList({
        resolve: (issue) => issue.path?.map((part) =>
          String(typeof part === 'object' ? part.key : part),
        ) ?? [],
      }),
    }),
  });

builder.objectType(InputValidationError, {
  name: 'InputValidationError',
  fields: (t) => ({
    issues: t.field({
      type: [InputValidationIssue],
      resolve: (err) => err.issues,
    }),
  }),
});

builder.queryType();
builder.queryField('fieldWithValidation', (t) =>
  t.boolean({
    errors: {
      types: [InputValidationError],
    },
    args: {
      string: t.arg.string({
        required: true,
        validate: z.string().min(3, 'Too short'),
      }),
    },
    resolve: () => true,
  }),
);

Example query:

query {
  fieldWithValidation(string: "a") {
    __typename
    ... on QueryFieldWithValidationSuccess {
      data
    }
    ... on InputValidationError {
      issues {
        message
        path
      }
    }
  }
}

Validation also protects writes inside a mutation resolver. This alternative builder stores accepted names and returns structured issues for invalid input without changing the stored data. It uses the same imports as the validation example above and requires @pothos/plugin-validation and zod alongside the errors plugin.

// This public example deliberately exposes validation details before authorization.
const builder = new SchemaBuilder({
  plugins: [ErrorsPlugin, ValidationPlugin],
  errors: { unsafelyHandleInputErrors: true },
});
const names: string[] = [];

const Issue = builder.objectRef<StandardSchemaV1.Issue>('ValidationIssue').implement({
  fields: (t) => ({
    message: t.exposeString('message'),
    path: t.stringList({
      resolve: (issue) =>
        issue.path?.map((part) => String(typeof part === 'object' ? part.key : part)) ?? [],
    }),
  }),
});
builder.objectType(InputValidationError, {
  name: 'InputValidationError',
  fields: (t) => ({ issues: t.field({ type: [Issue], resolve: (error) => error.issues }) }),
});

const Registration = builder.inputType('Registration', {
  fields: (t) => ({
    name: t.string({ required: true }).validate(z.string().trim().min(3, 'Name is too short')),
    email: t.string({ required: true, validate: z.email('Enter a valid email') }),
  }),
});
builder.mutationType({
  fields: (t) => ({
    register: t.string({
      args: { input: t.arg({ type: Registration, required: true }) },
      errors: { types: [InputValidationError] },
      resolve: (_, { input }) => {
        names.push(input.name);
        return input.name;
      },
    }),
  }),
});

With the dataloader plugin

To use this in combination with the dataloader plugin, ensure that the errors plugin is listed BEFORE the dataloader plugin in your plugin list.

If a field with errors returns a loadableObject, or loadableNode the errors plugin will now catch errors thrown when loading ids returned by the resolve function.

For a list of loadable IDs, loading happens per item. A load failure is not a whole-field failure, so the field's errors option does not catch it. Use explicit error unions when you need typed results for individual loads; see List item errors for the separate itemErrors handling of errors returned or thrown while iterating the resolver's list.

With the prisma plugin

To use this in combination with the prisma plugin, ensure that the errors plugin is listed BEFORE the prisma plugin in your plugin list. This will enable errors option to work correctly with any field builder method from the prisma plugin.

errors can be configured for any field, but if there is an error pre-loading a relation the error will always be surfaced at the field that executed the query. Because there are cases that fall back to executing queries for relation fields, these fields may still have errors if the relation was not pre-loaded. Detection of nested relations will continue to work if those relations use the errors plugin

List item errors

Use itemErrors on a list field to wrap each item in its own result union. It accepts the same options as errors. The following addition to the shared Error interface example yields one success, then throws an error. The plugin returns that error as the final item:

builder.queryField('listWithErrors', (t) =>
  t.intList({
    nullable: false,
    itemErrors: {},
    resolve: function* () {
      yield 1;
      throw new Error('Boom');
    },
  }),
);
type Query {
  listWithErrors: [QueryListWithErrorsItemResult!]!
}

union QueryListWithErrorsItemResult = BaseError | QueryListWithErrorsItemSuccess

type QueryListWithErrorsItemSuccess {
  data: Int!
}

At runtime, itemErrors also handles returned error instances. If the resolver's ordinary return type does not include errors, use an explicit error union for a list that mixes returned data and errors.

The plugin also wraps sync and async iterators. A yielded error becomes an error item; a thrown error becomes the final item and closes the iterator. Async iterable execution requires an executor that supports it, such as GraphQL 17.

Combine errors: {} with itemErrors: {} to handle a whole-field failure as well. The outer QueryListWithErrorsResult union contains BaseError and QueryListWithErrorsSuccess; the latter's data field contains the list of QueryListWithErrorsItemResult items.

For an async source, the same field can use an async generator. Replace its resolver with:

resolve: async function* () {
  yield 1;
  throw new Error('The next item could not be loaded');
},

This requires an executor that supports async iterables. A thrown error ends the iterator; use an explicit error union when the source needs to return an error item and continue.

To handle both a failure before the list is returned and a failure during iteration, replace listWithErrors with:

builder.queryField('listWithErrors', (t) =>
  t.intList({
    nullable: false,
    errors: {},
    itemErrors: {},
    args: { failBeforeLoad: t.arg.boolean() },
    resolve: (_parent, { failBeforeLoad }) => {
      if (failBeforeLoad) throw new Error('Could not load the list');
      return (function* () {
        yield 1;
        throw new Error('Could not load the next item');
      })();
    },
  }),
);

The generated result types are:

union QueryListWithErrorsResult = BaseError | QueryListWithErrorsSuccess

type QueryListWithErrorsSuccess {
  data: [QueryListWithErrorsItemResult!]!
}

union QueryListWithErrorsItemResult = BaseError | QueryListWithErrorsItemSuccess

type QueryListWithErrorsItemSuccess {
  data: Int!
}

Custom error union fields

Use t.errorUnionField and t.errorUnionListField to directly specify all members of the returned union type, including multiple success types and error types.

The following additions use the Error class registered as BaseError in the shared-interface example. Each success shape has an isTypeOf check so Pothos can distinguish plain objects. The resolver bodies simulate create and update outcomes to demonstrate the union branches; replace them with your application's persistence logic.

const CreateResult = builder.objectRef<{ id: string; created: boolean }>('CreateResult').implement({
  isTypeOf: (obj) => typeof obj === 'object' && obj !== null && 'created' in obj,
  fields: (t) => ({
    id: t.exposeString('id'),
    created: t.exposeBoolean('created'),
  }),
});

const UpdateResult = builder.objectRef<{ id: string; updated: boolean }>('UpdateResult').implement({
  isTypeOf: (obj) => typeof obj === 'object' && obj !== null && 'updated' in obj,
  fields: (t) => ({
    id: t.exposeString('id'),
    updated: t.exposeBoolean('updated'),
  }),
});

builder.mutationType({
  fields: (t) => ({
    modifyUser: t.errorUnionField({
      types: [CreateResult, UpdateResult, Error],
      args: {
        id: t.arg.id({ required: true }),
        name: t.arg.string({ required: true }),
        create: t.arg.boolean({ required: true }),
      },
      resolve: (parent, { id, name, create }) => {
        if (name.length < 3) return new Error('Name too short');
        return create ? { id: String(id), created: true } : { id: String(id), updated: true };
      },
    }),
    processUsers: t.errorUnionListField({
      types: [CreateResult, UpdateResult, Error],
      args: { ids: t.arg.idList({ required: true }) },
      resolve: (parent, { ids }) => ids.map((id) =>
        id === 'unknown' ? new Error('User not found') : { id: String(id), updated: true },
      ),
    }),
  }),
});

Type resolution

Union members use standard Pothos type resolution. Registering an error class with builder.objectType provides an instanceof check. Plain object types can supply isTypeOf, as above, or you can pass a custom resolveType in the field's union options. Error instances matched by the plugin are resolved before that custom callback.

For example, this alternative field resolves its success branches explicitly using the CreateResult and UpdateResult types above:

builder.mutationField('previewUserChange', (t) =>
  t.errorUnionField({
    types: [CreateResult, UpdateResult, Error],
    args: { create: t.arg.boolean({ required: true }) },
    union: {
      resolveType: (value) => 'created' in value ? 'CreateResult' : 'UpdateResult',
    },
    resolve: (_parent, { create }) =>
      create ? { id: '1', created: true } : { id: '1', updated: true },
  }),
);

Matched error instances use the plugin's error map, so this callback only needs to distinguish the success values.

Using builder.errorUnion

You can use builder.errorUnion to manually construct an error union type that can be used with any field. Fields returning an error union will automatically handle returned or thrown errors.

This addition to the shared-interface example distinguishes a missing user from invalid input. Both error types implement the shared Error interface, and validation failures expose the field that needs correction:

class NotFoundError extends Error {}

class ValidationError extends Error {
  constructor(message: string, public field: string) {
    super(message);
  }
}

builder.objectType(NotFoundError, {
  name: 'NotFoundError',
  interfaces: [ErrorInterface],
});

builder.objectType(ValidationError, {
  name: 'ValidationError',
  interfaces: [ErrorInterface],
  fields: (t) => ({ field: t.exposeString('field') }),
});

const User = builder.objectRef<{ id: string; name: string }>('User').implement({
  isTypeOf: (obj) => typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj,
  fields: (t) => ({
    id: t.exposeString('id'),
    name: t.exposeString('name'),
  }),
});

const UserResult = builder.errorUnion('UserResult', {
  types: [User, NotFoundError, ValidationError],
});

builder.queryField('getUser', (t) =>
  t.field({
    type: UserResult,
    args: { id: t.arg.string({ required: true }) },
    resolve: (_, { id }) => {
      if (!id) throw new ValidationError('ID required', 'id');
      if (id === 'unknown') return new NotFoundError('User not found');
      return { id, name: 'User' };
    },
  }),
);

Clients can select the common message or the details of a particular failure:

query {
  getUser(id: "") {
    ... on User {
      id
      name
    }
    ... on Error {
      message
    }
    ... on ValidationError {
      field
    }
  }
}

Options

  • types: Array of member types (object refs, error classes, etc.)
  • omitDefaultTypes: Set to true to exclude defaultTypes from the builder options (default: false)
  • resolveType: Optional custom resolve function. Called after the internal error map check.
  • All other standard union type options are supported