Bringing 'True' Nullability to GraphQL

Benjie Gillam

Want to skip the history lesson and get to the good stuff? Jump to Introducing the onError request property.

One of GraphQL’s early decisions was to allow “partial success”; this was a critical feature for Facebook: if one part of their backend infrastructure became degraded, they wouldn’t want to just render an error page; instead, they wanted to serve the user a page with as much working data as they could.

Null propagation

To accomplish this, if an error occurred within a resolver, that field’s response position would be replaced with null, and an error would be added to the errors array in the response.

But what if that position was marked as non-null?

To solve that apparent contradiction, GraphQL introduced “error propagation” (aka “null bubbling”): when a null occurs in a non-nullable position, the parent position is made null instead. If that position is also non-nullable, its parent will be made null instead, and so on up the tree until a nullable position is made null.

This solved the issue and meant that GraphQL’s nullability promises were still honoured, but it wasn’t without complications…

Complication 1: partial success

We want to be resilient to systems failing, but errors that occur in non-nullable positions cascade to surrounding parts of the query, making less and less data available to be rendered.

This seems contrary to our “partial success” aim, but it’s easy to solve: we just make sure that the positions where we expect errors to occur are nullable so that errors don’t propagate further.

Unfortunately, this means clients now need null-handling code in a few more places, but what is engineering if not choosing trade-offs?

Complication 2: nullable epidemic

So… where are errors likely to occur?

Almost any field in your GraphQL schema could raise an error. Errors might not only be caused by backend services becoming unavailable or responding in unexpected ways; they can also be caused by simple programming errors in your business logic, data consistency errors (e.g., expecting a boolean but receiving a float), access controls, or any other cause.

Since we don’t want to “blow up” the entire response if any such issue occurs, we’ve come to strongly encourage nullable usage throughout a schema, only adding the non-nullable ! marker to positions where we’re truly sure that the field is extremely unlikely to error.

This “nullable by default” approach means that developers consuming the GraphQL API have to handle potential nulls in more positions than they would expect, with an explosion of null checks leading people to even call into question the value of GraphQL’s “type safety”.

Complication 3: normalised caching

Many modern GraphQL clients use a “normalised” cache, such that updates pulled down from the API in one query can automatically update all the previously rendered data across the application. This helps ensure consistency for users and is a powerful feature.

However, if an error occurs in a non-nullable position, it’s no longer safe to store the data in the normalised cache.

The Nullability Working Group

At first, we thought the solution to these complications was to give clients control over the nullability of each field in the response, so we set up the Client-Controlled Nullability (CCN) Working Group. Later, we renamed the working group to the Nullability WG to show that it encompassed all potential solutions to this problem.

Client-controlled nullability

The first Nullability WG proposal came from a collaboration between Yelp and Netflix, with contributions from GraphQL WG regulars Alex Reilly, Mark Larah, and Stephen Spalding, among others. They proposed that we could adorn the queries we issue to the server with sigils indicating our desired nullability overrides for the given fields: client-controlled nullability.

A ? would be added to fields where we don’t mind if they’re null but we definitely want errors to stop there, and a ! would be added to fields where we definitely don’t want a null to occur (whether or not there is an error). This would give consumers control over where errors/nulls were handled.

However, after much exploration of the topic over the years, we found numerous issues that traded one set of concerns for another. We kept iterating whilst we looked for a solution to these trade-offs.

True nullability schema

Jordan Eldredge proposed that making fields nullable to handle error propagation was hiding the “true” nullability of the data. Instead, he suggested, we should have the schema represent the true nullability, and put the responsibility on clients to use the ? CCN operator to handle errors in the relevant places.

However, this would mean that clients such as Relay would want to add ? in every position, causing an “explosion” of question marks, because really what Relay desired was to disable null propagation entirely.

A new type

Getting the relevant experts together at GraphQLConf 2023 re-energised the discussions and sparked new ideas. After seeing Stephen’s “Nullability Sandwich” talk and chatting with Jordan, Stephen, and others in the corridor, Benjie Gillam was inspired to propose a “null only on error” type. This type would allow us to express the “true” nullability of a field whilst also indicating that errors may occur and should be handled without “blowing up” the response.

To maintain backwards compatibility, clients would need to opt in to seeing this new type (otherwise it would masquerade as nullable). It would be up to the client to decide how to handle the nullability of this position, knowing that a “null only on error” position would only contain a null if a matching error existed in the errors list.

A number of alternative syntaxes were suggested for this new type, but none were well-liked.

A new approach to client error handling

Also around the time of GraphQLConf 2023, the Relay team shared a presentation on some of the things they were thinking around errors. In particular, they discussed the @catch directive, which would give users control over how errors were represented in the data being rendered, allowing the client to differentiate an error from a legitimate null. Over the coming months, many behaviours were discussed at the Nullability WG; one particularly compelling one was that clients could throw the error when an errored field was read and rely on framework mechanics (such as React’s error boundaries) to handle those errors.

Strict semantic nullability

GraphQL Foundation director Lee Byron proposed that we introduce a schema directive, @strictNullability, whereby we would change what the syntax meant: Int? for nullable, Int for null-only-on-error, and Int! for never-null. This proposal was well-liked, but wasn’t a clear win; it introduced many complexities, including migration costs and concerns over schema evolution.

A pivotal discussion

Lee and Benjie had a call where they discussed the history of GraphQL nullability and all the relevant proposals in depth, including their two respective solutions. It was clear that, though no solution was quite there, the solutions were converging, hinting that we were getting closer and closer to an answer.

This long, detailed, highly technical discussion ultimately led to the realisation that error propagation itself was the issue. Rather than working around error propagation with new “null only on error” types, what we really needed was a way for “smart clients” to turn off error propagation entirely.

@experimental_disableErrorPropagation

Our first punt at this was the @experimental_disableErrorPropagation directive that could be added to operations to disable error propagation. However, we quickly realised that this was cumbersome and inconsistent, and that disabling error propagation would also become the responsibility of the developer rather than the client. A smart client that understands the schema should be able to fully re-implement traditional error propagation locally: data and errors contain all the information it would need to do so. And if a client supports this, it would want to disable error propagation for every request…

Introducing the onError request property

The new onError request property allows a client to indicate its preference for how errors are handled by the GraphQL service (each option is detailed below). For example:

 POST /graphql HTTP/1.1
 Host: example.com
 Content-Type: application/json
 Accept: application/graphql-response+json
 
 {
   "query": "query UserProfile($id: ID!) { user(id: $id) { id name avatarUrl bestFriend { name } } }",
+  "onError": "NULL",
   "variables": { "id": "27" }
 }

Services that support onError must honour the specified behaviour. If a service does not support onError, the request property will be ignored, resulting in the traditional behaviour, equivalent to onError: "PROPAGATE".

Services will soon be able to indicate their support for the onError request property through service capabilities), allowing clients to auto-discover and depend upon this capability.

onError: "PROPAGATE"

This is the traditional error propagation behaviour that we all know and… “love”?

Setting onError: "PROPAGATE" will be equivalent to the error behaviour in the initial 2015 GraphQL Specification: error propagation/null bubbling.

onError: "ABORT"

Ad-hoc scripts and similar clients throw away entire responses if any error occurs, but currently the server still computes the “partial success” response anyway. This mode allows clients to indicate that if any error occurs, they won’t read the data: any error should result in a { data: null, errors: [...] } response, allowing the service to abort execution when the first error occurs.

onError: "NULL"

Clients take responsibility for interpreting the response as a whole… ensuring application code can never read an “error null”

This is what we’re excited about!

onError: "NULL" completely disables error propagation within the GraphQL service. From an error perspective, every position in the response (fields and lists alike) is an error boundary, as though it were nullable. This effectively changes the “non-nullable” type modifier to mean “null only on error”.

Clients that opt in to this behaviour take responsibility for interpreting the response as a whole, correlating the data and errors properties of the response. They must cross-check any null values against errors to ensure the application can never read an “error null”1 as if it were a “semantic null”2.

”Smart” clients

onError: "NULL" is intended for use by “smart” clients such as Relay, Apollo Client, URQL, and others that understand GraphQL deeply and are responsible for the storage and retrieval of fetched GraphQL data. These clients are well positioned to handle the responsibilities outlined above.

By disabling error propagation, these clients will be able to safely update their stores (including normalised stores) even when errors occur. They can also re-implement traditional GraphQL error propagation on top of these new foundations, shielding application developers from needing to learn this new behaviour (whilst still allowing them to reap the benefits!). They can even take on advanced behaviours, such as throwing the error when the application developer attempts to read from an errored field, allowing the developer to handle errors with their system’s native error boundaries: try/catch, raise/except, or <ErrorBoundary />.

”Error-handling clients”

An error-handling client is a client that ensures that an “error null” can never be read by application code. A client that throws if an errors property exists in a response is an error-handling client; the data can never be read, so no “error nulls” can be read. Clients that implement error-handling behaviours such as throw-on-error at the field or fragment level also prevent application code from reading “error nulls” and are therefore also error-handling clients.

Many clients, including window.fetch(), Apollo Client, URQL, and graffle, can be made into error-handling clients by integrating something like graphql-toe (Throw On Error): a 0.5kB library that can be added to JavaScript projects and uses accessors so that, when application code attempts to read from an errored field, that error is thrown and can be processed by traditional error handling (try/catch or <ErrorBoundary />).

True nullability

For clients using onError: "NULL", fields are either nullable or non-nullable, just as with traditional error propagation. However, unlike with traditional propagation, errors can be represented in any position:

  • nullable (e.g., Int): a value, an error, or a true null;
  • non-nullable (e.g., Int!): a value, or an error.

(With traditional error propagation, non-nullable fields cannot represent an error because the error propagates to the nearest nullable position. Not so with onError: "NULL"!)

Greenfield services

If a GraphQL service can guarantee it will never need to perform error propagation (for example, by requiring all clients to include onError: "NULL" or onError: "ABORT" in requests), then the schema can safely indicate to clients the true intended nullability of a field in the traditional way: with a !:

type User {
  id: ID!
  username: String!
  organization: Organization! # Null only on error - a user definitely belongs
                              # to an organization, but the organizations
                              # service might be unavailable.
 
  mostRecentPost: Post        # Deliberately nullable, since you may not have
                              # posted anything yet.
 
  posts: [Post!]!             # No posts? Empty array. Array will never contain
                              # a semantic null.
}

Services with legacy clients

For GraphQL services that cannot guarantee that all clients will have error propagation disabled, there’s a little more work to do.

Traditional clients still need their nullable error boundaries, but modern clients that support onError: "NULL" would still treat these fields as truly nullable, requiring null checks in application code that should never fire.

We need a way of indicating fields that are “null only on error”, whether you’re using traditional clients with error propagation or modern error-handling clients.

For this, we’ve standardised on the use of the transitional @semanticNonNull directive until such time as all your clients can be error-handling clients:

type User {
  id: ID!
  username: String            @semanticNonNull
  organization: Organization  @semanticNonNull
  mostRecentPost: Post
  posts: [Post]               @semanticNonNull(levels: [0, 1])
}

@semanticNonNull states that a field will only ever be null within the data of the response if there is a matching error in the errors list. The levels argument allows this directive to be applied to different list positions.

Here’s how error-handling clients can interpret various combinations of @semanticNonNull:

SDLInterpretation
[[Int]][[Int]]
[[Int]] @semanticNonNull[[Int]]!
[[Int]] @semanticNonNull(levels: [1])[[Int]!]
[[Int]] @semanticNonNull(levels: [0, 1, 2])[[Int!]!]!
[[Int]!] @semanticNonNull(levels: [2])[[Int!]!]

Of course, clients don’t need to do this themselves; graphql-sock (Semantic Output Conversion Kit, a great pairing for graphql-toe) can be used to read an SDL marked up with @semanticNonNull and output the equivalent SDL for either error-handling clients (semantic-to-strict) or traditional clients (semantic-to-nullable).

The future

As clients and servers all adopt onError: "NULL", traditional error propagation should become a relic of the past. Application developers will not need to look through the errors list in a response manually; instead, error-handling clients will raise errors using ergonomic and familiar patterns (for example, Result<...> types for fragment reads or simply throwing errors when related data is read), fulfilling the promise of “partial success” that came with GraphQL’s launch all those years ago.

Once all the clients a service serves are error-handling clients, schema designers no longer need to factor “errorability” into their schema design. They can indicate the true nullability of each field directly through the schema with a !; clients will need fewer null checks, and the @semanticNonNull directive can join error propagation as a relic of the past.

Start integrating onError: "NULL" into your clients and services today, and let’s make this future of type safety and solid error handling a reality.

Help us get this merged!

Whether you work on a GraphQL client library, server library, or framework, or are just a GraphQL user with thoughts on nullability, we want to hear from you. Have you tried onError: "NULL", @semanticNonNull, graphql-toe, or other error handling mechanisms? Like all GraphQL Working Groups, the GraphQL Specification Working Group is open to all: add yourself to an upcoming working group meeting or chat with us in the #nullability-wg channel in the GraphQL Discord.

The solution is fully formed and ready to go; we just need your adoption and feedback to get it merged into the spec!


clarity.inputfrom.ai

Footnotes

  1. A null that has an associated error in the “errors” list in the response.

  2. A “true” null representing that the data itself doesn’t exist: e.g., Alice doesn’t have a favourite band: favoriteBand: null.3

  3. About the “u”… Yes, we Brits write prose in English but code in American.