Class 8: Where Error Handling Is Going: True Nullability
Duration: 20 minutes | Difficulty: Advanced | Prerequisites: Class 7 of this course.
The ambiguous null and Relay's client-side directives are assumed known; this class is about the specification work that resolves both.
What You'll Learn
By the end of this class, you will:
- State the insight behind semantic nullability: whether a value can be absent and how far an error propagates are separate questions
- Use
@semanticNonNulltoday as the additive migration bridge, withgraphql-sockserving both client generations - Explain the proposed
onErrorrequest property and predict response shapes underPROPAGATE,HALT, andNULL - Understand why the
NULLerror behavior makes normalized stores safe to update in the presence of errors - Know the proposal's exact status as of September 2026, its RFC trail, and what is safe to adopt now
The conflation and its workarounds
Class 2 diagnosed the root problem. GraphQL's Non-Null syntax makes one declaration answer two unrelated questions: whether a value can be absent in the domain, and how far a runtime failure should propagate. Every chapter since has managed the consequences:
- Class 2 covered nullable-by-default schemas that misstate the domain.
- Class 7 covered caches poisoned by error nulls.
- Both covered Relay building per-fragment machinery to reclaim locally what the schema gave away globally.
The specification work covered in this class removes the conflation at its source. Its normative text lives in open pull requests against the GraphQL specification, covered below. The clearest explanation of the whole design is a draft blog post by Benjie Gillam, "Bringing 'True' Nullability to GraphQL", open as graphql.github.io PR #1731. Its header carries the date August 14, 2026, but as of September 22, 2026 it has not been published on graphql.org, so this lesson quotes it as a draft. The draft summarises the proposal:
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:
Nullability then describes only whether a value can be absent in the domain. Errors are recorded at the positions where they occurred instead of propagating outward and nulling the data around them.
How the proposal got here
The trail matters, both for citing and because each abandoned stage explains the final shape. The effort began as Client-Controlled Nullability (graphql-spec PR #895, now closed): clients would annotate fields with ! and ? operators to override schema nullability per query. A related exploration, the "non-nullable error boundary" or interrobang proposal (PR #1046, also closed), tried the schema side instead: a new !? type that clients unaware of it would see as an ordinary nullable field. Both pull requests were closed. Per-field query syntax pushes the decision into every document, when what clients actually want is a uniform behavior for the whole request, which is what Relay's directives teach. The schema-side type stalled because no syntax for it was well liked. According to the draft post, a later call between Lee Byron and Benjie Gillam followed Lee Byron's @strictNullability proposal, and the post describes the outcome:
This long, detailed, highly technical discussion ultimately led to the realisation that error propagation itself was the issue.
The dedicated nullability working group, which started in 2023 as the Client-Controlled Nullability working group and was renamed that November, worked through these stages in public meetings. The rename records the change of direction. It was archived on February 5, 2026, having, per its own closing statement, landed on a "service capabilities + onError" design that continues in the main GraphQL working group. The live RFCs, all open against the spec as of September 2026, are these:
- PR #1163, "Error behaviors (including
onError: \"NULL\")" specifies theonErrorbehaviors. - PR #1208, "Service capabilities" provides the negotiation mechanism through which a server advertises that it supports the new behaviors at all.
- PR #1236, opened by Martin Bonnin in July 2026, specifies
onErroralone.
#1208 was opened in January 2026, and on 3 August 2026 #1163 dropped its own capabilities text in favour of it, so #1163 and #1236 now both specify onError without capabilities. The two differ on what happens when a request omits onError. After the working group meeting of 6 August 2026, the description of #1163 states that PROPAGATE will "forever" be the default error behavior, and its author's comment from that day records the removal of a configurable default. The meeting notes record the client-side half of that decision: new clients, urql and Apollo Client among them, should default to onError: "NULL" whenever the server supports it, while older clients keep today's behavior. #1236 instead says that when onError is omitted "the default error behavior of the service applies", that this default "is implementation-defined", and that HALT is not recommended as a default. #1163 also gained a pathNonNull entry on errors in August 2026, so that clients without a schema can reproduce propagation locally; #1236 leaves it out. The draft post's history section names the people whose proposals shaped the work, among them Lee Byron, Jordan Eldredge, Stephen Spalding, Alex Reilly, and Mark Larah. The Relay typing behavior from Class 7 came out of the same work, and the draft records that the first Nullability WG proposal "came from a collaboration between Yelp and Netflix".
The bridge available today: @semanticNonNull
The proposal's first component does not require a spec change, so it can be adopted in an existing schema immediately. @semanticNonNull is a schema directive, defined in a specification hosted by Apollo and published as a Draft, version 0.4. It has one precise meaning, quoted here from the draft post:
@semanticNonNullstates that a field will only ever benullwithin thedataof the response if there is a matching error in theerrorslist.
Reread the Movie Database field from Class 2, the one declared nullable purely as an operational hedge, with this available:
type Person {
id: ID!
name: String @semanticNonNull
}
The declaration now records both facts. The domain always supplies a name, because every person has one. The field stays nullable on the wire, so an error resolving it stays local and Class 2's propagation rules leave the parent alone. A null in this field now has a single meaning: resolution failed, and a matching error appears in the errors list. For fields declared this way, the ambiguity Class 2 described no longer arises.
The directive is additive, which is what makes it a bridge: traditional clients see an ordinary nullable field and behave as they always did; semantics-aware tooling sees the strengthened contract. Class 7 already showed the reward on the Relay side. Per the @catch guide, fields it handles "will be typed using their Semantic Nullability", and @throwOnFieldError fragments get the same types. Components that declare their error handling therefore get non-nullable generated types. For everything else there is graphql-sock, the "Semantic Output Conversion Kit", whose latest version, 1.0.1, dates from March 2025. The draft post describes it as reading "an SDL marked up with @semanticNonNull" and outputting "the equivalent SDL for either error-handling clients (semantic-to-strict) or traditional clients (semantic-to-nullable)". A single annotated schema therefore yields two derived views, so both client generations can be served for as long as the migration takes.
The proposal proper: onError
The second component is the spec change: a request-level property named onError, which the pull requests' specification text calls a request attribute. Through it the client, not the schema, chooses the error behavior for the whole request. The property accepts three values, the three error behaviors, each producing a different response shape, and the same Movie Database query from Class 2 (movieById with title, rating, and directors { name }, first director's name failing) shows each one concretely.
onError: "PROPAGATE" is, in the draft's words, "the traditional error propagation behaviour that we all know", and under #1163 it is the default, which is what makes the proposal adoptable: a request that omits the property gets today's semantics, bit for bit. Under #1236 the service chooses its own default, as noted above. The error from the failing name propagates through [Person!]! and nulls movieById, exactly as Class 2 traced:
{
"errors": [{ "message": "...", "path": ["movieById", "directors", 0, "name"] }],
"data": { "movieById": null }
}
onError: "HALT" is for clients that want all-or-nothing semantics. The draft describes it:
This mode allows clients to indicate that if any error occurs, they won't read the data: any execution error should result in a
{ data: null, errors: [...] }response, allowing the service to halt execution when the first error occurs.
Under PR #1163's spec text the server must cease execution at the first execution error, since nothing after it can matter:
{
"errors": [{ "message": "...", "path": ["movieById", "directors", 0, "name"] }],
"data": null
}
The draft names ad-hoc scripts and similar clients as the users of this error behavior. It advises most user-facing applications against it:
Using
onError: "HALT"disables one of the big innovations of GraphQL: partial success.
Batch jobs and migration scripts fit that description, since today they check whether errors is non-empty and discard the data themselves.
onError: "NULL" is the error behavior the proposal exists to enable, and it changes response behavior the most. The draft describes it:
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 (for error-handling only).
The failing name stays exactly where it failed, regardless of any ! in the schema:
{
"errors": [{ "message": "...", "path": ["movieById", "directors", 0, "name"] }],
"data": {
"movieById": {
"title": "The Shawshank Redemption",
"rating": 8.7,
"directors": [{ "name": null }]
}
}
}
Compare this with Class 2's version of the same failure, where everything under movieById was lost. Here the error affects only the position where it was raised, because the client chose that behavior. The null at the errored position is unambiguous by construction: under the NULL error behavior, a null at a @semanticNonNull (or non-null) position always has a matching error at that path. And with propagation gone, the schema author's dilemma from Class 2 is resolved: name can be declared semantically non-null because the domain guarantees it, while the error behavior the client chooses determines how failures appear on the wire.
The draft states the cache consequence directly, and it addresses the store-corruption problem Class 7 described:
By disabling error propagation, these clients will be able to safely update their stores (including normalised stores) even when errors occur.
Class 7 showed why propagation and normalized stores conflict: propagation removes valid sibling data (so a partial response under-writes the cache), and error nulls look like values (so the cache stores incorrect data). Under the NULL error behavior with a semantically annotated schema, every position holds either a true value or a precisely identified error. The draft names Relay, Apollo Client and urql among the clients that benefit.
What changes for whom
Schema authors can act now: annotate the fields that are truly never absent with @semanticNonNull instead of defensively widening them to nullable, and stop using ! as an operational gamble. New schemas can be designed with domain-true nullability from the start, which reverses the Class 2 guidance. The draft presents nullable-by-default as a response to error propagation, and says that once every client of a service handles errors, schema designers can stop factoring errors into nullability.
Client and tooling authors get the draft's explicit call to action: implement @semanticNonNull awareness and the onError behaviors. In the draft's words:
The solution is fully formed; we just need your adoption and feedback to prove it's ready to go and get it merged into the spec!
Relay's implementation (Class 7) is the working proof; graphql-sock covers codegen pipelines that are not ready.
Server authors should watch three RFCs: error behaviors (#1163) and the onError request property (#1236) for the semantics, and service capabilities (#1208) for how a server advertises support. graphql-js v17 ships an experimental @experimental_disableErrorPropagation directive, which gives an operation the same non-propagating behavior as onError: "NULL", and graphql-java has shipped the same directive since v23.0. The description of #1236 links merged onError support in Apollo Kotlin and Hot Chocolate, and open pull requests for graphql-java and graphql-js. Until the RFCs merge, onError is a proposal. As of September 2026 every source for this section is a draft: the open pull requests, and a blog post that has not been published yet.
The sober adoption line, then:
@semanticNonNullis safe today: additive, a published draft spec, shipping client support, conversion tooling.- Designing new schemas with true nullability in mind is safe today.
onErroritself is for implementers and early adopters willing to track open RFCs.- The caution Class 3 attached to
294-style timelines applies here with extra force, because language-spec changes move more slowly than transport recommendations.
Summary
The true-nullability work separates the two questions GraphQL's Non-Null conflated:
@semanticNonNull, available now via Apollo's published draft directive spec, declares "null only on error", keeping domain truth in the schema while leaving wire nullability lenient.graphql-sockderives strict or nullable SDL views for both client generations.onError, the proposed request property, moves error behavior to the client, per request.PROPAGATEkeeps today's error propagation as the default,HALTgives all-or-nothing semantics withdata: null, andNULLdisables propagation entirely, so every position is an error boundary. An error then affects only its own position, and normalized stores can update safely amid errors.- The lineage runs from Client-Controlled Nullability (PR #895) through the interrobang exploration (PR #1046) to the archived nullability working group's final design. That design continues in PRs #1163, #1208 and #1236, and Benjie Gillam's draft blog post, still unpublished, explains it.
- The status. The draft describes the solution as fully formed and waiting for adoption, feedback and a merge into the specification.
Further Reading
- Bringing "True" Nullability to GraphQL (draft): the explanation this class quotes, open as graphql.github.io PR #1731 and not yet published on the graphql.org blog as of September 2026; its examples show each error behavior in detail.
- graphql-spec PR #1163, Error behaviors: the open RFC specifying
onError, including the exact execution-algorithm changes for each error behavior and, since August 2026, thepathNonNullerror entry. - graphql-spec PR #1208, Service capabilities: the negotiation mechanism that lets a server advertise support for new behaviors like
onError. - graphql-spec PR #1236, onError request parameter:
onErroron its own, without service capabilities. - The @semanticNonNull specification: the published directive definition you can implement against today.
- graphql-sock on npm: the conversion kit for serving strict and nullable schema views from one annotated SDL.
- GraphQL nullability working group archive: the meeting notes and agendas recording how each design stage was proposed, argued, and superseded; primary-source history for the whole effort.
What's Next
The design guidance in this course is now complete; Classes 9 through 11 examine how real organizations apply it in practice, handling errors at scale, starting with Shopify, whose mutation-error pattern many other APIs adopted.