'True' Nullability is coming to GraphQL
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 occured within a resolver, the resolver’s value
would be replaced with a null, and an error would be added to the errors
array in the response.
But what if that field 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,
it’s 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 occurred,
we’ve moved to strongly encourage nullable usage throughout a schema, only
adding the non-nullable ! marker to positions where we’re truly sure that
field is extremely unlikely to error.
This “nullable by default” has the effect of meaning that developers consuming the GraphQL API have to handle potential nulls in more positions than they would expect, an explosion of null checks leading people to even call into question the value of GraphQL’s “type safety”.
Complication 3: normalized caching
Many modern GraphQL clients use a “normalized” 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 to the normalized cache.
The Nullability Working Group
At first, we thought the solution to this was to give clients control over the nullability of each field in 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 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 add a ! 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 years we found numerous issues that traded one set of concerns for another. We kept iterating whilst we looked for a solution to these tradeoffs.
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-energized 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 happen that should be handled, but would not “blow 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 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
behaviors 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 them.
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 converging hinted we were getting closer and closer to an answer.
This long and 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, inconsistent, and also became 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 contains 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 as
to how errors are handled by the GraphQL service (each option 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 honor the specified behavior. If a service
does not support onError then the request property will be ignored, resulting
in the traditional behavior - 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 the capability.
onError: "PROPAGATE"
This is the traditional error propagation behavior that we all know and… “love”?
Setting onError: PROPAGATE will be equivalent to the error behavior 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 be reading the data - any error should result in a
{ data: null, errors: [...] } response - and thus the service can 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 if they are all nullable. This
effectively changes the “non-nullable” type modifier to mean “null only on
error”.
Clients that opt-in to this behavior take responsibility for interpretting 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 which 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 normalized stores) even when errors occur. They can also
re-implement traditional GraphQL error propagation on top of these new
foundations, shielding applications developers from needing to learn this new
behavior (whilst still allowing them to reap the benefits!). They can even take
on advanced behaviors, 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 or 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 errors exists on a
response is an error handling client - the data can never be read, and so no
“error nulls” can be read. Clients that implement error handling behaviors such
as throw-on-error at the field or fragment level also prevent application code
from reading “error nulls”, and so are also error handling clients.
Many clients including window.fetch(), Apollo Client, URQL and graffle can be
made into error-handling clients by integration of something like
graphql-toe (Throw On Error) - a
0.5kB library that can be added to JavaScript projects and uses accessors such
that when application code attempts to read from an errored field, that error is
thrown so that traditional error handling (try/catch or <ErrorBoundary />)
can process it.
True nullability
Just like with traditional error propagation, for clients using onError: "NULL" fields are either nullable or non-nullable. However; unlike with
traditional propagation, with onError: "NULL", errors can be represented in
any position:
- nullable (e.g.
Int): a value, an error, or a truenull; - 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 that all clients must 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 for modern
clients that support onError: "NULL" this 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 which are “null only on error” whether you’re using traditional error propagation clients or modern error-handling clients.
For this, we’ve standardized 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 applying this directive to different list positions.
Here’s how error-handling clients can interpret various combinations of
@semanticNonNull:
| SDL | Interpretation |
|---|---|
[[Int]] | [[Int]] |
[[Int]] @semanticNonNull | [[Int]]! |
[[Int]] @semanticNonNull(levels: [1]) | [[Int]!] |
[[Int]] @semanticNonNull(levels: [0, 1, 2]) | [[Int!]!]! |
[[Int]!] @semanticNonNull(levels: [2]) | [[Int!]!] |
Of course each client doesn’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 for 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 through 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
GraphQL launched with all those years ago.
Once all 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
lets make this future of type safety and solid error handling a reality.
Help us get this merged!
Whether you work on a GraphQL client or 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 or chat with us in the
#nullability-wg channel in the GraphQL Discord.
The solution is formed and ready to go - we just need your adoption and feedback to get it merged into the spec!