From 58830d9b06e22ecf24f8d319b84ed0df5c0fa5dc Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Tue, 2 Jun 2026 18:38:04 +0200 Subject: [PATCH 1/2] Add `@semanticNonNull` draft --- gaps/GAP-0/DRAFT.md | 121 ++++++++++++++++++++++++++++++++++++++++ gaps/GAP-0/README.md | 36 ++++++++++++ gaps/GAP-0/metadata.yml | 14 +++++ 3 files changed, 171 insertions(+) create mode 100644 gaps/GAP-0/DRAFT.md create mode 100644 gaps/GAP-0/README.md create mode 100644 gaps/GAP-0/metadata.yml diff --git a/gaps/GAP-0/DRAFT.md b/gaps/GAP-0/DRAFT.md new file mode 100644 index 0000000..b951ad0 --- /dev/null +++ b/gaps/GAP-0/DRAFT.md @@ -0,0 +1,121 @@ +# @semanticNonNull Directive Specification + +## Introduction + +:: This document specifies the `@semanticNonNull` directive, a schema directive +that indicates a field position is _semantically non null_: it is only null when +there is a matching error in the response `errors` array, and is otherwise +guaranteed to contain a value. + +GraphQL's Non-Null type (`!`) serves two purposes at once: it asserts that a +position can never logically be null, and it dictates that, when an error occurs +in that position during execution, the resulting null is _propagated_ to the +nearest nullable parent. Because error propagation can erase large amounts of +otherwise-valid data, API authors frequently make fields nullable purely to +contain the blast radius of field errors — even when those fields are, in the +absence of an error, always present. + +`@semanticNonNull` lets a schema separate these two concerns. A field may remain +nullable on the wire (so that an error in it does not propagate) while still +declaring that, absent a matching error, the position always contains a value. + +**Example** + +```graphql example +type User { + # `name` is nullable on the wire, but is only ever null if `name` itself + # produced an error. Tooling may treat it as non-null otherwise. + name: String @semanticNonNull + + # The list and each of its elements are semantically non null. + friends: [User] @semanticNonNull(levels: [0, 1]) +} +``` + +**Use Cases** + +- Code generators may emit non-null types for semantically non-null positions + when the client handles field errors through an out-of-band mechanism (such as + a result type or an exception raised at the edge of the selection set), sparing + developers from null checks on positions that are only ever null on error. +- Schema authors may keep fields nullable for resilience (so that field errors + do not propagate and erase sibling data) without sacrificing the ability to + communicate that those fields are, in practice, always present. + +**Relationship to `onError`** + +The [`onError` proposal](https://github.com/graphql/graphql-spec/pull/1163) +allows a client to opt out of error propagation (`onError: "NULL"`), so that a field error produces +a localized {null} rather than propagating up to the nearest nullable parent. Once +error propagation is no longer a concern, the _Non-Null_ type (`!`) can be used +to mark every position where {null} is not a semantically valid value — the true +nullability of the schema. + +Representing that nullability with `!` is not always possible for existing services, however. Adding +`!` to an existing field may increase the blast radius of an error for clients that have not opted +out of error propagation. + +`@semanticNonNull` addresses this. Because it is additive metadata that does not +change the wire type, a nullability-aware client may read the true nullability of +a field and omit its non-null checks, while clients that are unaware of the +directive observe the field exactly as before. `onError` and `@semanticNonNull` +describe the same underlying truth — which positions are truly non-null — but +`@semanticNonNull` can be adopted incrementally without affecting existing +clients. + +## Definition + +```graphql +directive @semanticNonNull(levels: [Int!]! = [0]) on FIELD_DEFINITION +``` + +The `@semanticNonNull` directive indicates that a field's result is +_semantically non null_ at the indicated _levels_. + +A position is _semantically non null_ if it is only {null} when there is a +matching error in the `errors` array of the response. In all other cases, the +position contains a value. + +`@semanticNonNull` may only be applied to a _FieldDefinition_ whose type is +nullable at the indicated _levels_. Applying it to a position that is already a +_Non-Null_ type at that level is meaningless (the position is already +guaranteed non-null by the type system) and must be considered an error. + +## The levels argument + +The _levels_ argument selects which _levels_ of a (possibly nested) _List_ type +are semantically non null. Levels are zero-indexed, where level {0} is the +outermost position (the field's own value). + +- For a non-list type, only level {0} is meaningful, and it refers to the + field's value itself. +- For a _List_ type, level {0} refers to the list itself, level {1} refers to + each element of the list, level {2} to each element of each nested list, and + so on. + +The default value of `[0]` makes only the outermost position semantically non +null, matching the common case of a non-list field. + +A _level_ that is negative, or that exceeds the list dimensionality of the +annotated field, must be considered an error. + +**Examples** + +Given the field type {"[[String]]"}: + +| Application | Semantically non-null positions | +| ------------------------------------- | -------------------------------------------------- | +| `@semanticNonNull` | the outer list | +| `@semanticNonNull(levels: [1])` | each inner list | +| `@semanticNonNull(levels: [2])` | each {String} element | +| `@semanticNonNull(levels: [0, 1, 2])` | the outer list, each inner list, and each {String} | + +```graphql example +type Query { + # The list itself is semantically non null; individual elements may be null. + tags: [String] @semanticNonNull + + # Both the list and each element are semantically non null. + ids: [ID] @semanticNonNull(levels: [0, 1]) +} +``` diff --git a/gaps/GAP-0/README.md b/gaps/GAP-0/README.md new file mode 100644 index 0000000..41c3dc5 --- /dev/null +++ b/gaps/GAP-0/README.md @@ -0,0 +1,36 @@ +# GAP-0: @semanticNonNull Directive + +## Overview + +This proposal defines the `@semanticNonNull` directive, a schema directive that +marks a field position as _semantically non null_: the position is guaranteed to +contain a value unless there is a matching error in the response's `errors` +array. + +This separates two concepts that GraphQL's `!` (Non-Null) type conflates: + +- **"this position can never logically be null"** (semantic nullability), and +- **"this position becomes null when the field errors"** (error nullability). + +By expressing semantic nullability separately, code generation tools can emit +non-null types for clients that can opt-out of error propagation with `onError: NULL`, removing the unnecessary null checks that +clients would otherwise have to write. + +## Relationship to prior art + +This directive is part of the broader +[GraphQL Nullability specification](https://specs.apollo.dev/nullability/) being +developed in the +[GraphQL Nullability Working Group](https://github.com/graphql/nullability-wg). +The full nullability specification also defines client-side directives +(`@catch`, `@catchByDefault`) describing how clients handle field errors. This +GAP scopes itself to the schema-side `@semanticNonNull` (and the companion +`@semanticNonNullField`) directive, which is independently useful and can be +adopted on its own. + +Related discussions and prior art: + +- [GraphQL Nullability WG](https://github.com/graphql/nullability-wg) +- [graphql-spec #1452 — "Client Controlled Nullability"](https://github.com/graphql/graphql-spec/pull/1452) + and the broader nullability discussions in the GraphQL specification. +- [`specs.apollo.dev/nullability/v0.4`](https://specs.apollo.dev/nullability/v0.4/#@semanticNonNull). diff --git a/gaps/GAP-0/metadata.yml b/gaps/GAP-0/metadata.yml new file mode 100644 index 0000000..2ee3ff6 --- /dev/null +++ b/gaps/GAP-0/metadata.yml @@ -0,0 +1,14 @@ +id: 0 +title: "@semanticNonNull Directive" +summary: > + A schema directive that marks a field position as "semantically non null" — + guaranteed to be non-null unless there is a matching error in the response + `errors` array — allowing tooling to generate non-null types for newer clients + without breaking older clients that cannot enable `onError: "NULL"`. +status: proposal +authors: + - name: "Martin Bonnin" + email: "martin@apollographql.com" + githubUsername: "@martinbonnin" +sponsor: "@martinbonnin" +discussion: "https://github.com/graphql/gaps/pull/0" From 4d7594ad70c1e1a6339940337192f9869789d538 Mon Sep 17 00:00:00 2001 From: Martin Bonnin Date: Tue, 2 Jun 2026 18:39:22 +0200 Subject: [PATCH 2/2] Update GAP number --- gaps/{GAP-0 => GAP-49}/DRAFT.md | 0 gaps/{GAP-0 => GAP-49}/README.md | 2 +- gaps/{GAP-0 => GAP-49}/metadata.yml | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename gaps/{GAP-0 => GAP-49}/DRAFT.md (100%) rename gaps/{GAP-0 => GAP-49}/README.md (97%) rename gaps/{GAP-0 => GAP-49}/metadata.yml (89%) diff --git a/gaps/GAP-0/DRAFT.md b/gaps/GAP-49/DRAFT.md similarity index 100% rename from gaps/GAP-0/DRAFT.md rename to gaps/GAP-49/DRAFT.md diff --git a/gaps/GAP-0/README.md b/gaps/GAP-49/README.md similarity index 97% rename from gaps/GAP-0/README.md rename to gaps/GAP-49/README.md index 41c3dc5..0c849ba 100644 --- a/gaps/GAP-0/README.md +++ b/gaps/GAP-49/README.md @@ -1,4 +1,4 @@ -# GAP-0: @semanticNonNull Directive +# GAP-49: @semanticNonNull Directive ## Overview diff --git a/gaps/GAP-0/metadata.yml b/gaps/GAP-49/metadata.yml similarity index 89% rename from gaps/GAP-0/metadata.yml rename to gaps/GAP-49/metadata.yml index 2ee3ff6..3baf2fd 100644 --- a/gaps/GAP-0/metadata.yml +++ b/gaps/GAP-49/metadata.yml @@ -1,4 +1,4 @@ -id: 0 +id: 49 title: "@semanticNonNull Directive" summary: > A schema directive that marks a field position as "semantically non null" — @@ -11,4 +11,4 @@ authors: email: "martin@apollographql.com" githubUsername: "@martinbonnin" sponsor: "@martinbonnin" -discussion: "https://github.com/graphql/gaps/pull/0" +discussion: "https://github.com/graphql/gaps/pull/49"