Errors plugin
Turn thrown and returned errors into typed result unions, with per-field, per-item, and custom union handling.
Some failures are part of your API: "team not found," "name already taken," "insufficient funds." The errors plugin turns those into typed result unions instead of entries in the GraphQL errors array, so clients can switch on __typename and render each case. You register each error class as a Pothos object type, list those classes on a field's errors option, and the plugin wraps the field in a union of a success type plus one member per error.
This is the third layer described in Handling errors. Use it when a field's failure modes are worth spelling out in the schema.
Install
npm install --save @pothos/plugin-errorsSet target to es6 or higher in your tsconfig.json. The plugin matches thrown errors with instanceof, which breaks under the default es3 target because TypeScript rewrites the prototype chain for classes that extend Error.
A field with errors
Add errors: { types: [...] } to any field. The resolver throws as usual; the plugin catches instances of the listed classes and resolves them to their object type.
const builder = new SchemaBuilder({
plugins: [ErrorsPlugin],
errors: {
defaultTypes: [],
// onResolvedError: (error) => console.error('Handled error:', error),
},
});
const Team = builder.objectRef<ITeam>('Team').implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
}),
});
builder.objectType(Error, {
name: 'Error',
fields: (t) => ({
message: t.exposeString('message'),
}),
});
builder.queryType({
fields: (t) => ({
team: t.field({
type: Team,
errors: {
types: [Error],
},
args: {
id: t.arg.id({ required: true }),
},
resolve: (_parent, { id }) => {
const team = Teams.get(Number(id));
if (!team) {
throw new Error(`No team with id ${id}`);
}
return team;
},
}),
}),
});The team field no longer returns Team directly. The plugin replaces its type with a generated union:
type Team {
id: ID!
name: String!
}
type Error {
message: String!
}
type Query {
team(id: ID!): QueryTeamResult
}
union QueryTeamResult = Error | QueryTeamSuccess
type QueryTeamSuccess {
data: Team!
}The successful value moves under a data field on a generated QueryTeamSuccess type; each error class becomes a sibling union member. Clients select the branch they need with inline fragments:
query {
team(id: "1") {
__typename
... on QueryTeamSuccess {
data {
id
name
}
}
... on Error {
message
}
}
}Prefer throwing: a thrown value is untyped, so the resolver's return type stays Team. The plugin also unwraps errors you return rather than throw, but returning an Error widens the resolver's return type. Save the return form for errorUnion and errorUnionField below, which type the errors into the signature.
A shared Error interface
Listing raw Error on every field works, but it forces clients to know each concrete type. To avoid that, define an Error interface that every error implements, plus a base type in defaultTypes so it is merged into every field automatically. Clients can then always fall back to ... on Error { message } and add narrower fragments only for the errors they render specifically, which keeps adding new error types from breaking existing queries.
const builder = new SchemaBuilder({
plugins: [ErrorsPlugin],
errors: {
// BaseError is merged into every field that opts into error handling.
defaultTypes: [Error],
},
});
// One interface every error type implements. Clients can always fall back
// to `... on Error { message }` and add narrower fragments when they care.
const ErrorInterface = builder.interfaceRef<Error>('Error').implement({
fields: (t) => ({
message: t.exposeString('message'),
}),
});
// The catch-all error, registered in defaultTypes above.
builder.objectType(Error, {
name: 'BaseError',
interfaces: [ErrorInterface],
});
class NotFoundError extends Error {
constructor(public readonly id: string) {
super(`No team with id ${id}`);
this.name = 'NotFoundError';
}
}
builder.objectType(NotFoundError, {
name: 'NotFoundError',
interfaces: [ErrorInterface],
fields: (t) => ({
id: t.exposeString('id'),
}),
});A field opts into just the defaults with errors: {}, or adds its own on top; errors: { types: [NotFoundError] } handles both NotFoundError and the default BaseError. Because both implement the Error interface, one ... on Error { message } fragment covers every branch.
Field options
The errors option on a field accepts:
| Option | Purpose |
|---|---|
types | Error classes to catch on this field. Merged with the builder's defaultTypes. |
result | Options for the generated success object type. Standard object-type options plus a name to rename it. |
dataField | Options for the success type's payload field. Standard field options plus a name (default data). |
union | Options for the generated union type. Standard union options plus a name. |
directResult | Non-list fields only. Puts the field's own object type into the union directly instead of wrapping it in a …Success type. Throws at build time if the field type is not an object type. |
For example, directResult: true flattens QueryTeamResult = Error | QueryTeamSuccess into QueryTeamResult = Error | Team, dropping the data indirection when the payload is already an object type.
Builder options
Pass an errors object when constructing the builder to set defaults for every field:
| Option | Purpose |
|---|---|
defaultTypes | Error classes included on every field that uses errors or itemErrors. |
directResult | Default for the field-level directResult option (non-list fields only). |
onResolvedError | Called with each error the plugin handles; the hook for logging or metrics. It never fires for errors the plugin doesn't catch. |
defaultResultOptions | Defaults for every generated success type, including a name function to control naming. |
defaultUnionOptions | Defaults for every generated union type, including a name function. |
defaultItemResultOptions | Like defaultResultOptions, but for the per-item success types created by itemErrors. |
defaultItemUnionOptions | Like defaultUnionOptions, but for the per-item union types created by itemErrors. |
unsafelyHandleInputErrors | Lets the plugin catch errors thrown during argument validation. See Validation errors; it has security implications. |
The name functions receive { parentTypeName, fieldName } and return the generated type name, so you can rename every result and union type in one place:
const builder = new SchemaBuilder({
plugins: [ErrorsPlugin],
errors: {
defaultTypes: [Error],
defaultResultOptions: {
name: ({ fieldName }) => `${fieldName}Success`,
},
defaultUnionOptions: {
name: ({ fieldName }) => `${fieldName}Result`,
},
},
});Per-item list errors
For a list field, a single failure normally nulls or errors the whole list. Use itemErrors instead of errors to wrap each item in its own union, so one bad row surfaces in place while the rest resolve. The options are identical to errors; they apply per item. Return an Error (or a listed subclass) in the array to mark that slot as failed. That return works at runtime, but itemErrors reuses the plain field-option types, so the resolver's return type stays ITeam[], and strict TypeScript may need a cast or a widened return annotation, the same throw-vs-return trade-off as field-level errors.
builder.queryType({
fields: (t) => ({
standings: t.field({
type: [Team],
itemErrors: {},
resolve: () => [
{ id: 1, name: 'Comet' },
new Error('Aurora withdrew'),
{ id: 3, name: 'Vertex' },
],
}),
}),
});type Query {
standings: [QueryStandingsItemResult!]!
}
union QueryStandingsItemResult = Error | QueryStandingsItemSuccess
type QueryStandingsItemSuccess {
data: Team!
}itemErrors also works with sync and async iterators (with graphql@>=17 or any executor that supports the @stream directive). A yielded error becomes an error item; if the generator itself throws, that error is added as the final item and no further results are produced for the field:
builder.queryType({
fields: (t) => ({
liveScores: t.field({
type: ['Int'],
itemErrors: {},
resolve: async function* () {
yield 3;
yield 5;
yield new Error('feed dropped');
yield 8;
},
}),
}),
});Combine errors and itemErrors to handle both a failure of the whole field and failures of individual items. The field gets an outer result union whose success payload is itself a list of per-item unions:
builder.queryType({
fields: (t) => ({
standings: t.field({
type: [Team],
errors: {},
itemErrors: {},
resolve: () => [{ id: 1, name: 'Comet' }, new Error('Aurora withdrew')],
}),
}),
});type Query {
standings: QueryStandingsResult!
}
union QueryStandingsResult = Error | QueryStandingsSuccess
type QueryStandingsSuccess {
data: [QueryStandingsItemResult!]!
}
union QueryStandingsItemResult = Error | QueryStandingsItemSuccess
type QueryStandingsItemSuccess {
data: Team!
}Custom error unions
When a field has more than one success type, t.errorUnionField (and t.errorUnionListField for lists) lets you spell out every union member yourself, mixing success types and error types. Errors are returned here, so they're part of the resolver's typed return:
const CreateResult = builder.objectRef<{ id: string; created: true }>('CreateResult').implement({
isTypeOf: (obj) => 'created' in obj,
fields: (t) => ({
id: t.exposeString('id'),
created: t.exposeBoolean('created'),
}),
});
const UpdateResult = builder.objectRef<{ id: string; updated: true }>('UpdateResult').implement({
isTypeOf: (obj) => 'updated' in obj,
fields: (t) => ({
id: t.exposeString('id'),
updated: t.exposeBoolean('updated'),
}),
});
builder.mutationType({
fields: (t) => ({
registerTeam: t.errorUnionField({
types: [CreateResult, UpdateResult, ValidationError],
resolve: (_parent, { name, action }) => {
if (name.length < 3) return new ValidationError('Name too short');
if (action === 'create') return { id: '123', created: true };
return { id: '123', updated: true };
},
}),
processRegistrations: t.errorUnionListField({
types: [CreateResult, UpdateResult, ValidationError],
resolve: (_parent, { operations }) =>
operations.map((op) =>
op.invalid
? new ValidationError('Invalid')
: op.action === 'create'
? { id: op.id, created: true }
: { id: op.id, updated: true },
),
}),
}),
});Union members are resolved with standard Pothos type resolution, so you have three ways to tell the members apart:
- Class-based types resolve automatically via
instanceof. Most error types fall here;builder.objectType(ValidationError, …)needs noisTypeOf. isTypeOfdiscriminates plain object types, asCreateResultandUpdateResultdo above.- A custom
resolveTypeon theunionoption handles anything more involved. It runs after the plugin's internal error lookup:
t.errorUnionField({
types: [CreateResult, UpdateResult, ValidationError],
union: {
resolveType: (value) => {
if (value instanceof ValidationError) return 'ValidationError';
if ('created' in value) return 'CreateResult';
return 'UpdateResult';
},
},
resolve: () => {
/* ... */
},
});Manual error unions
builder.errorUnion builds a reusable union type up front, for several fields that share the same success-or-error shape. Any field that returns it automatically handles both returned and thrown errors.
const TeamResult = builder.errorUnion('TeamResult', {
types: [Team, NotFoundError, ValidationError],
});
builder.queryField('team', (t) =>
t.field({
type: TeamResult,
args: { id: t.arg.string({ required: true }) },
resolve: (_parent, { id }) => {
// thrown errors are wrapped
if (!id) throw new ValidationError('id required', 'id');
// returned errors are wrapped too
if (id === 'unknown') return new NotFoundError('team not found');
return { id, name: 'Comet' };
},
}),
);errorUnion accepts:
types: the member types (object refs, error classes, and so on).omitDefaultTypes: settrueto exclude the builder'sdefaultTypesfrom this union (defaultfalse).resolveType: an optional custom resolver, called after the plugin's internal error-map check.- Every other standard union type option.
Working with other plugins
Validation errors
The validation plugin throws before your resolver runs, so those errors bypass errors by default. Enabling unsafelyHandleInputErrors lets the plugin catch them and return structured results for validation failures.
unsafelyHandleInputErrors wraps errors at a higher level than field resolution, which means it runs before field auth checks, so a request that fails validation returns a typed error without those auth checks running. Enable it only when you understand that trade-off.
With it enabled, define a type for your validation error the same way you would any other, then list it on the field:
const InputValidationIssue = builder
.objectRef<StandardSchemaV1.Issue>('InputValidationIssue')
.implement({
fields: (t) => ({
message: t.exposeString('message'),
path: t.stringList({
resolve: (issue) => issue.path?.map((p) => String(p)),
}),
}),
});
builder.objectType(InputValidationError, {
name: 'InputValidationError',
interfaces: [ErrorInterface],
fields: (t) => ({
issues: t.field({
type: [InputValidationIssue],
resolve: (err) => err.issues,
}),
}),
});
builder.queryField('registerTeam', (t) =>
t.boolean({
errors: {
types: [InputValidationError],
},
args: {
name: t.arg.string({
validate: z.string().min(3, 'Too short'),
}),
},
resolve: () => true,
}),
);Dataloader
List the errors plugin before the dataloader plugin. A field whose errors returns a loadableObject or loadableNode will then catch errors thrown while loading the ids returned by resolve.
List fields are the exception: errors while loading objects from a list of ids are associated with each item, not the field, so the plugin does not wrap them. A future dataloader option may raise a field-level error when any item fails to load, which would let the errors plugin handle that case too.
Prisma
List the errors plugin before the Prisma plugin so errors works with every Prisma field-builder method.
You can put errors on any field, but an error while pre-loading a relation always surfaces on the field that ran the query. Some relations fall back to their own query, so those fields may still error if the relation wasn't pre-loaded. Nested-relation detection keeps working when those relations use the errors plugin themselves.