Skip to content

Commit 8ad5769

Browse files
committed
[errors plugin] Add builder.errorUnion method and support for item errors in nested lists
1 parent 61b642a commit 8ad5769

File tree

10 files changed

+1739
-135
lines changed

10 files changed

+1739
-135
lines changed

.changeset/giant-wolves-look.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pothos/plugin-errors": minor
3+
---
4+
5+
Add builder.errorUnion method and support for item errors in nested lists

packages/plugin-errors/README.md

Lines changed: 189 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Errors Plugin
2+
---
3+
title: Errors plugin
4+
description: Errors plugin docs for Pothos
5+
---
26

37
A plugin for easily including error types in your GraphQL schema and hooking up error types to
48
resolvers
@@ -8,7 +12,7 @@ resolvers
812
### Install
913

1014
```bash
11-
yarn add @pothos/plugin-errors
15+
npm install --save @pothos/plugin-errors
1216
```
1317

1418
### Setup
@@ -239,74 +243,47 @@ builder.queryType({
239243

240244
### With validation plugin
241245

242-
To use this in combination with the validation plugin, ensure that that errors plugin is listed
243-
BEFORE the validation plugin in your plugin list.
246+
To handle validation errors you will need to enable the `unsafelyHandleInputErrors` option in the
247+
errors plugin options. This will allow the errors plugin to catch errors thrown by the validation plugin.
248+
This setting is unsafe because it wraps and catches errors at a higher level which will allow you to
249+
bypass other plugin hooks like the `auth` plugin. This enables you to return structured error responses for
250+
validation issues which happen BEFORE auth checks are executed, but this also means that those auth checks won't be run.
244251

245-
Once your plugins are set up, you can define types for a ZodError, the same way you would for any
246-
other error type. Below is a simple example of how this can be done, but the specifics of how you
247-
structure your error types are left up to you.
252+
Once you enable the `unsafelyHandleInputErrors` option, you can define types for an InputValidationError
253+
(or any custom error you use in the validation plugin), the same way you would for any other error type. Below
254+
is a simple example of how this can be done, but the specifics of how you structure your error types are left up to you.
248255

249256
```typescript
250-
// Util for flattening zod errors into something easier to represent in your Schema.
251-
function flattenErrors(
252-
error: ZodFormattedError<unknown>,
253-
path: string[],
254-
): { path: string[]; message: string }[] {
255-
const errors = error._errors.map((message) => ({
256-
path,
257-
message,
258-
}));
259-
260-
Object.keys(error).forEach((key) => {
261-
if (key !== '_errors') {
262-
errors.push(
263-
...flattenErrors((error as Record<string, unknown>)[key] as ZodFormattedError<unknown>, [
264-
...path,
265-
key,
266-
]),
267-
);
268-
}
269-
});
270-
271-
return errors;
272-
}
273-
274-
// A type for the individual validation issues
275-
const ZodFieldError = builder
276-
.objectRef<{
277-
message: string;
278-
path: string[];
279-
}>('ZodFieldError')
257+
const InputValidationIssue = builder
258+
.objectRef<StandardSchemaV1.Issue>('InputValidationIssue')
280259
.implement({
281260
fields: (t) => ({
282261
message: t.exposeString('message'),
283-
path: t.exposeStringList('path'),
262+
path: t.stringList({
263+
resolve: (issue) => issue.path?.map((p) => String(p)),
264+
}),
284265
}),
285266
});
286267

287-
// The actual error type
288-
builder.objectType(ZodError, {
289-
name: 'ZodError',
268+
builder.objectType(InputValidationError, {
269+
name: 'InputValidationError',
290270
interfaces: [ErrorInterface],
291271
fields: (t) => ({
292-
fieldErrors: t.field({
293-
type: [ZodFieldError],
294-
resolve: (err) => flattenErrors(err.format(), []),
272+
issues: t.field({
273+
type: [InputValidationIssue],
274+
resolve: (err) => err.issues,
295275
}),
296276
}),
297277
});
298278

299-
builder.queryField('fieldWIthValidation', (t) =>
279+
builder.queryField('fieldWithValidation', (t) =>
300280
t.boolean({
301281
errors: {
302-
types: [ZodError],
282+
types: [InputValidationError],
303283
},
304284
args: {
305285
string: t.arg.string({
306-
validate: {
307-
type: 'string',
308-
minLength: 3,
309-
},
286+
validate: z.string().min(3, 'Too short'),
310287
}),
311288
},
312289
resolve: () => true,
@@ -323,8 +300,8 @@ query {
323300
... on QueryValidationSuccess {
324301
data
325302
}
326-
... on ZodError {
327-
fieldErrors {
303+
... on InputValidationError {
304+
issues {
328305
message
329306
path
330307
}
@@ -336,7 +313,7 @@ query {
336313
### With the dataloader plugin
337314

338315
To use this in combination with the dataloader plugin, ensure that that errors plugin is listed
339-
BEFORE the validation plugin in your plugin list.
316+
BEFORE the dataloader plugin in your plugin list.
340317

341318
If a field with `errors` returns a `loadableObject`, or `loadableNode` the errors plugin will now
342319
catch errors thrown when loading ids returned by the `resolve` function.
@@ -350,15 +327,119 @@ handle these types of errors.
350327
### With the prisma plugin
351328

352329
To use this in combination with the prisma plugin, ensure that that errors plugin is listed BEFORE
353-
the validation plugin in your plugin list. This will enable `errors` option to work work correctly
354-
with any field builder method from the prisma plugin.
330+
the prisma plugin in your plugin list. This will enable `errors` option to work correctly with any
331+
field builder method from the prisma plugin.
355332

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

339+
### List item errors
340+
341+
For fields that return lists, you can specify `itemErrors` to wrap the list items in a union type so
342+
that errors can be handled per-item rather than replacing the whole list with an error.
343+
344+
The `itemErrors` options are exactly the same as the `errors` options, but they are applied to each
345+
item in the list rather than the whole list.
346+
347+
```typescript
348+
builder.queryType({
349+
fields: (t) => ({
350+
listWithErrors: t.string({
351+
itemErrors: {},
352+
resolve: (parent, { name }) => {
353+
return [
354+
1,
355+
2,
356+
new Error('Boom'),
357+
3,
358+
]
359+
},
360+
}),
361+
}),
362+
});
363+
```
364+
365+
This will produce a GraphQL schema that looks like:
366+
367+
```graphql
368+
type Query {
369+
listWithErrors: [QueryListWithErrorsItemResult!]!
370+
}
371+
372+
union QueryListWithErrorsItemResult = Error | QueryListWithErrorsItemSuccess
373+
374+
type QueryListWithErrorsItemSuccess {
375+
data: Int!
376+
}
377+
```
378+
379+
Item errors also works with both sync and async iterators (in graphql@>=17, or other executors that support the @stream directive):
380+
381+
```typescript
382+
builder.queryType({
383+
fields: (t) => ({
384+
asyncListWithErrors: t.string({
385+
itemErrors: {},
386+
resolve: async function* () {
387+
yield 1;
388+
yield 2;
389+
yield new Error('Boom');
390+
yield 4;
391+
throw new Error('Boom');
392+
},
393+
}),
394+
}),
395+
});
396+
```
397+
398+
When an error is yielded, an error result will be added into the list, if the generator throws an error,
399+
the error will be added to the list, and no more results will be returned for that field
400+
401+
402+
You can also use the `errors` and `itemErrors` options together:
403+
404+
```typescript
405+
406+
builder.queryType({
407+
fields: (t) => ({
408+
listWithErrors: t.string({
409+
itemErrors: {},
410+
errors: {},
411+
resolve: (parent, { name }) => {
412+
return [
413+
1,
414+
new Error('Boom'),
415+
3,
416+
]
417+
}),
418+
}),
419+
});
420+
```
421+
422+
This will produce a GraphQL schema that looks like:
423+
424+
```graphql
425+
426+
type Query {
427+
listWithErrors: [QueryListWithErrorsResult!]!
428+
}
429+
430+
union QueryListWithErrorsResult = Error | QueryListWithErrorsSuccess
431+
432+
type QueryListWithErrorsSuccess {
433+
data: [QueryListWithErrorsItemResult!]!
434+
}
435+
436+
union QueryListWithErrorsItemResult = Error | QueryListWithErrorsItemSuccess
437+
438+
type QueryListWithErrorsItemSuccess {
439+
data: Int!
440+
}
441+
```
442+
362443
### Custom error union fields
363444
364445
Use `t.errorUnionField` and `t.errorUnionListField` to directly specify all members of the returned union type,
@@ -437,3 +518,57 @@ t.errorUnionField({
437518
resolve: ...
438519
});
439520
```
521+
522+
### Using `builder.errorUnion`
523+
524+
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.
525+
526+
```typescript
527+
builder.objectType(NotFoundError, {
528+
name: 'NotFoundError',
529+
interfaces: [ErrorInterface],
530+
});
531+
532+
builder.objectType(ValidationError, {
533+
name: 'ValidationError',
534+
interfaces: [ErrorInterface],
535+
isTypeOf: (value) => value instanceof ValidationError,
536+
fields: (t) => ({
537+
field: t.exposeString('field'),
538+
}),
539+
});
540+
541+
const UserType = builder.objectRef<{ id: string; name: string }>('User').implement({
542+
isTypeOf: (obj) => 'id' in obj && 'name' in obj,
543+
fields: (t) => ({
544+
id: t.exposeString('id'),
545+
name: t.exposeString('name'),
546+
}),
547+
});
548+
549+
const UserResult = builder.errorUnion('UserResult', {
550+
types: [UserType, NotFoundError, ValidationError],
551+
});
552+
553+
builder.queryField('getUser', (t) =>
554+
t.field({
555+
type: UserResult,
556+
args: { id: t.arg.string({ required: true }) },
557+
resolve: (_, { id }) => {
558+
// Handles thrown errors
559+
if (!id) throw new ValidationError('ID required', 'id');
560+
// Handles returned errors
561+
if (id === 'unknown') return new NotFoundError('User not found');
562+
563+
return { id, name: 'User' };
564+
},
565+
})
566+
);
567+
```
568+
569+
#### Options
570+
571+
- `types`: Array of member types (object refs, error classes, etc.)
572+
- `omitDefaultTypes`: Set to `true` to exclude `defaultTypes` from the builder options (default: `false`)
573+
- `resolveType`: Optional custom resolve function. Called after the internal error map check.
574+
- All other standard union type options are supported

0 commit comments

Comments
 (0)