Semantic Nullability
At a glance
- Identifier: SemanticNullability
- Stage: RFC0: Strawman
- Champion: -
- PR: -
- Related:
Timeline
- RFC document updated on 2025-05-01 by Benjie
- RFC document updated on 2025-05-01 by Benjie
- RFC document updated on 2025-04-25 by Martin Bonnin
- RFC document updated on 2025-03-29 by Martin Bonnin
- RFC document updated on 2025-03-28 by Martin Bonnin
- RFC document updated on 2025-03-28 by Alex Reilly
- RFC document updated on 2025-03-06 by Benjie
- RFC document updated on 2025-02-22 by Benjie
- RFC document updated on 2025-02-22 by Benjie
- RFC document updated on 2025-02-20 by Benjie
- RFC document updated on 2025-02-20 by Benjie
- RFC document updated on 2025-02-20 by Martin Bonnin
- RFC document updated on 2025-02-17 by Benjie
- RFC document updated on 2025-02-17 by Alex Reilly
- RFC document updated on 2025-02-17 by Martin Bonnin
- RFC document updated on 2025-02-17 by Benjie
- RFC document updated on 2025-02-17 by Benjie
- RFC document updated on 2025-02-16 by Benjie
- RFC document updated on 2025-02-16 by Benjie
- RFC document created on 2025-02-15 by Benjie
RFC: Semantic Nullability
📜 Problem History
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.
Error 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 theerrorsarray in the response. GraphQL thus adopted the non-traditional stance of all types being "nullable by default" (since an error could happen anywhere at any time for any reason).However, null-checking is exhausting and in some positions errors are extremely unlikely (and null is not an expected value according to the business logic) so GraphQL allowed a position to be marked non-nullable by following the type with a
!marker - this would guarantee that that position in the data could not contain anull.What if a non-null field were to throw an error, or incorrectly return
null, then? To solve that apparent contradiction, GraphQL introduced the "error propagation" behavior (also known colloquially as "null bubbling") - when anull(from an error or otherwise) occurs in a non-nullable position, the parent position (either a field or a list item) is madenullinstead. This behavior would repeat if the parent position was also non-nullable, and this could propagate (or "bubble") all the way up to the root of the operation if everything in the path is non-nullable.Thus the
!non-null marker has also been known as "kills parent on exception" due to this destructive error propagation behavior.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. Clients now need to handle
nullin these positions.Complication 2: nullable epidemic
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), 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 has the effect of meaning that developers consuming the GraphQL API have to handle potential nulls in more positions than they would expect, making for additional work.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. Again, the solution is to make more of your schema nullable.
The Nullability Working Group
At first, we thought the solution to this was to give clients control over the nullability of a 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 error propagation entirely.Disabling error propagation
It became clear that disabling error propagation was desired by advanced GraphQL clients and vital for ensuring that normalized caches were as useful as possible and that we could live up to the promise of GraphQL's partial success without compromise. But that was only part of the problem - the other part was that we want to see the "true" nullability of fields, the nullability if we were to exclude errors.
Note: this RFC assumes that clients may opt out of error propagation via some mechanism that is outside the scope of this RFC and will be handled in a separate RFC (e.g. via a directive such as
@noErrorPropagationor@behavior(onError: NULL); or via a request-level flag) - in general the specific mechanism is unimportant and thus solutions are not expected to comment on it unless the choice is significant to the proposal.Semantic nullability
We realised that if we were to do this, we would need two schemas: one for when null bubbling is disabled, where the true nullability of fields could be represented; and one for the traditional error handling behavior, where nullability would need to factor in that errors can occur.
However, maintaining two nearly-identical-except-for-nullability schemas is a chore... and it felt like it was solveable if we could teach GraphQL to understand this need... What we ultimately realised is that GraphQL is missing a type.
Ignoring errors, if we look at our business logic we can determine if a field is either semantically nullable (it's meaningful for this field to be null - for example an
Animalmight not have anownercurrently) or semantically non-nullable (this field will never be null - for example everyPostmust belong to atopic). However GraphQL muddied the waters here by factoring errors into the mix... "what if the "topics" service went down?" it would ask; "we might want to render the post!" And thus, we would makePost.topicnullable, even though we know it should always exist, because we don't want error propagation to destroy the entire response.So we actually have three types:
Value Error null Semantically nullable ✅ ✅ ✅ Semantically non-nullable ✅ ✅ ❌ Strictly non-nullable ✅ ❌ ❌ We could already express a position that could never error and never be null (we called this non-nullable, e.g.
Int!), and we could express a position that could be null or have an error (we called this nullable, e.g.Int), but what we lacked was the ability to say "this position can be null, but that will only happen if an error has occurred" - a "null only on error" or "semantically non-null" type.📜 Problem Statement
GraphQL needs to be able to represent semantically nullable and semantically non-nullable types as such when error propagation is disabled.
📋 Solution Criteria
This section sketches out the potential goals that a solution might attempt to fulfill. These goals will be evaluated with the GraphQL Spec Guiding Principles in mind:
- Backwards compatibility
- Performance is a feature
- Favor no change
- Enable new capabilities motivated by real use cases
- Simplicity and consistency over expressiveness and terseness
- Preserve option value
- Understandability is just as important as correctness
Each criteria is identified with a
Letterso they can be referenced in the rest of the document. New criteria must be added to the end of the list.Solutions are evaluated and scored using a simple 3 part scale. A solution may have multiple evaluations based on variations present in the solution.
- ✅ Pass. The solution clearly meets the criteria
- ⚠️ Warning. The solution doesn't clearly meet or fail the criteria, or there is an important caveat to passing the criteria
- 🚫 Fail. The solution clearly fails the criteria
- ❔ The criteria hasn't been evaluated yet
Passing or failing a specific criteria is NOT the final word. Both the Criteria and the Solutions are up for debate.
Criteria have been given a "score" according to their relative importance in solving the problem laid out in this RFC while adhering to the GraphQL Spec Guiding Principles. The scores are:
- 🥇 Gold - A must-have
- 🥈 Silver - A nice-to-have
- 🥉 Bronze - Not necessary
🎯 A. GraphQL should be able to indicate which nullable fields should become non-nullable when error propagation is disabled
The promise of this RFC - the reflection of the semantic nullability of the fields without compromising requests with error propagation enabled via the differentiation of a "null if and only if an error occurs" type.
With error propagation enabled (the traditional GraphQL behavior), it's recommended that fields are marked nullable if errors may happen there, even if the underlying value is semantically non-nullable. If we allow error-handling clients to disable error propagation, then these traditionally nullable positions can be marked (semantically) non-nullable in that mode, since with error propagation disabled the selection sets are no longer destroyed.
Note: Traditional non-nullable types will effectively become semantically non-nullable when error propagation is disabled no matter which solution is chosen, so this criteria is only concerned with traditionally nullable types.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ ✅ 🚫👍 ✅ ✅ ✅ Criteria score: 🥈
🎯 B. Existing executable documents should retain validity and meaning
Users should be able to adopt semantic nullability into an existing schema, and when doing so all existing operations should remain valid, and should have the same meaning as they always did.
1 2 3 4 5 6 7 8 ✅ 🚫 ✅ ✅ ✅ ✅ ✅ ✅ Criteria score: 🥈
🎯 C. Unadorned type should mean nullable
GraphQL has been public for 10 years and there's a lot of content out there noting that GraphQL types are nullable by default (unadorned type is nullable) and our changes should not invalidate this content.
1 2 3 4 5 6 7 8 ✅ 🚫 ✅ 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥉
🎯 D. Syntax should be obvious to programmers
The GraphQL languages similarity to JSON is one of its strengths, making it immediately feel familiar. Syntax used should feel obvious to developers new to GraphQL.
1 2 3 4 5 6 7 8 🚫 ✅ ✅ ✅ ⚠️ ✅ ✅ ✅ Criteria score: 🥇
🎯 E. Syntax used in SDL and in executable documents should be consistent with SDL
When a user wishes to replace the value for an input field or argument with a variable in their GraphQL operation, the type syntax should be either identical or similar, and should carry the same meaning.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥇
🎯 F. Alternative syntaxes should not cause confusion
Where a proposal allows alternative syntaxes to be used, the two syntaxes should not cause confusion.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥇
🎯 G. Error propagation boundaries should not change in existing executable documents
An expansion of B, this states that the proposal will not change where errors propagate to when error propagation is enabled (i.e. existing documents will still keep errors local to the same positions that they did when they were published), allowing for the "partial success" feature of GraphQL to continue to shine and not compromising the resiliency of legacy deployed app versions.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ ✅ 🚫 ✅ ⚠️ ✅ Criteria score: 🥈
- ✂️ Objection: proposal to lower the score to 🥈. With enough advance notice and a clear upgrade path for legacy apps, the tradeoff might be acceptable.
🎯 H. Implementation and spec simplicity
The implementation required to make the proposal work should be simple.
1 2 3 4 5 6 7 8 ✅ 🚫 🚫 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥇
🎯 I. Syntax used in executable documents should be unchanged
Executable documents do not differentiate between semantic and strict non-null since inputs never handle "errors" ("null only on error" is the same as "not null" on input). As such, there's no benefit to clients for the syntax of executable documents to change.
1 2 3 4 5 6 7 8 ✅ ❔ ✅ 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥈
🎯 J. Type reasoning should remain local
The type of a field (
foo: Int) can be determined by looking at the field and its type; the reader should not have to read a document or schema directive to determine how the type should be interpreted.
1 2 3 4 5 6 7 8 ✅ ❔ ⚠️ 🚫 ✅ ⚠️ ✅ ✅ Criteria score: 🥇
🎯 K. Introspection must be backwards compatible
We do not want to break existing tooling.
1 2 3 4 5 6 7 8 ✅ ❔ ✅ ❔ ✅ ✅ ⚠️ ✅ Criteria score: 🥈
🎯 L. General GraphQL consumers should only need to think about nullable vs non-nullable
Schema authors and client frameworks can handle different types of nullability based around error handling and error propagation, but consumers (frontend developers) should only need to deal with nullable or non-nullable as presented to them by their client framework of choice.
May contradict: M
1 2 3 4 5 6 7 8 ✅ ❔ ✅ ❔ ✅ ⚠️ ✅ ✅ Criteria score: 🥈
🎯 M. The SDL should have exactly one form used by all producers and consumers
The SDL should not be influenced by client features such as local extensions and error propagation mechanics, and should always represent the true full source schema SDL.
May contradict: L
1 2 3 4 5 6 7 8 ✅ ❔ ⚠️ ❔ ✅ ✅ ✅ ✅ Criteria score: 🥇
🎯 N. The solution should add value even with error propagation enabled
Even when error propagation is enabled, it's valuable to be able to tell the difference between a field that is truly (semantically) nullable, and one that's only nullable because errors may occur. GraphQL-TOE can be used in such situations so that codegen can safely use non-nullable types in semantically non-nullable positions.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ ✅ 🚫 ✅ ✅ ✅ Criteria score: 🥉
🎯 O. Should not have breaking changes for existing executable documents
It should be possible to enable the solution without negatively impacting existing deployed clients.
Per Lee:
A breaking change is a client observable change in behavior. The decade old GraphQL query should work in the same way as it always has. (We sometimes allow inconsequential changes in behavior, but bubbling the error up isn't inconsequential.)
1 2 3 4 5 6 7 8 ✅ ❔ ✅ ✅ 🚫 ✅ ⚠️ ✅ Note: though this criteria is currently not considered due to overlap with B and G, it acts as a reminder to look for other forms of breaking change, and helps to reason why B and G are important.
Criteria score: X (not considered - covered by B and G)
🎯 P. The solution should result in users marking all semantically non-null fields as such
When a field returns data that the business logic dictates does not and will never return a legitimate (non-error) null, the schema authors should have no hesitation over marking it as semantically non-nullable - and thus all semantically non-nullable fields should be marked as such.
Per Benoit:
Not sure how to express it well, but I feel there should be a criteria to mean something like “the solution encourages that eventually most fields in most schemas are semantically non null”. As a client developer that’s kind of an outcome of this whole effort I’d like to see happening.
1 2 3 4 5 6 7 8 ✅ ✅ ✅ ✅ 🚫 ✅ 🚫 ✅ Criteria score: 🥇
🎯 Q. Migrating the unadorned output type to other forms of nullability should be non-breaking
The default (unadorned) type should be a type that you can migrate away from, once nullability expectations become more concrete, without breaking existing client queries.
1 2 3 4 5 6 7 8 ✅ 🚫 ✅ 🚫 ✅ ✅ ✅ ✅ Note: this is not necessarily a duplicate of C as it doesn't specifically require the unadorned type be nullable, however no proposal currently proposes a mechanism for moving from any non-nullable type to a nullable type in a non-breaking way, and thus this criteria is currently discounted.
Criteria score: X (not considered)
🎯 R. Semantic nullability should only impact outputs, not inputs
There's no meaningful difference between semantic non-null and strict non-null on input, since inputs do not handle errors (and thus "null only on error" describes a situation that cannot occur).
Inputs include: field arguments, directive arguments, and input fields.
As such:
- the syntax used to represent input nullability in SDL (
Int= nullable, andInt!= non-nullable) should be unchanged- the representation in introspection for inputs (namely the
NON_NULLtype wrapper) should be unchanged
1 2 3 4 5 6 7 8 ✅ ❔ ✅ 🚫 ✅ ✅ ✅ ✅ Criteria score: 🥈
🎯 S. Should be incrementally adoptable