Guides

Nullability

Nullability in principle

When creating an API, especially before going to production or lifting features out of beta, thinking about if arguments and input object fields (inputs) should be required and if object type fields (outputs) should be nullable is an important design consideration. How easy your API is to consume trades for how easy it is to change and some reliability characteristics.

If inputs are optional or outputs are guaranteed then client developers will have a simpler API to deal with since making requests demands no up front configuration and handling responses presents no null cases. On the other hand, for the API developer, changing the API becomes harder since turning inputs from optional to required or making outputs go from guaranteed to nullable are breaking changes from the client's point of view.

Also, as more outputs are guaranteed, the greater the potential of the "null blast radius" can be. This is the effect where, within a schema runtime, a null or error received from some data source where the schema states there shall be no null requires propagating the null up the data tree until a nullable type is found (or, at root, finally error).

If you'd like to see these design considerations discussed further here are a few articles/resources you may find helpful:

Nullability in Nexus

Nexus defaults to both inputs and outputs being nullable. This means by default your API is conservative in what it sends but flexible in what it accepts. With this approach, by default:

  • You're free to defer some hard thinking about output nullability, knowing you can always change your mind later without breaking clients.
  • Client developers work more to processing API responses, having to handle null conditions.
  • You're forced to frontload some hard thinking about inputs, since realizing something should have been required later will require breaking clients.
  • Client developers work less to satisfy minimum query requirements.
  • The "null blast radius" (refer to Nullability in Principal) is reduced to zero.

There is no right or wrong answer to nullability. These are just defaults, not judgments. Understand the tradeoffs, and react to your use-case, above all.

You can override the global defaults at the per-type level or per-field level. If you find yourself writing local overrides in a majority of cases then it might mean the global defaults are a bad fit for your API. In that case you can change the global defaults.

When you make an input nullable then Nexus will alter its TypeScript type inside your resolver to have null | undefined. null is for the case that the client passed in an explicit null while undefined is for the case where the client simply did not specify the input at all.

If an arg has been given a default value, then it will be used when the client passes nothing, but since clients can still pass explicit null, resolvers must still handle nullability. If this surprises you then you may be interested in #485.

Example: Default nullability settings

1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: 'String',
6 },
7 resolve(_root, args) {
8 return args.message ?? 'nil'
9 },
10 })
11 },
12})
1type Query {
2 echo(message: String): String
3}

Example: Nullability flipped at global level

1makeSchema({
2 nonNullDefaults: {
3 input: false,
4 output: false
5 },
6 types:[...]
7})
1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: 'String',
6 },
7 resolve(_root, args) {
8 return args.message
9 },
10 })
11 },
12})
1type Query {
2 echo(message: String): String!
3}

Example: Nullability flipped at type level

1queryType({
2 nonNullDefaults: {
3 input: true,
4 output: true,
5 },
6 definition(t) {
7 t.string('echo', {
8 args: {
9 message: 'String',
10 },
11 resolve(_root, args) {
12 return args.message
13 },
14 })
15 },
16})
1type Query {
2 echo(message: String): String!
3}

Example: Nullability flipped at input and field level

1queryType({
2 definition(t) {
3 t.field('echo', {
4 type: nonNull('String'),
5 args: {
6 message: nullable(stringArg()),
7 },
8 resolve(_root, args) {
9 return args.message
10 },
11 })
12 },
13})
1type Query {
2 echo(message: String): String!
3}

Example: Mixing levels

It is possible to use type and input/field layers together. This provides flexibility to optimize for local sections of your API that have different characteristics. For example here, a type deviates from the global default for all but but one field and its input.

1queryType({
2 // flip the global defaults
3 nonNullDefaults: {
4 input: true,
5 output: true,
6 },
7 definition(t) {
8 // ... Everything in this type uses the type-level
9 // nullability config ... Except the following,
10 // which effectively reverts back to what the global
11 // defaults are:
12 t.nonNull.string('echo', {
13 args: {
14 message: nonNull(stringArg()),
15 },
16 resolve(_root, args) {
17 return args.message
18 },
19 })
20 },
21})
1type Query {
2 echo(message: String!): String!
3}

Example: Args that have default values

When an arg has a default you might think that then it should be nullable to the client but non-nullable within your resolver logic. However it turns out that if the client passes an explicit null then that is considered an actual value, and hence is not subject to being assigned the default value. Thus, and then, the resolver still can observe null from the client. If you are curious about seeing this change and/or become configurable then please refer to #485.

1queryType({
2 definition(t) {
3 t.nonNull.string('echo', {
4 args: {
5 message: stringArg({
6 default: 'nil via default',
7 }),
8 },
9 resolve(_root, args) {
10 const fallback = 'nil via client null'
11 return args.message ?? fallback
12 },
13 })
14 },
15})
1type Query {
2 echo(message: String = "nothing via default"): String!
3}}
1query {
2 echo1: echo
3 echo2: echo(message: null)
4}
1{
2 "data": {
3 "echo1": "nil via default",
4 "echo2": "nil via client null"
5 }
6}
Edit this page on Github