Patterns

Handling errors

Throw from resolvers, mask errors in production, and know when to graduate to plugin-errors.

Error handling has three layers, each adding control over what the client sees. Most schemas stop at the first or second; the third is a typed-union approach for when error types are part of the API contract.

Layer 1: throw from the resolver

A resolver that throws produces null for the field and one entry in the response's errors array. The thrown message reaches the client unless your server masks it.

builder.queryType({
  fields: (t) => ({
    team: t.field({
      type: Team,
      args: { id: t.arg.id({ required: true }) },
      resolve: (_root, { id }) => {
        const team = Teams.get(Number(id));
        if (!team) {
          // Plain Error. Yoga returns the message in dev.
          throw new Error(`No team with id ${id}`);
        }
        return team;
      },
    }),
  }),
});

When the field is non-nullable, the executor can't write null into it, so the error propagates up to the nearest nullable ancestor, which becomes null instead; if there is none, the whole response's data is null. This is graphql-js's behavior, the same for every GraphQL server.

Layer 2: mask in production

In development, the server returns the raw thrown message — useful for debugging. In production, you want generic messages so internal details don't leak.

graphql-yoga's maskedErrors option controls this:

// In dev — see the actual message
const yoga = createYoga({
  schema,
  maskedErrors: false,
});
// In prod — replace with "Unexpected error" and log the original
const yoga = createYoga({
  schema,
  maskedErrors: process.env.NODE_ENV !== 'production' ? false : true,
});

Don't ship maskedErrors: false to production. Stack traces, internal IDs, and database error messages all flow through GraphQL errors when masking is off. Default-on with an env-flag escape hatch is the safe pattern.

For Apollo Server, the equivalent is the includeStacktraceInErrorResponses option (default off in production builds); Mercurius wraps errors via errorFormatter.

Layer 3: typed result unions

The first two layers treat errors as exceptions. For errors that are part of the API (a username already taken, a full roster, a missing team), clients are better served by a typed result union:

type Mutation {
  renameTeam(input: RenameTeamInput!): RenameTeamResult!
}

union RenameTeamResult = Team | NotFoundError | PermissionError

The client can switch (result.__typename) and render the right UI for each case.

@pothos/plugin-errors produces a union like this from thrown errors. You list the error classes on the field's errors option, and the plugin generates a union of the success type plus one member per error.

class NotFoundError extends Error {}
class PermissionError extends Error {}

builder.mutationField('renameTeam', (t) =>
  t.field({
    type: Team,
    errors: { types: [NotFoundError, PermissionError] },
    args: { /* ... */ },
    resolve: (_root, args, ctx) => {
      // throw new NotFoundError() or new PermissionError() for expected failures
    },
  }),
);

See plugin-errors for the full setup.

When to graduate

The boundaries are usually clear:

  • Layer 1 alone is enough for prototypes and internal tools.
  • Layer 1 + Layer 2 is the right baseline for production. Throw clear messages, mask them in prod, log the originals server-side.
  • Layer 3 fits when errors are part of the contract: a mutation has expected failure modes that clients render specifically.

You don't have to pick one. Schemas usually mix: most resolvers throw plain Errors and rely on masking; a few mutations use typed unions where the failure modes are well-known.