GraphQL nulls

Litany against nulls.

graphql
Author

A. Coady

Published

February 24, 2023

Litany against nulls.

The myths and misconceptions regarding null behavior in GraphQL are epic. Even the spec is wrong.

Inputs (such as field arguments), are always optional by default. However a non-null input type is required. In addition to not accepting the value null, it also does not accept omission. For the sake of simplicity nullable types are always optional and non-null types are always required.

One might pedantically ask, can a spec be wrong by definition? Yes, it can contradict itself.

Arguments can be required. An argument is required if the argument type is non-null and does not have a default value. Otherwise, the argument is optional.

Input object fields may be required. Much like a field may have required arguments, an input object may have required fields. An input field is required if it has a non-null type and does not have a default value. Otherwise, the input object field is optional.

Some of the arguments being made here could be dismissed as merely semantics. In the author’s opinion, the misinformation has clearly caused real misunderstanding. Let’s break down the persistent myths.

Fields are nullable by default.

Most type systems which recognise “null” provide both the common type and the nullable version of that type, whereby default types do not include “null” unless explicitly declared. However, in a GraphQL type system, every field is nullable by default.

This is true in the context of the spec and the reference graphql-js implementation. Whether it is relevant to a developer creating an API is entirely dependent on which graphql framework they are using.

  • graphene-python: nullable by default. Types are wrapped by a NonNull type or passed a required=True flag.
  • strawberry-graphql: non-null by default. Following the Python convention, types must be annotated as nullable explicitly.
  • ariadne and every schema-first framework: assuming there is a default, it would be nullable.

Does the graphql schema have a “default”? In the sense that a Type must be annotated with a whole extra character to become Type!. Not in the same sense that query is the default operation, for example, because the operation can be omitted. There is no explicit Type? syntax which is being defaulted to. It is just as accurate to say there is no default in the schema; there is a syntactic binary choice.

Fields should default to nullable.

When designing a GraphQL schema, it’s important to keep in mind all the problems that could go wrong and if “null” is an appropriate value for a failed field. Typically it is, but occasionally, it’s not. In those cases, use non-null types to make that guarantee.

So not whether nulls literally are the default, but the recommendation that one should default to using nullable types. This advice would be fine in theory, but in practice has reversed what is “typical” versus “occasional”. Typically, errors propagate up to an enclosing type, possibly all the way up. It is common for trivial scalars to never be null, and like any tree there are far more leaf nodes than higher nodes.

It is also rare to want nulls in a list. Better to omit them; even more so for input lists.

The standard advice is often combined with a compatibility claim.

Including non-null fields and arguments in a schema makes that schema harder to evolve where a client expects a previously non-null field’s value to be provided in a response. For example, if a non-null email field on a User type is converted to a nullable field, will the clients that use that field be prepared to handle this potentially null value after the schema is updated? Similarly, if the schema changes in such a way that a client is suddenly expected to send a previously nullable argument with a request, then this may also result in a breaking change.

Notice the common mistake: the first sentence contradicts the last with respect to arguments. Null compatibility advice applies only to outputs; the opposite is true for inputs. Even the staunchest pro-null advocate must concede that one should “default” to inputs being non-null.

A better question to ask is “what would a null in this field represent”? In an implemented API, if the field can actually be null, then there will be an answer to that question. But if the field is truly never null, then there may be no answer. You may find yourself not just bike-shedding, but counterfactual bike-shedding. How is the client supposed to “correctly” handle a response that is impossible, in a scenario that is indescribable?

There is no need to speculate on what a hypothetical API might do, nor to agree on a “default” behavior. Analyze how the implemented API actually behaves, and describe it accurately.

Optional == Nullable.

GraphQL is far from the first to conflate “optional” with “nullable”, but it has elevated it to a next level. There is no context in which the quote “nullable types are always optional and non-null types are always required” is both true and not vacuous.

  • Output field
    • From the client’s perspective, optional would mean the client can omit the field from the request. No, all fields are optional in that sense.
    • From the server’s perspective, optional would mean the server can omit the field from the response. No, all fields are required in that sense. Nullables may null, not omitted.
  • Input field or argument
    • From the client’s perspective, optional would mean the client can omit the input. Yes, so they are equivalent in this case? No, an input can also be optional by having a default value, even with a non-null type. Nullable implies optional; optional does not imply nullable.
    • From the server’s perspective, optional would mean omission is equivalent to a sent null value. No, the spec clarifies that those two scenarios are semantically different.

If the value null was provided for an input object field, and the field’s type is not a non-null type, an entry in the coerced unordered map is given the value null. In other words, there is a semantic difference between the explicitly provided value null versus having not provided a value.

So there is only one context in which the concept of “optional” has any meaning: when the client can omit the input. And being nullable is only one of two ways for an input to be optional. That makes “optional” roughly 12.5% identical to “nullable”. optional == omittable != nullable

Alternatives

So rather than just be a rant, let’s use this hard-fought contrarian knowledge to create better APIs.

Default values wherever possible.

Default values are under-utilized, no surprise given the introduction.

length(unit: LengthUnit = METER): Float

Arguments can be either required or optional. When an argument is optional, we can define a default value - if the unit argument is not passed, it will be set to METER by default.

Notice the misleading phrasing combined with the nullable LengthUnit reinforces the false - and widespread - notion that the argument must be nullable to be optional. It would be perfectly valid for a client to send an explicit null in this example, and a good chance it is broken on the server, assuming “working” was defined. A non-null here is better in every measurable way: length(unit: LengthUnit! = METER): Float.

  • the client does not have to guess what sending an explicit null would mean because it is disallowed
  • the server does not have to document or support a use case that never really existed

It can not be overstated how little known default value optionality is. Once pointed out, it is impossible to not see this in APIs everywhere.

  • the vast majority of Boolean inputs should be Boolean! = {false,true}
  • the vast majority of [Type!] inputs should be [Type!]! = []. Any valid value is a valid default value, not just scalars.
  • many Int and Float inputs should be Int! = 0 and Float! = 0.0
  • some String inputs should be String! = ""

A similar dynamic has occurred in Python. None is over-used as a default value, instead of a natural default. Speaking of which, Python also fell for Optional equivocation, but at least is trying to walk it back. ... | None is the new preferred syntax, and the documentation is quick to point out that the Optional annotation does not actually mean optional.

Exploit the explicit null distinction.

There are at least two scenarios where distinguishing an explicit null from omission is quite useful.

A partial update where null is a valid value to set, that is an unset. Often there will be a clunky Boolean flag to indicate “no seriously, set the null”: update(name: String, setName: Boolean, ...). Instead, drop the flag and document that passing a value will update (null or otherwise), and omission will not update.

Another scenario is a filter where null is a valid value. It is a perfectly natural interpretation that omission means to not apply the filter, whereas an explicit null means to filter on null.

If this path is followed, it also becomes natural to add = null to inputs where there is no difference. Yes, even null is a valid default value. The point is it clearly documents to the client that there is no difference, while providing assurance that the server is implemented correctly.

Note many frameworks will not make it convenient to check whether an input was present. It may be necessary to check the argument map in the GraphQL info. One of the few that does is strawberry-graphql, which uses UNSET as a sentinel. Using a sentinel when None is a valid value is an established pattern in Python, such as dataclasses.MISSING.

Use an @optional directive.

There is one last case that is not well-covered. What if there is not a natural default, and no inherent meaning for null. That is, the server is being forced to declare an input as nullable when it only wants it to be optional. Using an @optional directive would clarify that, and remove any expectation of behavior if the client insists on sending a null.

Advocacy

The above suggestions are in this GraphQL proposal, and can be see implemented in the graphique project. It demonstrates fields like: slice(offset: Int! = 0, length: Int = null, reverse: Boolean! = false).

When debating this topic, beware of undue JavaScript influence from a client perspective. For example, undefined is neither a GraphQL nor a JSON concept. And all of the input points listed apply to input coercion of variables as well.