🌱 This post is still growing and likely will change as best practices evolve 🌳

GraphQL errors are something many of us struggle with. Good practices are emerging but the community has not yet settled on a convention. I hope this guide helps demystify the many ways you can structure errors in your GraphQL servers, and the tradeoffs each of them make.

Stage 1: GraphQL Errors AKA Top-Level Errors

{
"data": {
"createProduct": null
},
"errors": [
{
"path": [
"createProduct"
],
"locations": [
{
"line": 2,
"column": 3
}
],
"message": "Could not resolve to a node with the global id of 'shop-that-does-not-exist'"
}
]
}

❗️Con: No Schema

❗️Con: Nullability

❗️Con: Exceptional Only

GraphQL errors encode exceptional scenarios — like a service being down or some other internal failure. Errors which are part of the API domain should be captured within that domain.

The general philosophy at play is that Errors are considered exceptional. Your user data should never be represented as an Error. If your users can do something that needs to provide negative guidance, then you should represent that kind of information in GraphQL as Data not as an Error. Errors should always represent either developer errors or exceptional circumstances (e.g. the database was offline).

While it’s possible to add extensions to make these errors better to work with for end user errors, the fact these errors have no schema and that they can’t often be collocated with data makes them a less than ideal choice for these use cases.

✅Pro: Great for Developer Errors

✅Pro: As Close of a Convention We’ve Got!

Stage 2: Ad Hoc Error Fields

type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload
}
# 💡The "MutationPayload" wrapper type is a common convention
# initially specified by the GraphQL client Relay
type CreateUserPayload {
user: User
userNameWasTaken: Boolean!
userNameAlternative: String!
}

In this example we’ve added two fields to a mutation payload type that helps us deal with errors. The createUser may error out when the username someone wants to create already exists. In this case we want to display an error and also propose a better username based on what they entered.

✅Pro: Discoverability

✅Pro: Simplicity

❗️Con: Impossible States

While we may know that intuitively, the schema doesn’t really tell us that. In theory, userNameWasTaken could be true, and user could be there as well. What does this mean? It's unclear.

This behavior instead probably needs to be described in documentation or descriptions on fields, which is never ideal when we’ve got a schema in the first place!

❗️Con: Consistency

Stage 3: Error Array

type CreateUserPayload {
user: User
userErrors: [UserError!]!
}
type UserError {
# A description of the error
message: String!
# A path to the input value that caused the error
path: [String!]
}

A small note on the UserError.path field

For example, imagine I try to create a user where the usernameis already taken:

mutation {
createUser(input: { username: "xuorig" }) {
user
userErrors {
message
path
}
}
}

We would get a response that looks like this:

{
"data": {
"createUser": {
"user": null,
"userErrors": [{
"message": "Username `xuorig` was already taken.",
"path": ["input", "username"]
}]
}
}
}

Notice how path points to the username input field we had used. This is really helpful for clients, for example by pointing to the form field that caused the error. OK, back to the error array.

This is one of the first ways to do errors in a more structured way that I’ve encountered, one we used at Shopify back in the days 👴 (since then their approach has evolved).

✅Pro: Consistency

✅Pro: Evolution

❗️Con: Impossible States

❗️Con: User has to select

As a side-note, one idea I’ve been playing with is the idea of something like a @mustSelect schema directive / error. Certain fields could be annotated in a way to produces an error if a client does not select the field.

type CreateUserPayload {
userErrors: [UserError!]! @mustSelect
}

If the client is really not interested in the field, it has to opt-out explicitly:

mutation {
createUser(input: {}) @ignoreSelection(fields: ["userErrors"]) {
user
}
}

The cool thing about this is that we don’t force the response to include the userErrors field, which would ruin GraphQL's declarativeness. Instead, we hope the user will discover the error at dev time allowing them to explicitly opt-out or select the field

❗️Con: Custom Error Fields

Stage 4: Error Interface

type CreateUserPayload {
user: User
userErrors: [UserError!]!
}
type UserNameTakenError implements UserError {
userNameSuggestion: String
message: String!
path: [String!]
}
type SomeOtherError implements UserError {}interface UserError {
# A description of the error
message: String!
# A path to the input value that caused the error
path: [String!]
}

✅Pro: Evolution

mutation {
createUser(input: {}) {
user { id }
userErrors {
message
path
... on UserNameTakenError {
userNameSuggestion
}
}
}
}

❗️Con: Hard to See What Errors May Happen

There are a few ways around this:

  1. A) We could keep it a bit more generic and have a list of UserCreationError instead.
  2. B) We could have a more specific UserCreation interface, + the UserError interface
  3. C) We could look at Union types instead.

Stage 5: Result Types

Error unions are great because they are a really expressive way of structuring our schema. It lets clients see right away what could happen when querying or mutating a resource.

type Mutation {
createUser(input: CreateUserInput): CreateUserResult
}
union CreateUserResult = UserCreated | UserNameTakentype UserCreated {
user: User!
}
type UserNameTaken {
message: String!
suggestion: String!
}

✅Pro: No Impossible States

✅Pro: Works Great on the Query Side

✅Pro: Discoverability

🟡Warning: Potentially Hard to Implement

❗️Con: Hard to Evolve

mutation {
createUser(input: {}) {
... on UserCreated {
user { id }
}
... on UserNameTaken {
message
}
}
}

What if a new error type is added? Like password too short? Clients have no way to adapt to this change beforehand because we’re dealing with a union type.

❗️Con: Multiple Errors

  1. A) By having a more generic UserCreationError which can host multiple sub errors
  2. B) Going for a userErrors list type, which uses a union

Stage 6: Error Union List

type CreateUserPayload {
user: User
userErrors: [CreateUserError!]!
}
union CreateUserError = UserNameTaken | PasswordTooShort | MustBeOver18type UserNameTaken {
message: String!
suggestion: String!
}

✅Pro: Expressive and Discoverable Schema

✅Pro: Support for Multiple Errors

❗️Con: Hard to Evolve

Stage 6a: Error Union List + Interface

type CreateUserPayload {
user: User
userErrors: [CreateUserError!]!
}
union CreateUserError = UserNameTaken | PasswordTooShort | MustBeOver18type UserNameTaken implements UserError {
message: String!
path: String!
suggestion: String!
}
interface UserError {
message: String!
path: String!
}

Make sure all of your union members implement a common interface for this solution to work. I recommend you look into a schema linter to make sure that’s the case. graphql-schema-linter is great for this.

✅Pro: Expressive and Discoverable Schema

✅Pro: Support for Multiple Errors

✅Pro: Easier Evolution

mutation {
createUser(input: {}) {
user { id }
userErrors {
# Specific cases
... on UserNameTaken {
message
path
suggestion
}
# Interface contract
... on UserError {
message
path
}
}
}
}

❗️Con: Quite Verbose!

Final Stage: Picking The Best Thing for You

I’ll keep working on this post as new solutions arise, in the meantime, happy schema building!

If you’ve enjoyed this post, you might like the Production Ready GraphQL book, which I have just released!

Thanks for reading 💚

#GraphQL Enthusiast, Speaker, Platform Interface Engineer @ GitHub 📖 Book is now available https://book.productionreadygraphql.com/

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store