skip to content

In GraphQL, why is making an output field Non-Null safe for clients but making it nullable again breaking?

level: middleimportance: must knowfreq 62%

answer

  1. Ask which side reads the value
  2. Narrowing what can arrive is the safe way
  3. Dead branch versus unguarded dereference
  4. Cheap for the reader, permanent for the producer
  5. Interface implementations may only strengthen

basics

~20 s

Tightening an output field from a type to its Non-Null form only removes a case clients already handle, so nothing they wrote stops working. Widening it back introduces a null those clients never wrote code for, which is why that direction breaks them.

solid answer

~40 s

In the output position the client is the *reader* of the value, so the safe direction is the one that narrows what it can receive. Changing `Enrollment.certificate` from `Certificate` to `Certificate!` means every response a client already handled is still valid — it simply never sees the null branch again, and a generated type gets narrower in a way existing code compiles against. Going the other way, `Certificate!` back to `Certificate`, hands clients a value they were promised could not appear; code written against the promise has no branch for it and a generated non-nullable type stops matching the response. The catch is that the safe-for-clients direction is not free: `!` binds the server permanently, so tightening is only safe if the value really is always producible, not merely always present in today's data.

code

graphql · 9 lines
graphql
# safe for clients: the null branch becomes dead code
type Enrollment {
  certificate: Certificate!   # was: Certificate
}

# breaking: clients have no guard for the null now possible
type Enrollment {
  certificate: Certificate    # was: Certificate!
}

go deeper

for a junior

Learn the direction as a fact you can state: adding ! to an output field does not break readers, removing it does. Be able to say why in one line — a client that already handled null loses nothing, a client that never expected null gains a crash.

for a middle

Be ready to reason it out from first principles rather than recite it: identify who consumes the value, then argue that shrinking the set of possible values is safe for the consumer. Mention what a typed client generator does to each direction.

for a senior

Show that you know the safe direction is not the cheap one. Talk about the permanent obligation a ! places on every future data source and tenant, and give a concrete test for whether a value is guaranteed by identity or merely by current data.

for a principal

Own the ratchet framing: output nullability tightens easily and loosens only through a client migration, so it accumulates. Be ready to say how you keep that ratchet from being pulled casually across a schema many teams write into.

## Who is the reader, and who is the writer Every nullability change question resolves the same way once you ask which party *consumes* the value. In the output position the server produces and the client consumes, so a change is safe for clients exactly when it shrinks the set of values they can receive, and breaking when it grows that set. Take a course-enrolment graph published to two mobile builds and a web app: ```graphql # before type Enrollment { id: ID! certificate: Certificate } # after — a tightening type Enrollment { id: ID! certificate: Certificate! } ``` Every client written against the *before* schema already contains a branch for `certificate == null`. After the tightening that branch becomes dead code, which is harmless. No document has to change, no field has to be reselected, and a typed client generator moves from a nullable type to a plain one — a narrowing that existing reading code still satisfies. That is the whole argument for calling the direction safe. ## Why the reverse breaks Now run it backwards. A team discovers that 1,463 historic enrolments were imported without a certificate record and proposes relaxing `Certificate!` to `Certificate`. Every client compiled against the promise has code shaped like `enrollment.certificate.issuedOn` with no guard, because the schema said a guard was unnecessary. The moment one response carries null there, that code faults. A client generator makes it worse in a useful way: the generated field type widens from `Certificate` to `Certificate | null`, which turns the change into a compile error in every downstream build — visible, but still a break that has to be scheduled across every deployed client, including the ones already in an app store. This asymmetry is why `!` is usually described as a one-way door. You can walk through it; you cannot walk back without a client migration. ## "Safe for clients" is not "safe" The direction that is safe for readers is a *tightening of the server's own obligations*, and that is where the real risk sits. Once `certificate: Certificate!` is published, the server has committed that every enrolment it will ever return has a certificate — across every tenant, every backfill, every new source of enrolment data, and every backend that might be unavailable when the field is resolved. Teams reach for `!` because it makes client code tidier, and then discover the promise was true of the data they had rather than of the domain. Ask instead: is the value guaranteed by the identity of the object, or merely by the current contents of a table? Only the first earns the wrapper. A useful practical rule: tighten output fields deliberately and rarely, and only when you can state the invariant in one sentence without the word "currently". ## Interface implementation follows the same direction The same asymmetry appears inside the type system itself. When an object type implements an interface, each field's type must be a valid sub-type of the interface's declaration, and a Non-Null of a type is a valid sub-type of that type. So if an interface declares: ```graphql interface Enrollable { certificate: Certificate } ``` an implementing object type may declare `certificate: Certificate!` — a stricter promise — but may not declare `certificate: Certificate` where the interface promised `Certificate!`. The reasoning is identical: a client that selects the field through the interface must be able to hold the value it was promised, whichever concrete type came back. Strengthening is always allowed; weakening is not. ## What to say about detection A schema diff will class the two edits differently — tightening as a change to watch, widening as outright breaking — but the classification is a property of the tooling, not of the specification. The specification defines what the types mean; nothing in it says a schema may not change. Everything about "breaking" is a statement about the clients you actually have, which is why teams pair a diff with evidence of what is really being selected before they let either edit through. ## The one-sentence version Outputs flow server to client, so removing a possible value (nullable to Non-Null) costs the client nothing and costs the server a permanent promise, while adding one back (Non-Null to nullable) costs the client every unguarded line it wrote on the strength of that promise.

  • If tightening an output field is safe for clients, why not tighten everything you can?
    Because the safety is one-sided. Each `!` converts a client convenience into a permanent server obligation that must hold for every future tenant, backfill and data source, and it cannot be withdrawn without a client migration. Tighten only where the value is guaranteed by the object's identity rather than by the rows you happen to have today.
  • A client generator turns the widening edit into a compile error. Does that make it non-breaking?
    No — it makes it *detectable*. A compile error still means every downstream build must be changed, rebuilt and redeployed, and clients already shipped to users cannot be. Loud breakage is better than silent breakage, but the schema change is breaking either way.
  • Can an object type implement an interface field as nullable when the interface declares it Non-Null?
    No. An implementing field's type must be a valid sub-type of the interface's, and Non-Null of a type is a sub-type of that type, not the reverse. So an implementation may strengthen a nullable interface field to Non-Null, but may never weaken a Non-Null one — the same direction that governs client-facing changes.

saying these in an interview costs you the question

  • Calls both nullability directions equally breaking
  • Says relaxing Non-Null to nullable is additive and safe
  • Treats tightening as free because clients need no change
  • Marks a field Non-Null because today's rows are all populated
  • Thinks the specification defines which edits are breaking
  • Believes an implementation may weaken an interface field's nullability

context