skip to content

When a GraphQL union gains a member type, what does a client with inline fragments for only the old members receive?

level: seniorimportance: should knowfreq 44%

answer

  1. The schema change is additive
  2. No branch matched, so nothing was collected
  3. Only what was selected outside survives
  4. No error, no alert, no failed request
  5. An interface leaves a floor behind

basics

~20 s

It receives the object with none of its branch fields — only what was selected outside the branches, which on a union is at most __typename. Nothing errors; the document stays valid and the row simply arrives empty.

solid answer

~50 s

Adding a member to a union is **additive in the schema**: existing documents still validate, so nothing fails and nothing warns. But at execution, field collection includes only the inline fragments whose type condition matches, and none of the client's branches match the new type. The object comes back carrying just the fields selected outside the branches — on a union that is `__typename` at most, so often a single key. A consumer that switches exhaustively on the type name hits an unhandled case, and one that reads a branch field unconditionally sees `undefined` rather than an error. Mitigations are all client-side and all conventions: always select `__typename` outside the branches, always write a default branch, and prefer an **interface** over a union where consumers need a guaranteed shape — an interface's declared fields still resolve for a new implementing type.

code

graphql · 10 lines
graphql
query BinActivity($binId: ID!) {
  bin(id: $binId) {
    events {
      __typename
      ... on Receipt { asnNumber quantity }
      ... on Pick { orderRef quantity }
      ... on CycleCountAdjustment { delta countedBy }
    }
  }
}

go deeper

for a junior

Know that a client only receives fields from inline fragments whose type matched, and that fields from unmatched branches are simply missing. That alone explains why an unfamiliar type comes back nearly empty.

for a middle

Explain the mechanics end to end: the schema change is additive so validation still passes, field collection skips every non-matching branch, and the object is built from the outer selection only — with no error raised at any point.

for a senior

Demonstrate the production judgement: the failure is silent, so name what you would select defensively, what a default branch buys you, and how you would find affected operations before shipping rather than after.

for a principal

Own the abstraction choice. A union obliges every current and future consumer to enumerate members; an interface guarantees them a floor. Decide which you hand out on widely-read fields, and how membership changes are announced when diffing tools call them additive.

## The change looks safe from the server side Adding a member to a union type does not remove a field, narrow a type or tighten nullability. Every document written against the old schema still parses and still validates: its type conditions all still name types that overlap the union, and its selections are all still legal. A schema-diff check comparing the two versions reports an addition. Nothing in the specification, and nothing in a registry's breaking-change rules of the usual kind, flags it. The risk is entirely on the **client**, and it is invisible to the server because the client's failure is a rendering failure, not a protocol one. ## What actually comes back Execution determines the concrete object type at that position, then collects fields from the enclosing selection set plus every inline fragment whose type condition **matches**. A new member type matches none of the old branches. So the object is built from the outer selection alone. On a union, the outer selection can contain only `__typename` and fragment spreads, which means the response object is at most one key wide: ```json { "__typename": "DamageWriteOff" } ``` And if the client selected `__typename` only *inside* its branches, the object comes back empty. No error is raised anywhere: the errors array is untouched, the HTTP status is unchanged, and every field the client asked for that could apply did apply. From the transport's point of view this is a completely successful response. ## A concrete failure A 4-person platform team owning a warehouse inventory graph added `DamageWriteOff` to the `InventoryEvent` union so a new returns workflow could record write-offs. Eleven client screens read that union. Three of them rendered a blank row for every write-off event — the row component read `event.quantity`, found nothing, and drew an empty line — and one crashed on an exhaustive switch with no default arm. Roughly 2,300 events a day flowed through the union, of which write-offs were a low single-digit percentage, so the blank rows read as sporadic data quality noise for over a week before anyone connected them to the schema change. The telling detail: the server's own metrics were perfect. Request success rate, error rate and latency were all unchanged, because nothing failed. ## How to build so this degrades instead of breaking **Always select the discriminator outside the branches.** It costs nothing and guarantees that an unhandled object still identifies itself, which turns a silent blank row into something a client can log or render as "unsupported item". **Always write a default branch.** Exhaustive matching over a set the server controls is a bet that the set will never grow. Generated discriminated types help here — a good generator emits an "other" case precisely so the compiler forces you to handle it — but the discipline has to be there in hand-written consumers too. **Prefer an interface where consumers need a guaranteed shape.** This is the design lever. If `InventoryEvent` were an interface declaring `id`, `occurredAt` and `label`, a client could select those outside any branch, and a brand-new implementing type would still return all three. The row renders with real content and only the type-specific detail is missing. A union gives clients precision at the cost of forcing every one of them to enumerate members forever; an interface gives them a floor. That tradeoff is the actual interview question hiding behind this scenario. **Treat abstract-type membership as a client-visible change even though it is schema-additive.** Where field-usage data is collected per operation, you can at least see which operations select that abstract field and tell those owners before shipping. Where it is not, the change needs a human announcement. ## What weak answers miss The common wrong answer is that the client gets an error, or a null, or that validation rejects the old document. None of those happen — and the reason this scenario is asked at senior level is precisely that the failure is silent. A candidate who has lived through it talks about the absence of a signal: no error rate to alert on, no failed request to trace, just a shape the client did not expect. The second common miss is treating it as purely a client bug, with no discussion of whether a union was the right abstraction to hand many consumers in the first place.

  • Would the outcome differ if the abstract type were an interface rather than a union?
    Yes, and that is the main design lever. A client can select an interface's declared fields outside any branch, so a new implementing type still returns them. The row keeps its identifier, timestamp and label and loses only type-specific detail, which is a degraded render rather than a blank one.
  • How would you detect this before shipping the schema change?
    Look at which registered operations select that abstract field and tell their owners; per-operation field-usage data makes that a query rather than a guess. Failing that, treat union membership as a client-visible change by policy and announce it, because ordinary breaking-change diffing classifies it as additive and will stay quiet.
  • Why do the server's error metrics stay flat during this failure?
    Because nothing failed. The document validated, every matching field resolved, the errors array is absent and the status is unchanged. The mismatch is between the response's shape and the consumer's expectations, which no server-side signal can see — it surfaces only in client-side telemetry or user reports.

It is a form with checkboxes for every category the office knew about last year. A parcel in a new category is not rejected — it just arrives with nothing ticked, and whoever reads the form downstream has to decide what a blank one means.

saying these in an interview costs you the question

  • Claiming the old document fails validation after the change
  • Expecting an error entry for the unhandled member
  • Assuming branch fields come back as null instead of missing
  • Calling union member addition a purely server-side change
  • Relying on exhaustive type-name switches with no default
  • Thinking error-rate monitoring would have caught it

context