One Document That Renders Two Ways
One Document That Renders Two Ways
A settings screen shows identity fields to everyone but moderation controls only to moderators. Without directives you maintain two near-identical documents, or fetch everything and strip fields in the client, or branch into two operations that quietly drift apart.
The spec ships the answer as built-in directives:
query Settings($isModerator: Boolean! = false, $hideAvatar: Boolean! = false) {
me {
id
name
role
moderationQueue @include(if: $isModerator) # present only for moderators
avatarUrl @skip(if: $hideAvatar) # removed when the client hides it
recentActions(first: 5) { actor action at }
}
}
Both directives accept Boolean! — a literal, a variable, or a variable with a default — and nothing else. @include(if: true) keeps a field, @skip(if: true) removes it, and the document text stays byte-identical for every caller while the variables decide the shape.
Two immediate consequences of that design:
- There is no expression language.
@skip(if: !$showAvatar)is a syntax error; GraphQL values are literals or variables, never negations. Compute the opposite on the client and pass$hideAvataras its own variable. - Directives are spec-defined, not vendor syntax. Every server, client, and graph tool already understands
@includeand@skip, so conditional selections need no custom directive implementation.
The constant-document property is what makes directives useful in exactly one situation: shapes that share most fields and flip a few. When two shapes have little in common, two documents are the honest answer.
Also worth separating: @include and @skip are the spec's own directives, so every conforming server implements them and they are the only two that change which fields execute. @deprecated is spec-defined as well but is pure documentation, and anything else you see — @auth, @cacheControl, @key — is a server-defined directive with its own location rules; see custom schema directives.
Directives, Variables, and Precedence
Directives, Variables, and Precedence
Where they are allowed: fields, fragment spreads, and inline fragments — not operations and not fragment definitions. Putting @include on a fragment definition is a validation error; apply it to each spread instead.
query Feed($withComments: Boolean!, $withAuthors: Boolean!) {
posts(first: 10) {
id
title
...Body @skip(if: !$withComments) # ✗ — no ! operator in GraphQL values
comments(first: 3) @include(if: $withComments) {
body
author { ...UserCard @include(if: $withAuthors) }
}
}
}
Corrected, the evaluation rules in order:
@skip(if: true)wins. A field is included only when skip is absent or false and include is absent or true. With both directives on one field and bothif: true, the field is dropped.- Validation runs first; effects apply during execution. The document — directive argument types, allowed locations, and field selections as written — is validated before execution. Field-merging rules do not evaluate directive values, so a field marked
@skip(if: true)must still satisfy every validation rule. Only when execution begins do directives change what resolvers are called for. - Omission is not null. A field removed by a directive does not appear in
dataat all; a nullable field that resolved tonullappears with the valuenull. Client code that distinguishes absent from null behaves correctly with directives. if:is non-null Boolean. A variable used there must beBoolean!or carry a default — passing JSONnullis a coercion error, not "false".
A concrete consequence of rule 2: avatarUrl @skip(if: $x) still appears in the schema's field list for that selection, so a query-cost tool prices it even when the runtime intent is to skip.
The Cache Problem Conditional Fields Create
The Cache Problem Conditional Fields Create
Directives change the response shape without changing the document text, and that breaks an assumption every cache makes.
HTTP and CDN caching. A cache keyed on the query hash alone will happily serve the moderator's response — moderation queue included — to a caller whose variables said otherwise. The key has to include the variables, or conditional responses must be marked private:
// wrong: key = sha256(document)
// right: key = sha256(document) + stableStringify(variables)
Normalized client caches. Run the same document with hideAvatar false, then true: the first read stores me without avatarUrl, and a later cache read of me { avatarUrl } misses even though the field is "in" the query. Partial reads and fetch policies have to account for fields that were conditionally absent — see response caching strategy.
Cost and monitoring. Skipped fields still passed validation, so a budget computed from the document charges for work the request never does. That is the safe direction (over-charge rather than under-charge), but it means budgets cannot reflect variable intent.
When two documents win. If the shapes share little — a public marketing query versus an internal admin query — separate operations are easier to cache, budget, authorize, and read. Directives pay off when the overlap is large and only a few fields flip.
Interview angle, in one answer: @skip wins over @include; effects are applied at execution while validation inspects the document as written; removed fields are absent from data rather than null; and identical document text with different shapes means any cache key must include the variables. That sequence is what separates people who have shipped conditional fields from people who have read the syntax.
Practice Problems
A settings screen needs the same document for moderators and regular members: moderation fields for moderators only, avatar only when the client shows it. Write the operation with directives driven by variables, list the two variables payloads, and predict the keys present in each response.
Example:
Input: type User { id: ID! name: String! avatarUrl: String role: Role! moderationQueue: [Action!]! }
Output: query Settings($isModerator: Boolean! = false, $hideAvatar: Boolean! = false) { me { id name role avatarUrl @skip(if: $hideAvatar) moderationQueue @include(if: $isModerator) { action at } } } // moderator, avatar shown → id, name, role, avatarUrl, moderationQueue // member, avatar hidden → id, name, role
Both variants come from one constant document; only the variables change, so the response key set follows directly from the two Boolean values.
Optimal Solution — O(1) time, O(f) space
Drive each conditional field from its own Boolean variable: @include adds, @skip removes, and the document text never changes so it stays hashable and cacheable (with the variables joined to the cache key).
query Settings($isModerator: Boolean! = false, $hideAvatar: Boolean! = false) {
me {
id
name
role
avatarUrl @skip(if: $hideAvatar)
moderationQueue @include(if: $isModerator) {
action
at
}
}
} Edge Cases:
- Both directives true on one field — @skip wins and the field is dropped
- Passing null for a Boolean! variable — coercion error before execution
- A CDN keyed on the document hash alone — it would serve one variant to the other caller
Quiz
1. A field carries @skip(if: true) and @include(if: true). What does the response contain?
2. When do @skip and @include actually take effect?
3. How does a field removed by @skip(if: true) appear in the response?
4. Why do conditional fields complicate HTTP caching?
Flashcards
Question
@include vs @skip
Click to reveal answer
Answer
@include(if: true) keeps the field; @skip(if: true) removes it. Both take Boolean! and apply to fields, spreads, and inline fragments.
Question
Precedence rule
Click to reveal answer
Answer
A field is included only if skip is absent/false AND include is absent/true — @skip(if: true) always wins.
Question
Effect timing
Click to reveal answer
Answer
Validation checks the document as written; directives change what runs during execution.
Question
Absent vs null
Click to reveal answer
Answer
Directive-removed fields are missing from data; resolved nullable fields appear as null.
Question
Cache key with directives
Click to reveal answer
Answer
Same document, different shapes ⇒ key = hash(document) + variables, or keep conditional queries out of shared caches.
Revision Notes
Key Takeaways
- 1. @include and @skip toggle fields per request while the document text stays constant
- 2. Skip wins when both directives are true; effects apply at execution, after validation
- 3. Removed fields are absent from the response, not null
- 4. Constant text plus variable shapes means cache keys must include variables
Interview Tips
- • Give the precedence answer immediately, then say where in the pipeline directives take effect
- • Explain absent-versus-null, and why client code should branch on key presence
- • Walk through the cache scenario: identical document, two variables, one shared cache entry serving the wrong shape
Cheat Sheet
@include / @skip Cheat Sheet
query Settings($isModerator: Boolean! = false, $hideAvatar: Boolean! = false) {
me {
id name
moderationQueue @include(if: $isModerator)
avatarUrl @skip(if: $hideAvatar)
...AuditTrail @include(if: $isModerator) # spreads accept directives too
}
}
- Allowed on: fields, fragment spreads, inline fragments — not fragment definitions
if:is Boolean! — literal, variable withBoolean!, or defaulted variable- No
!operator: compute!xclient-side and pass$hideX - Precedence: skip true ⇒ dropped, whatever include says
- Timing: validation sees the document as written; effect applies at execution
- Omitted key ≠ null key
- Cache key = hash(document) + variables, or mark conditional responses private