Skip to content

fix: follow the specification for absent, null, and default input values - #57

Merged
ikawaha merged 10 commits into
mainfrom
fix/preserve-undefined-vs-null-distinction
Aug 18, 2026
Merged

fix: follow the specification for absent, null, and default input values#57
ikawaha merged 10 commits into
mainfrom
fix/preserve-undefined-vs-null-distinction

Conversation

@toiroakr

@toiroakr toiroakr commented May 12, 2026

Copy link
Copy Markdown
Contributor

Summary

The GraphQL specification distinguishes three states for an input: not provided,explicitly null, and a value. This fork collapsed the first two, and separately never implemented the rule that an input is required only when its type is non-null and it declares no default value.

This PR restores both distinctions. SchemaConfig.NonSpecArgumentHandling opts a schema
back out to the previous behaviour, byte for byte, while code that depends on it migrates.

Schema used in the tables below

type Query {
  f(a: String): String
  fDef(a: String = "ARGDEF"): String
  fNNDef(a: String! = "NNDEF"): String
  fObj(input: In): String
  fObjDef(input: InDef): String
  fNNObj(input: NNDef): String
  fNNReq(input: NNReq): String
}

input In    { a: String }
input InDef { a: String = "FIELDDEF" }
input NNDef { a: String! = "FIELDDEF" }
input NNReq { a: String! }

Every cell below is measured by replaying the document through graphql.Do. The "before" column is commit 39d54ab, the branch point. "Before" and "flag on" are identical in every row — that is the whole promise of the flag.

1. A variable the caller did not supply leaves its argument absent

Spec §6.4.1 CoerceArgumentValues.

Document Variables Before After NonSpecArgumentHandling: true
f(a: $x) {} {a: null} {} {a: null}
f(a: $x) {x: null} {a: null} {a: null} {a: null}
fObj(input: {a: $x}) {} {input: {a: null}} {input: {}} {input: {a: null}}
fObj(input: {a: $x}) {x: null} {input: {a: null}} {input: {a: null}} {input: {a: null}}

A resolver can now test for the presence of the key to learn whether the caller sent the field at all. Rows 2 and 4 show what does not change: an explicitly supplied null has always reached resolvers in this fork, and still does.

2. A default stands in only for a value that was not supplied

Spec §6.1.2 and §6.4.1: the default applies when hasValue is false. An explicit null is a supplied value.

Document Variables Before After Flag on
fDef(a: $x) {x: null} {a: "ARGDEF"} {a: null} {a: "ARGDEF"}
query ($x: String = "VARDEF") { f(a: $x) } {x: null} {a: "VARDEF"} {a: null} {a: "VARDEF"}
fObjDef(input: $in) {in: {a: null}} {input: {a: "FIELDDEF"}} {input: {a: null}} {input: {a: "FIELDDEF"}}
fObjDef(input: {a: $x}) {} {input: {a: null}} {input: {a: "FIELDDEF"}} {input: {a: null}}

The first three rows and the last one move in opposite directions, and that is the point: because "absent" and "null" were the same state internally, the answer to "does the default apply?" depended on which code path the value travelled.

3. A non-null input that declares a default is optional

One rule, needed in four places. Spec §5.4.2.1 (arguments, §5.4.3 in draft), §6.1.2 (variable definitions), §5.8.5 (variable usage positions), §3.10 and §5.6.4 (input object fields).

Site Document Variables Before After Flag on
field argument { fNNDef } {} error {a: "NNDEF"} error
variable definition query ($x: String! = "VARDEF") { f(a: $x) } {} error {a: "VARDEF"} error
variable usage position fNNDef(a: $x) {} error {a: "NNDEF"} error
input field, literal fNNObj(input: {}) {} error {input: {a: "FIELDDEF"}} error
input field, variable fNNObj(input: $in) {in: {}} error {input: {a: "FIELDDEF"}} error
no default → still required fNNReq(input: {}) {} error error error

The "before" errors, in order:

Field "fNNDef" argument "a" of type "String!" is required but not provided.
Variable "$x" of type "String!" is required and will not use the default value. Perhaps you meant to use type "String".
Variable "$x" of type "String" used in position expecting type "String!".
Argument "input" has invalid value {}.  /  In field "a": Expected "String!", found null.
Variable "$in" got invalid value {}.  /  In field "a": Expected "String!", found null.

The last row is the guard: a non-null field with no default stays required in every mode.

4. A supplied null at a non-null position is a field error

Spec §6.4.1 and the fourth rule of §3.10 Input Coercion. Allowing a nullable variable at a non-null location (row 3 of the previous table) makes these documents reachable, so the runtime has to reject the null that validation no longer does.

Document Variables Before After Flag on
fNNDef(a: $x) {x: null} error, from validation error, from coercion error, from validation
fNNObj(input: {a: $x}) {x: null} error, from validation error, from coercion error, from validation
fNNObj(input: $in) {in: {a: null}} error error error

The outcome is unchanged; only the layer that reports it moves. After:

Argument "a" of non-null type "String!" must not be null.
Argument "input" has invalid value.  /  In field "a": Expected "String!", found null.

5. @Skip and @include test their if argument structurally

Spec §6.3.2 CollectFields does not coerce the arguments of @skip and @include. It asks
only whether if is true, with identical wording in October 2021 and draft:

If skipDirective's if argument is true or is a variable in variableValues with the
value true, continue with the next selection.
If includeDirective's if argument is not true and is not a variable in variableValues
with the value true, continue with the next selection.

A value that is not true — a variable carrying null, say — is simply "not true", and
no error is raised. Validation blocks every other route here, so the one document that
reaches it declares a variable with a non-null default and then supplies null for it.

Document Variables Before After Flag on
a @skip(if: $x) b, $x: Boolean = true {"x": null} {"b"} {"a","b"} {"b"}
a @include(if: $x) b, $x: Boolean = true {"x": null} {"a","b"} {"b"} {"a","b"}

The @include row is a behaviour change beyond argument coercion, and the only one in
this PR that is not about a default value. The pre-fix test was ok && !includeIf, so an
if that was not a bool failed the type assertion, missed the branch, and left the
selection in; the specification keeps it only when if is true.

This is also the one place where graphql-js goes beyond the specification: it coerces
directive arguments inside collectFields and fails the whole request with data: null.
The coercion errors this PR adds for field arguments are unaffected.

What changed in the code

  • values.go — argument and variable coercion follow §6.4.1 and §6.1.2 step by step; a default is applied before the non-null requirement is checked; getArgumentValues gained an error return and reports any null sitting at a non-null position, with the path to it.
  • rules.goProvidedNonNullArgumentsRule and DefaultValuesOfCorrectTypeRule stop treating a declared default as irrelevant; isValidLiteralValue exempts an input field that declares one; VariablesInAllowedPositionRule implements §5.8.5, including hasLocationDefaultValue.
  • type_info.go, validator.goTypeInfo tracks the default declared by the argument or input object field being visited, and VariableUsage carries it, so §5.8.5 can be evaluated. List positions carry no default, per the spec's wording.
  • executor.go, subscription.goresolveField and ExecuteSubscription turn the
    coercion error into a field error. @skip and @include do not: §6.3.2 CollectFields
    tests their if argument structurally rather than coercing it, so a value that is not
    true — including one a coercion failure left unusable — simply answers "not true".

Every behavioural change is behind NonSpecArgumentHandling.

Verification

  • go test ./... passes.
  • Replaying 61 scenarios through graphql.Do shows NonSpecArgumentHandling: true
    matching 39d54ab on every one of them, error messages included.
  • A conformance check derived from the coercion algorithms covers 28 cases: optional
    inputs were already correct at 17/17 and stay there; non-null inputs move from 5/11 to
    11/11.
  • Mutation testing over the new decisions: reverting any one of them fails at least one
    test, in both directions of the flag.

Notes

  • The parser still does not accept the null literal in query documents, so the explicit
    null cases are exercised through variables.
  • DefaultValue interface{} cannot express a: String = null, which §6.4.1 mentions as
    "defaultValue exists (including null)". That is a schema-expressiveness limit rather
    than a coercion bug, and it is unreachable today because the parser rejects the literal.
  • VariableUsage gained a field. Code constructing it with an unkeyed composite literal
    will need field names.

Variables declared in an operation but not supplied by the caller used to
arrive at resolvers as explicit nil, making it impossible to tell "field
omitted" from "field explicitly null". Restore the three-state semantics
required by the spec (CoerceArgumentValues / CoerceVariableValues) while
keeping the existing behavior of preserving explicit nulls.

- getVariableValues: only insert a coerced value when the caller supplied
  the variable or when the definition declares a default value.
- getArgumentValues: treat an argument that resolves to an unprovided
  variable reference as absent, but still surface explicit nulls.
- valueFromAST (InputObject): fields whose values come from unprovided
  variables stay absent in the resulting map.
- Add argument_coercion_test.go covering the three states for scalars,
  input objects, and input-object literals.
@toiroakr
toiroakr marked this pull request as ready for review May 13, 2026 01:05
@toiroakr
toiroakr requested a review from a team as a code owner May 13, 2026 01:05
@ikawaha ikawaha assigned ikawaha and unassigned ikawaha Aug 14, 2026
@ikawaha

ikawaha commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Behavior comparison

The table below reports the state of p.Args as a resolver observes it, measured by
replaying each document through graphql.Do.

Column 3 is identical to column 1 in every row: setting NonSpecArgumentHandling: true
restores the pre-fix behavior exactly. This was verified across 43 scenarios, of which
the 13 below are the ones the fix changes; the other 30 behave the same in all three
columns.

Schema used

type Query {
  f(a: String, b: String): String
  fDef(a: String = "ARGDEF"): String
  fNN(a: String! = "NNDEF"): String
  fObj(input: Input): String
  fObjDef(input: InputDef): String
  fNested(input: Nested): String
  fList(a: [String]): String
  fObjList(input: [InputDef]): String
}

input Input    { a: String, b: String }
input InputDef { a: String = "FIELDDEF", b: String }
input Nested   { inner: InputDef }

Rule 1 — A variable the caller did not supply leaves its argument absent

Document Variables Before fix After fix (default) NonSpecArgumentHandling: true
f(a: $x, b: "k") {} {"a":null,"b":"k"} {"b":"k"} {"a":null,"b":"k"}
fObj(input: {a: $x, b: "k"}) {} {"input":{"a":null,"b":"k"}} {"input":{"b":"k"}} {"input":{"a":null,"b":"k"}}
fObj(input: $in) {} {"input":null} {} {"input":null}
fList(a: $x) {} {"a":null} {} {"a":null}

A resolver can now test for the presence of the key to learn whether the caller sent
the field at all.

Rule 2 — An explicit null is a supplied value, so no default stands in for it

Document Variables Before fix After fix (default) NonSpecArgumentHandling: true
fDef(a: $x) {"x":null} {"a":"ARGDEF"} {"a":null} {"a":"ARGDEF"}
query Q($x: String = "VARDEF") { f(a: $x) } {"x":null} {"a":"VARDEF"} {"a":null} {"a":"VARDEF"}
fObjDef(input: $in) {"in":{"a":null}} {"input":{"a":"FIELDDEF"}} {"input":{"a":null}} {"input":{"a":"FIELDDEF"}}
fNested(input: $in) {"in":{"inner":{"a":null}}} {"input":{"inner":{"a":"FIELDDEF"}}} {"input":{"inner":{"a":null}}} {"input":{"inner":{"a":"FIELDDEF"}}}
fObjList(input: $in) {"in":[{"a":null},{}]} {"input":[{"a":"FIELDDEF"},{"a":"FIELDDEF"}]} {"input":[{"a":null},{"a":"FIELDDEF"}]} {"input":[{"a":"FIELDDEF"},{"a":"FIELDDEF"}]}

A client that sends null to mean "clear this field" now reaches the resolver with that
intent intact. The last row is the clearest case: before the fix, an element that sent
null and an element that omitted the key produced the same result.

Rule 3 — A value the caller did not supply does fall back to the default

Document Variables Before fix After fix (default) NonSpecArgumentHandling: true
fObjDef(input: {a: $x}) {} {"input":{"a":null}} {"input":{"a":"FIELDDEF"}} {"input":{"a":null}}
fNested(input: {inner: {a: $x}}) {} {"input":{"inner":{"a":null}}} {"input":{"inner":{"a":"FIELDDEF"}}} {"input":{"inner":{"a":null}}}
fObjList(input: [{a: $x}, {a: "k"}]) {} {"input":[{"a":null},{"a":"k"}]} {"input":[{"a":"FIELDDEF"},{"a":"k"}]} {"input":[{"a":null},{"a":"k"}]}

Rules 2 and 3 are the same principle — a default applies only when no value was
supplied — and the pre-fix code got both wrong, in opposite directions. Because
"absent" and "null" were the same state internally, the answer to "does the default
apply?" depended on which code path the value travelled: an explicit null picked up
the default at the argument level, while an absent value failed to pick it up inside
an input object literal.

Rule 4 — A non-null argument that declares a default is optional

Document Variables Before fix After fix (default) NonSpecArgumentHandling: true
{ fNN } {} validation error:
Field "fNN" argument "a" of type "String!" is required but not provided.
{"a":"NNDEF"} validation error:
Field "fNN" argument "a" of type "String!" is required but not provided.

This is the only change to document validation. Spec §5.4.2.1 defines an argument as
required when its type is non-null and it declares no default value, so a document
may omit fNN's argument and take the default.

Unchanged

Preserving an explicitly supplied null all the way to the resolver is not new — it has
been this fork's behavior since #17, and it stays. Upstream graphql-go collapses an
explicit null with an omitted value; this fork deliberately does not, before and after
this change alike.

Comment thread values.go
fieldValue := coerceValue(field.Type, v)
// The key is present and holds null: the caller supplied a value, so
// the field's default must not stand in for it.
if !nonSpec && ok && isNullish(v) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes coercion after a value has reached coerceValue, but a valid input object with an omitted non-null field that has a default is rejected earlier by isValidInputValue (and literal validation has the same issue). For example, { input: {} } for input I { value: String! = "default" } fails with Expected "String!", found null. instead of applying "default". Per the spec, an input field is required only when it is non-null and has no default value: https://spec.graphql.org/draft/#sec-Input-Objects. Please make both validation paths exempt non-null fields with defaults and add coverage for variable and literal input objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 292cbb8 — thank you, the report was accurate on both paths.

Both validation paths now exempt a non-null input field that declares a default:
isValidInputValue for values arriving through variables, and isValidLiteralValue
for object literals. Coverage was added for both, plus the nested and list-element
cases, and for the two states that must keep failing — a field with no default, and
a field given an explicit null. Coercion needed no change: once validation lets the
value through, coerceValue and valueFromAST already substitute the default.

While confirming the report I checked the rest of the specification for the same rule
and found it missing in two more places, so 292cbb8 covers those as well:

  • Variable definitions. DefaultValuesOfCorrectTypeRule rejected any non-null
    variable that declared a default, and getVariableValue checked the non-null
    requirement before applying the default, so §6.1.2 step 1 never ran. In other words
    query Q($x: String! = "d") was unusable.
  • Variable usage positions. §5.8.5 allows a nullable variable at a non-null
    location when either the variable or *the lt
    (hasLocationDefaultValue); only the variable's own default was being considered.
    TypeInfo now tracks the location's defaul, the way
    graphql-js does.

One part deserves a closer look in review. Relaxing the usage-position check exposed a
latent gap: getArgumentValues had no error ing a non-null
argument was handed to the resolver instead of raising the field error §6.4.1 step 2
calls for. Fixing §5.8.5 alone would therefore have let null through silently, so
292cbb8 gives getArgumentValues an error return and handles it at its four call
sites — resolveField, the two @skip / @ieSubscription.
This is the only change that touches execution rather than validation.

All of it sits behind NonSpecArgumentHandling, so a schema that opts out is
unaffected.

Two existing tests asserted the pre-spec beha

  • TestValidate_VariableDefaultValuesOfCorrectType_NoRequiredVariablesWithDefaultValues
    now expects the document to validate; the r the opt-out
    instead.
  • TestVariables_NonNullableScalars_PassesAlongNullForNonNullableInputsIfExplicitlySetInTheQuery
    expected a required argument to go missing without an error. Its document actually
    omits the argument rather than setting it explicitly, and its two sibling tests
    already expect an error for the variable eqthe field error
    and is renamed ...DoesNotAllowNonNullableInputsToBeOmittedDirectly.

Beyond the suite: replaying 43 scenarios through graphql.Do shows the opt-out
matching the pre-fix commit 39d54ab on everse conformance
check against the coercion algorithms moves non-null handling from 5/11 to 11/11 with
optional handling unchanged at 17/17.

@k1LoW k1LoW left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@ikawaha ikawaha changed the title fix: preserve absent vs explicit-null distinction in argument coercion fix: follow the specification for absent, null, and default input values Aug 18, 2026
@ikawaha
ikawaha requested a review from k1LoW August 18, 2026 04:00

@k1LoW k1LoW left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems there is an inconsistency: the validation layer consistently checks for != nil, while the coercion layer consistently checks for isNullish.

Comment thread values.go Outdated
Comment thread rules.go Outdated

@k1LoW k1LoW left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

@ikawaha
ikawaha merged commit 30a5053 into main Aug 18, 2026
1 check passed
@ikawaha
ikawaha deleted the fix/preserve-undefined-vs-null-distinction branch August 18, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants