Skip to main content

Class 5: Errors as Data

Duration: 30 minutes | Difficulty: Intermediate | Prerequisites: Class 4 of this course; familiarity with GraphQL unions and interfaces.

If unions and interfaces are new to you, the Spring GraphQL tutorial's unions class covers them, so you can read it before you start this lesson.


What You'll Learn​

By the end of this class, you will:

  • Explain why the errors list is the wrong home for expected business outcomes
  • Walk the design progression from ad hoc error fields to union result types with a shared interface, knowing each stage's trade-offs
  • Design a mutation result with ...Input in, ...Response out, and typed outcomes inside
  • Recognize the external userErrors convention and know what to borrow from it
  • Apply the decision line: outcomes in the schema, failures in the errors list, and know the exceptions

The wrong tool for "username taken"​

Class 4 explained that "review text too long" is an expected outcome and belongs outside the error codes. This class puts it in the schema, starting with why the errors list handles it badly.

Consider createReview on the Movie Database. A user submits a review whose text exceeds the limit. If the server surfaces this as a GraphQL error, three practical problems follow.

The failure is invisible in the schema. Marc-Andre Giroux's guide to GraphQL errors, the essay whose structure this section follows, puts it exactly:

They are specified, but introspection tells a client nothing about what might be found in them, they're harder to evolve, and harder to extend. The extensions key allows providers to add extra fields but then we are out of the specification, and they need to be documented in an ad hoc way, outside of the schema.

No codegen tool will generate a type for "text too long", and no client will discover it before hitting it at runtime. The frontend team learns the possible outcomes of your mutation from your source code or your wiki.

Propagation removes the result. Class 2's propagation rules work against this design. An error raised in the createReview resolver removes the mutation's result, so the response cannot carry the resource's state. When the result type is non-null, as CreateReviewResponse! is later in this class, the error propagates to the root and the whole data entry becomes null. Giroux again:

This can potentially be a deal breaker if you're wanting to have errors returned as part of a mutation, but want to query for data on the result anyways. A common example of this is the server sending back the actual state of a resource after a mutation that had errors.

Tooling counts it as a failure. Every monitoring layer from Class 3 and every client policy in Class 7 treats errors in the errors list as failures. A user typing a slightly-too-long review is not a failure of your service, and counting it alongside database timeouts poisons every error-rate metric you have.

The community reached a consistent conclusion on this question years ago, and it is documented in sources you can cite in a design review:

  • Marc-Andre Giroux writes that top-level errors "are today generally accepted as a way to represent 'exceptional' errors, and errors that are developer facing". He notes that Lee Byron, GraphQL's co-creator, has stated the same position in comments on the spec repository. He puts the other side just as plainly: "Service unavailable? Syntax errors? Rate limited? Timeout? These types of errors are perfect for 'Top-Level' errors and you should absolutely use them."
  • Sasha Solomon's "200 OK! Error Handling in GraphQL" describes the other group. Outcomes like a suspended user or an image flagged for copyright "aren't really errors ... we got the data we requested". They are alternative results, valid states of the domain.
  • graphql.org's official error-handling guidance now codifies the split in a table: infrastructure failures, invalid GraphQL, and missing authentication are top-level errors; business rule violations, validation failures, and domain constraints are errors-as-data. Its recommended shape for the second group is a userErrors list whose items carry a field path and a code: UserErrorCode!.

The design progression​

Once outcomes move into the schema, they can take several shapes. Giroux's guide presents them as stages, and each stage removes a weakness of the one before it. His first stage is the top-level errors list, which the previous section covered. This section follows the rest of his guide, and its last stage combines his final two: a list of a union, and the same list with a shared interface.

The stages use five terms, each explained again where it first matters:

  • Union. A type whose value is one of a listed set of object types.
  • Interface. A set of fields that several object types declare, so a client can read those fields from any of them.
  • Inline fragment. A selection such as ... on StarsOutOfRange { min }, which applies only when the value has that type.
  • Impossible state. A response the schema allows even though it contradicts itself, such as a created review returned together with the reasons it could not be created. The phrase comes from a talk by Richard Feldman, and Giroux's guide uses it.
  • Deployed client. A client that users already run, which keeps sending the query it was released with while the schema changes.

Every example below models createReview, and only CreateReviewResponse changes from stage to stage. The mutation and its input stay as follows:

type Mutation {
createReview(input: CreateReviewInput!): CreateReviewResponse!
}

input CreateReviewInput {
movieId: ID!
text: String!
stars: Int!
}

This course names mutation inputs ...Input and mutation results ...Response. Giroux's and Shopify's examples use the Relay-style ...Payload suffix, an external convention, and the examples below borrow their shapes under our names.

Each stage sends the same request: a six-star review of movie 999. Reviews take 1 to 5 stars and movie 999 does not exist, so two outcomes apply at once, and each response shows how its stage reports more than one.

The missing movie is an outcome here, and it could be an error in another operation: Class 4's grid lists NOT_FOUND as a client mistake. The deciding question, covered at the end of this class, is whether a well-behaved client on a healthy system can meet the situation as a result of what the user asked for. A user can submit a review for a movie that was deleted while the review form was open, so createReview reports MovieNotFound as an outcome.

The diagram shows the stages in order, with the weakness that leads from each stage to the next:

Ad hoc fields​

The simplest shape adds one field to the response for each outcome. Giroux names two strengths: a client can see every outcome in the schema, and the shape is simple to use.

type CreateReviewResponse {
review: Review
textTooLong: Boolean!
starsOutOfRange: Boolean!
movieNotFound: Boolean!
}

The request selects every flag, and the response sets the two that apply:

Request
mutation CreateReview {
createReview(input: {
movieId: "999"
text: "Loved every minute."
stars: 6
}) {
review {
id
}
textTooLong
starsOutOfRange
movieNotFound
}
}
Response
{
"data": {
"createReview": {
"review": null,
"textTooLong": false,
"starsOutOfRange": true,
"movieNotFound": true
}
}
}

A client shows a message for each flag that is true. The schema allows an impossible state: a response with a review and starsOutOfRange: true at the same time. Only field descriptions or documentation can say that the two exclude each other. Data that belongs to one outcome, such as the maximum text length, becomes one more field next to the flags. Every mutation also gets its own set of flags, so client code has to handle each mutation's outcomes separately.

The larger problem appears when the server adds an outcome, such as one for a user who reviews the same movie twice. A deployed client keeps sending the query above, here for movie 42, which this user has already reviewed, with five stars:

Schema change
type CreateReviewResponse {
review: Review
textTooLong: Boolean!
starsOutOfRange: Boolean!
movieNotFound: Boolean!
alreadyReviewed: Boolean!
}
Response to the deployed client
{
"data": {
"createReview": {
"review": null,
"textTooLong": false,
"starsOutOfRange": false,
"movieNotFound": false
}
}
}

The server did not create the review, and every flag the client selects is false. The client cannot tell the user why, because its query does not select alreadyReviewed.

A list of errors​

The next shape gives every outcome the same type and returns a list of them next to the entity. Shopify's Admin API uses this shape in its userErrors field, and Class 9 follows how that field developed. Shopify's base UserError type carries only a field path and a message, which leaves clients reading message strings, the practice Class 4 warns against. The version in graphql.org's error-handling guide adds a code enum:

type CreateReviewResponse {
review: Review
userErrors: [UserError!]!
}

type UserError {
message: String!
field: [String!]
code: UserErrorCode!
}

enum UserErrorCode {
REVIEW_TEXT_TOO_LONG
STARS_OUT_OF_RANGE
MOVIE_NOT_FOUND
}

The request selects all three fields of each entry:

Request
mutation CreateReview {
createReview(input: {
movieId: "999"
text: "Loved every minute."
stars: 6
}) {
review {
id
}
userErrors {
message
field
code
}
}
}
Response
{
"data": {
"createReview": {
"review": null,
"userErrors": [
{
"message": "Choose 1 to 5 stars.",
"field": ["input", "stars"],
"code": "STARS_OUT_OF_RANGE"
},
{
"message": "Movie 999 does not exist.",
"field": ["input", "movieId"],
"code": "MOVIE_NOT_FOUND"
}
]
}
}
}

field is the path to the input value that caused the outcome, so a form can show each message next to the right input, and client code decides what to do from code. When one UserErrorCode enum serves every mutation in an API, the schema lists all of the API's outcomes without saying which of them createReview returns.

The list makes evolution easier, as Giroux notes:

When the server adds a new possible error, clients that handle a list of errors simply get it for free.

A deployed client that shows every message in the list also shows the message of an outcome added later, without an update. The schema still allows the impossible state, a review next to a non-empty userErrors list. The shared type is also too general for data that belongs to a single outcome, such as the allowed range of stars. In Giroux's words:

For example, adding a userNameSuggestion field make little sense on the generic UserError type.

An error interface​

The interface shape turns UserError into an interface and gives each outcome its own object type that implements it. Each type carries the data its outcome needs, and the type name takes over the job of code:

type CreateReviewResponse {
review: Review
userErrors: [UserError!]!
}

interface UserError {
message: String!
field: [String!]
}

type ReviewTextTooLong implements UserError {
message: String!
field: [String!]
maxLength: Int!
}

type StarsOutOfRange implements UserError {
message: String!
field: [String!]
min: Int!
max: Int!
}

type MovieNotFound implements UserError {
message: String!
field: [String!]
movieId: ID!
}

The request reads message and field from every entry through the interface, and an inline fragment, ... on StarsOutOfRange, reads min and max only from that outcome. __typename tells the client which outcome each entry is:

Request
mutation CreateReview {
createReview(input: {
movieId: "999"
text: "Loved every minute."
stars: 6
}) {
review {
id
}
userErrors {
__typename
message
field
... on StarsOutOfRange {
min
max
}
}
}
}
Response
{
"data": {
"createReview": {
"review": null,
"userErrors": [
{
"__typename": "StarsOutOfRange",
"message": "Choose 1 to 5 stars.",
"field": ["input", "stars"],
"min": 1,
"max": 5
},
{
"__typename": "MovieNotFound",
"message": "Movie 999 does not exist.",
"field": ["input", "movieId"]
}
]
}
}
}

An outcome type added later reaches deployed clients the same way a new list entry did in the previous stage, because its message and field arrive through the interface fields.

The remaining weakness is in the schema: the field type [UserError!]! promises only that every entry implements UserError. Introspection lists every implementation of the interface across the whole API, including the errors of other mutations, and the schema does not say which three of them createReview can return. The schema also still allows the impossible state.

A union result​

The union shape, which Giroux calls result types, makes the mutation return a union. Each response is exactly one member: the created review or one of the outcomes. Sasha Solomon's article, quoted earlier in this class, argues for this shape.

union CreateReviewResponse =
| ReviewCreated
| ReviewTextTooLong
| StarsOutOfRange
| MovieNotFound

type ReviewCreated {
review: Review!
}

type ReviewTextTooLong {
message: String!
field: [String!]
maxLength: Int!
}

type StarsOutOfRange {
message: String!
field: [String!]
min: Int!
max: Int!
}

type MovieNotFound {
message: String!
field: [String!]
movieId: ID!
}

The request needs an inline fragment for each member, because a union does not declare any fields of its own:

Request
mutation CreateReview {
createReview(input: {
movieId: "999"
text: "Loved every minute."
stars: 6
}) {
__typename
... on ReviewCreated {
review {
id
}
}
... on ReviewTextTooLong {
message
field
maxLength
}
... on StarsOutOfRange {
message
field
min
max
}
... on MovieNotFound {
message
field
}
}
}
Response
{
"data": {
"createReview": {
"__typename": "StarsOutOfRange",
"message": "Choose 1 to 5 stars.",
"field": ["input", "stars"],
"min": 1,
"max": 5
}
}
}

ReviewCreated.review can now be non-null, because ReviewCreated appears only when the server created a review, and each response carries either a created review or one outcome. Giroux describes the gain:

With union result types like these, we solve the "Make Impossible States Impossible" problem for earlier. Notice how we can now make all fields non-null because we get the right type in the right context!

The response shows the first weakness: the user made two mistakes, and the response reports only one of them. After fixing the stars and submitting again, the user learns that the movie does not exist.

The second weakness appears when the schema changes. A deployed client reads each member through the inline fragment written for it, so a member added after its release does not match any of those fragments. As Giroux writes:

Clients have no way to adapt to this change beforehand because we're dealing with a union type.

Here is the same deployed client, sending the request above for movie 42 with five stars after the server adds AlreadyReviewed:

Schema change
union CreateReviewResponse =
| ReviewCreated
| ReviewTextTooLong
| StarsOutOfRange
| MovieNotFound
| AlreadyReviewed

type AlreadyReviewed {
message: String!
field: [String!]
}
Response to the deployed client
{
"data": {
"createReview": {
"__typename": "AlreadyReviewed"
}
}
}

The client receives only the name of a type that its code does not handle, so it cannot show the user a message.

A list of a union with a shared interface​

The last shape combines three parts from the earlier stages:

  • A list lets the response report several outcomes at once.
  • A union as the list's element type names the outcomes createReview can return.
  • A shared interface, implemented by every union member, lets a deployed client read message and field from any outcome, including one added after its release.

Giroux reaches this shape in two steps. A list of a union on its own still gives deployed clients only the type name of a new member, as the union result did, and the shared interface removes that weakness.

Here is CreateReviewResponse in full at this stage:

type CreateReviewResponse {
"""The created review; null when userErrors is non-empty."""
review: Review
"""Expected outcomes that prevented creation. Empty on success."""
userErrors: [CreateReviewError!]!
}

union CreateReviewError =
| ReviewTextTooLong
| StarsOutOfRange
| MovieNotFound

interface UserError {
message: String!
field: [String!]
}

type ReviewTextTooLong implements UserError {
message: String!
field: [String!]
maxLength: Int!
}

type StarsOutOfRange implements UserError {
message: String!
field: [String!]
min: Int!
max: Int!
}

type MovieNotFound implements UserError {
message: String!
field: [String!]
movieId: ID!
}

The request reads the interface fields through an inline fragment on UserError, which matches every member of the union, and reads min and max through a fragment on StarsOutOfRange:

Request
mutation CreateReview {
createReview(input: {
movieId: "999"
text: "Loved every minute."
stars: 6
}) {
review {
id
}
userErrors {
__typename
... on UserError {
message
field
}
... on StarsOutOfRange {
min
max
}
}
}
}
Response
{
"data": {
"createReview": {
"review": null,
"userErrors": [
{
"__typename": "StarsOutOfRange",
"message": "Choose 1 to 5 stars.",
"field": ["input", "stars"],
"min": 1,
"max": 5
},
{
"__typename": "MovieNotFound",
"message": "Movie 999 does not exist.",
"field": ["input", "movieId"]
}
]
}
}
}

A client that only displays a form selects the interface fields, and a client that renders a character counter also reads maxLength from ReviewTextTooLong.

When the server adds AlreadyReviewed, the deployed client sends its unchanged query for movie 42 with five stars, and the response carries the new outcome's message:

Schema change
union CreateReviewError =
| ReviewTextTooLong
| StarsOutOfRange
| MovieNotFound
| AlreadyReviewed

type AlreadyReviewed implements UserError {
message: String!
field: [String!]
}
Response to the deployed client
{
"data": {
"createReview": {
"review": null,
"userErrors": [
{
"__typename": "AlreadyReviewed",
"message": "You have already reviewed this movie.",
"field": ["input", "movieId"]
}
]
}
}
}

The fragment on UserError matches AlreadyReviewed, because the new type implements the interface. Giroux summarizes the gain:

Clients can now select on specific errors, but also fallback to the interface contract, meaning they will never miss a new error!

Two rules come with this shape:

  • The resolver keeps review and userErrors apart. Success means review is set and userErrors is empty. The schema still allows both at once, so the resolver has to prevent that impossible state, one of the costs described below.
  • Every union member implements the interface. GraphQL leaves this check to the team. Giroux's guide recommends "a schema linter to make sure that's the case" and names graphql-schema-linter, which has not had a release since May 2022. A schema test in CI catches a missed implementation before it ships, and the Spring course's testing class shows the harness. For linting, GraphQL-ESLint is the maintained option listed on graphql.org.

The cost is verbosity, which Giroux lists under the heading "Quite Verbose!":

This solution does have a lot of moving pieces, and adds a lot of types to a schema.

Comparing the stages​

The table compares the five stages on the properties the examples showed:

StageThe schema lists each outcomeOutcome-specific fieldsSeveral outcomes in one responseDeployed clients handle new outcomesRules out impossible states
Ad hoc fieldsYesLimitedYesNoNo
A list of errorsAPI-wide onlyNoYesYesNo
An error interfaceAPI-wide onlyYesYesYesNo
A union resultYesYesNoNoYes
A list of a union with a shared interfaceYesYesYesYesNo

Giroux ends his guide by saying that the most structured shape is not always the right one:

More is not always better and it's very possible you'll end up picking something between ad-hoc errors and a fully fledged union type + interface.

He suggests a structured shape that is easy to evolve for a public API, and says that an internal API with a single client may be able to use a simpler shape for a long time.

The honest costs​

Errors-as-data is this course's recommendation for expected outcomes, and it has three costs.

Verbosity. The createReview example above is thirty-odd lines of schema for one mutation. A schema with eighty mutations at this rigor carries hundreds of outcome types. Code-first servers can generate the boilerplate, and Class 6 shows the Spring side, but the schema a client reads is genuinely bigger.

Exhaustiveness is now the client's job. With top-level errors, a lazy client ignores errors and shows a spinner forever; with unions, a lazy client that selects only the success member silently drops outcomes. The interface fallback softens this but cannot force selection; clients must be written, and reviewed, to handle the list.

The server must be disciplined. Every outcome that moves into the schema is one the resolver must now catch and translate, so that it never escapes as an exception. One that does escape arrives in the errors list with the wrong semantics. Per Class 2, it also removes the result: CreateReviewResponse! is non-null, so the error propagates to the root and data becomes null. The boundary in Class 6 (business outcomes returned as values, failures resolved into classified errors) has to be enforced in code review, because the framework leaves that check to the team.

When not to use errors-as-data​

The line works in both directions: moving failures into the schema is as wrong as moving outcomes out of it. These failures stay in the errors list:

  • Authentication. An unauthenticated request should fail uniformly everywhere, which is exactly what extensions.code: UNAUTHENTICATED plus (per Class 3) a 401 provides. The official graphql.org table agrees, filing "Authentication required" under top-level errors.
  • Infrastructure failures. A RatingsServiceDown union member would force every client to handle, per mutation, a condition their input never caused and that they can only answer with a retry. UNAVAILABLE already communicates that uniformly.
  • Everything else in Class 4's quadrants, such as rate limiting and request malformation, stays where Class 4 put it.

The test that decides every case: could this happen to a well-behaved client on a healthy system as a consequence of what the user asked for? If yes, it is an outcome and goes in the schema. If no, it is a failure and goes in the errors list with a code.

Summary​

This lesson covered why expected outcomes belong in the schema, how to model them, and where the pattern stops:

  • Outcomes in the errors list. They vanish from the schema and from introspection, propagation removes the very result that should carry the resource's state, and every metric and client policy miscounts them as failures.
  • The consensus. Giroux's guide, Solomon's alternative-results argument, Lee Byron's comments, and now graphql.org's official guidance reserve top-level errors for exceptional, developer-facing failures and put expected outcomes in the schema.
  • The design ladder. It runs from ad hoc fields through a list of errors and an error interface to a union result, and each stage removes a weakness of the one before. The strongest general shape is a list of a union whose members share an interface. It reports several outcomes, names them in the schema, and still gives deployed clients a message for new outcomes, at a real cost in verbosity.
  • The mutation shape. This course uses CreateReviewInput in and CreateReviewResponse out, with userErrors: [CreateReviewError!]! inside; the ...Payload naming seen elsewhere is an external convention.
  • The costs. Bigger schemas, exhaustiveness pushed to clients, and server discipline to keep outcomes out of the exception path.
  • The limits. Auth, infrastructure, and every Class 4 failure quadrant stay in the errors list; the deciding question is whether the situation is a consequence of the user's request on a healthy system.

Further Reading​

What's Next​

The schema now declares every expected outcome, and Class 6 implements the server side of that contract. That means Spring GraphQL's exception resolvers, the mapping from Class 4's error taxonomy to ErrorType and extensions.code, masking by default, and the controller discipline that keeps business outcomes out of the exception path.