skip to content

In GraphQL, why can a resolver's selection lookahead miss a selected field?

level: middleimportance: should knowfreq 41%

answer

  1. The selection set is not flat
  2. Three kinds of selection, not one
  3. Names in the text can lie
  4. Aliases move the response key
  5. Collection applies @skip and @include first

basics

~10 s

The document is not flat. A field can arrive through a fragment spread or an inline fragment, an alias renames its response key, and @skip or @include can remove it entirely.

solid answer

~50 s

A naive lookahead reads the resolved field's immediate child selections and compares names. That fails three ways. **Fragments**: a spread or an inline fragment is a selection in its own right, so `fileNumber` selected inside `...CaseHeader` is not an immediate field at all until you resolve the spread against the document's fragment definitions and check its type condition. **Aliases**: the response key is the alias when one is written, so matching keys instead of field names makes `principals: parties` invisible. **Conditional directives**: field collection applies `@skip` and `@include` using the request's variable values, so a field that is present in the text may never execute. A correct lookahead mirrors the spec's field-collection step — expand fragments, apply the conditional directives, key by response key but match on field name — and errs toward fetching when a branch is uncertain.

code

graphql · 12 lines
graphql
query CaseFile($id: ID!, $withHearings: Boolean!) {
  case(id: $id) {
    ...CaseHeader
    principals: parties(role: PRINCIPAL) { fullName }
    hearings @include(if: $withHearings) { scheduledFor }
  }
}

fragment CaseHeader on Case {
  fileNumber
  status
}

go deeper

for a junior

Know that a selected field can be written inside a fragment or renamed with an alias, so the text a resolver sees is not simply the list of names the response will contain.

for a middle

Walk through field collection out loud: expand fragment spreads using the document's definitions, apply @skip and @include with this request's variables, group by response key. Say why matching keys instead of field names breaks on aliases.

for a senior

Separate the failure modes by blast radius — missed fragments and aliases produce wrong data, ignored conditional directives only waste work — and insist on a test document that combines an alias, a named fragment, an inline fragment and a variable-driven @include.

for a principal

Push for one shared, tested collection helper rather than a hand-rolled walk in every resolver. The correctness rules here are subtle enough that duplicating them across a codebase guarantees at least one copy is wrong.

## The document is a tree, not a list The mental model that breaks lookahead is "the selection set under my field is a list of field names". It is not. A selection set holds three kinds of selection: **fields**, **fragment spreads** (`...CaseHeader`) and **inline fragments** (`... on SealedCase { sealReason }`). Only the first kind has a name you can compare. The other two are containers, and the fields you care about may be several levels of container deep. The executor deals with this in a step the specification calls field collection: given a selection set and the object type being resolved, it produces an ordered map from **response key** to the list of fields that share that key, expanding fragments and applying the conditional directives along the way. Lookahead that does not reproduce that step is guessing. ## Failure one: fragment spreads ```graphql query CaseFile($id: ID!, $withHearings: Boolean!) { case(id: $id) { ...CaseHeader principals: parties(role: PRINCIPAL) { fullName } hearings @include(if: $withHearings) { scheduledFor } } } fragment CaseHeader on Case { fileNumber status } ``` Read the `case` field's immediate child selections and you get three entries: one fragment spread and two fields. `fileNumber` and `status` are nowhere in that list. A resolver that projects columns from immediate field names alone will fetch neither, and both come back null. Expanding the spread also needs something the selection set does not contain: the **fragment definitions**, which live at the top level of the document. Any lookahead helper that cannot reach them cannot be correct, which is one reason servers put the document's fragments on the resolution-info argument. Inline fragments have the same shape but carry their own hazard — a **type condition**. `... on SealedCase` contributes its fields only when the object being resolved is of that type. For a concrete object type you can decide this immediately. For a field returning an interface or a union you often cannot: the runtime type is not known until the value has been resolved, which is *after* the fetch you are trying to optimize. The safe move is to treat every conditional branch as possibly present and fetch the union of them, because over-fetching is a cost bug and under-fetching is a correctness bug. ## Failure two: aliases move the key The **response key** is the alias when one is written, otherwise the field name. In the document above, `principals: parties(role: PRINCIPAL)` selects the field `parties` under the key `principals`. Two consequences: * If your lookahead collects response keys and asks "is `parties` selected?", the answer is no, and the join is skipped. * The same field can appear under several keys — `principals: parties(role: PRINCIPAL)` and `counsel: parties(role: COUNSEL)` are two separately executed selections of one schema field, with different arguments. So the rule is: key the collected map by response key, because that is what execution does and what the response will contain, but **match on field name** when deciding what to fetch. And when a field appears more than once with different arguments, remember that your fetch must satisfy every occurrence, not just the first one you found. ## Failure three: the conditional directives `@skip(if:)` and `@include(if:)` are evaluated during field collection using the coerced **variable values of this request**. `hearings @include(if: $withHearings)` is in the document text on every request, but is only collected when the variable is true. A lookahead that ignores the directives will join the hearings table on every call for a field that executes on some of them — wasted work that looks exactly like the over-fetch you set out to remove. Reading the directives requires the variable values, which is again why they sit on the resolution-info argument. Note the asymmetry: getting the directives wrong costs performance, while getting fragments or aliases wrong costs correctness. Prioritize accordingly when you are reviewing someone's lookahead helper. ## What a correct check looks like Mirror field collection rather than inventing a walk: 1. Iterate the selection set in order. 2. Drop any selection excluded by `@skip` / `@include` under the request's variables. 3. For a field, record it under its response key. 4. For a fragment spread, look the definition up in the document's fragments; for an inline fragment, use it directly. If its type condition applies to the type at hand — or might apply, for an abstract type — recurse into it and merge the results under the same keys. Most servers expose a helper that does exactly this; prefer it to a hand-rolled walk, and if you must hand-roll one, test it against a document containing an alias, a named fragment, an inline fragment and a variable-driven `@include` at once. Those four in one document is the case that separates a lookahead that works from one that has only ever seen flat queries in tests.

  • Your lookahead needs to know whether an inline fragment's fields will be selected, but the field returns an interface. What do you do?
    You cannot know yet — the runtime type is only determined after the value is resolved, which is after your fetch. Take the union of every branch whose type condition could apply and fetch for all of them, or defer the decision to per-concrete-type resolvers further down. Guessing one branch risks a silent null; fetching the union only risks reading a few extra columns.
  • Should a lookahead helper match on the response key or the field name, and why does it matter?
    Match on the field name to decide what to fetch, but key the collected map by response key because that is what execution and the response use. An alias changes the key while leaving the field name untouched, so a key-based match makes an aliased selection invisible. The same field can also appear under several keys with different arguments, and the fetch must satisfy all of them.
  • Which of these mistakes is a correctness bug and which is only a performance bug?
    Missing fields hidden in fragments or behind aliases is a correctness bug: the data is absent, so the field resolves null, or errors and propagates if it is non-null. Ignoring @skip and @include is only a performance bug: you fetch for a field that will not execute, which wastes work but produces the right response.

saying these in an interview costs you the question

  • Treats the selection set as a flat list of field names
  • Matches response keys and so misses aliased fields
  • Forgets fragment spreads need the document's fragment definitions
  • Ignores @skip and @include when reading the selection
  • Picks one inline-fragment branch before the runtime type is known
  • Assumes each schema field appears at most once per selection set

context