skip to content

How does a typed client generator map GraphQL nullability onto generated types?

level: middleimportance: should knowfreq 52%

answer

  1. Nullable is the default, not the exception
  2. Read the wrapper one layer at a time
  3. A list and its items decide separately
  4. Required means required on success
  5. A failure can empty the parent instead

basics

~20 s

Each nullability modifier maps independently: a nullable field becomes an optional member, a non-null field a required one, and a list's own nullability is separate from its items'. Non-null variables without defaults become required arguments.

solid answer

~50 s

GraphQL types are nullable by default and `!` marks non-null, so a generator reads the modifiers outward and maps each one on its own. `String` becomes an optional member, `String!` a required one, and `[Appointment!]!` becomes a required list whose items are all present — distinct from `[Appointment!]`, an optional list, and `[Appointment]!`, a required list that may contain holes. On the variables side a non-null variable with no default becomes a required argument, while a nullable one is optional. The catch is that a required member is only a promise about a **successful** execution. If a resolver fails on a non-null field, the null propagates up to the nearest nullable ancestor, so the object your required member lives on can be missing entirely and the response arrives half-empty. That is why careful generators type the top-level `data` as optional and why generated code must still read the `errors` entry.

code

graphql · 7 lines
graphql
type Appointment {
  id: ID!
  startsAt: DateTime!
  cancelledAt: DateTime
  attendees: [Attendee!]!
  notes: [Note]
}

go deeper

for a junior

Know that GraphQL is nullable by default and that ! means non-null, and that a nullable field becomes an optional member in generated code. Being able to read [Attendee!]! aloud correctly is most of what is expected here.

for a middle

Explain the layer-by-layer mapping, including the four distinct list shapes and how a variable's default value makes a non-null variable optional in the generated signature. Interviewers expect you to say why the two list nullabilities are not interchangeable.

for a senior

Demonstrate that you do not trust a required member blindly: describe how a failure empties an ancestor, why data is optional, and how you write client code that reads the errors entry instead of dereferencing into a hole.

for a principal

Own nullability as an API policy question. Every ! is a promise the whole organisation's generated clients will depend on and whose failure blast radius grows; every nullable adds a permanent check to every consumer. Decide where that line sits and hold it in review.

## The modifiers, read one at a time Every GraphQL type reference is a base type wrapped in zero or more modifiers: `!` for non-null and `[ ]` for list. Nullable is the **default**, which is the opposite of most host languages' defaults and the source of most of the surprise. A generator walks the wrapper outward and emits one decision per layer. Take a hospital appointment graph: ```graphql type Appointment { id: ID! startsAt: DateTime! cancelledAt: DateTime attendees: [Attendee!]! notes: [Note] } ``` A generator reads that as: `id` required; `startsAt` required; `cancelledAt` optional; `attendees` a required list of required items; `notes` an optional list whose items may individually be null. Four distinct shapes from two modifiers, and candidates routinely collapse the last two into "a list of notes". The distinction is not pedantry. `[Note]` means an item can be null, and the only ordinary way that happens is a **field error on an item**: a resolver for one element failed, that element is nullable, so a hole is punched at that index and the rest of the list survives. If the item type were `[Note!]` the same failure would null the whole list instead. The generated types differ accordingly — in one case you iterate over optional items, in the other you do not — and that difference is exactly the schema author's decision about how much of the payload one bad row is allowed to take with it. ## The variables side Variables follow the same modifiers with one extra rule: a **default value** changes the requirement. A variable declared `$day: Date!` with no default must be supplied, and a generator makes it a required argument. A variable declared `$day: Date! = "2026-09-03"` may be omitted, so it becomes optional in the generated signature even though its type is non-null. A nullable variable is optional regardless. There is a subtlety most generated code cannot express: for a nullable input, **omitting** a field and **passing an explicit null** are different requests. Omission means "say nothing about this"; explicit null means "set it to nothing". Servers that implement partial updates rely on the distinction, and a host language whose optional type has a single absent value flattens the two together. Teams that need both usually reach for a sentinel wrapper or hand-write that one call. ## Where the required member stops being true A required generated member says: **if this execution succeeded, this key is present and not null.** It does not say the key is always present. Two things break the promise. First, error propagation. If a resolver for a non-null field raises, the executor cannot write null into a non-null position, so it nulls the **nearest nullable ancestor** and records the failure under `errors` with a path. If every ancestor up the chain is also non-null, the null reaches the root and `data` itself is null. So a document whose generated result type declares `appointment.clinician.fullName` as a required string can still yield: ```json { "data": { "appointment": null }, "errors": [ { "message": "Clinician directory unavailable", "path": ["appointment", "clinician", "fullName"] } ] } ``` The response arrives half-empty and everything the generated type promised below `appointment` is gone. This is why generators type `data` as optional and why treating a required member as a guarantee — dereferencing without checking the ancestor — is the single most common way generated clients crash in production. Second, incremental delivery. When a document uses deferred or streamed delivery, part of the payload arrives later, so a field that will eventually be present is absent in the first chunk. Generators that support it model those branches as separately-arriving pieces rather than as members of one flat object. ## What this means when you review a schema Because the mapping is mechanical, nullability decisions in the schema are decisions about the ergonomics of every generated client. Marking a field non-null makes it pleasant to consume and enlarges the blast radius of its failure; marking it nullable pushes an optional into every caller forever. A small platform team feels this immediately: one over-eager `!` on a field backed by a flaky directory service turns every transient failure into a null parent, and one timid nullable on a field that is genuinely always present adds a check to dozens of call sites. The generated code is the mirror in which those choices become visible.

  • Why do many generators declare the top-level data member as optional even when every root field is non-null?
    Because a field error on a non-null field nulls the nearest nullable ancestor, and if the whole chain up to the root is non-null there is no nullable ancestor short of `data` itself. The spec allows `data` to be null in exactly that case, alongside an `errors` entry. Typing it as required would produce code that dereferences a legitimately absent value.
  • What is the practical difference between a generated type for `[Note!]` and one for `[Note]`?
    `[Note!]` gives a list whose items are all present, so a failure on one item nulls the entire list; `[Note]` gives a list of optional items, so a failure punches a hole at that index and the surviving items still arrive. In generated code that is the difference between one check on the list and a check per element — and it is the schema author's decision about failure blast radius, not the generator's.
  • Why can generated code often not distinguish an omitted input field from one explicitly set to null?
    Because most host languages have one absent value, while GraphQL treats an omitted argument and a literal null as different requests — the first says nothing about the field, the second sets it to null. A generator mapping both onto the same optional loses that. Teams needing partial updates either wrap the value in a three-state holder or hand-write the call rather than rely on generated variables.

saying these in an interview costs you the question

  • Thinks GraphQL fields are non-null by default
  • Collapses list nullability and item nullability into one
  • Treats a required generated member as always present
  • Says a non-null field can still come back null in place
  • Ignores the errors entry when data is partially filled
  • Assumes a defaulted non-null variable is still required

context