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 | PermissionErrorThe 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.