Skip to content
intermediate Phase 2 · Schema Foundations: Types and the Query Root

Enums, Lists, and Non-Null Modifiers

Enumeration types plus list and non-null wrappers for precise field contracts.

45m
1 problems
Topic Progress 0%

Enums Lock a Field to a Closed Set

Enums Lock a Field to a Closed Set

A field that may hold one of four states is everywhere in real APIs: order status, plan tier, severity. Typed it as String and every consumer re-implements the same validation, discovers valid values from the code, and breaks on the day someone writes "Cancelled" instead of "CANCELED".

enum OrderStatus {
  PENDING
  PAID
  SHIPPED
  CANCELLED
}

type Order {
  id: ID!
  status: OrderStatus!
  statusHistory: [StatusChange!]!
}

type StatusChange {
  at: String!
  from: OrderStatus
  to: OrderStatus!
}

The enum becomes a first-class type: it can be used in output fields, in arguments, and inside input objects, and every use draws from the same declared set.

Enum values are names, not strings. They must be valid GraphQL names: no quotes, no spaces, no leading digits, and they cannot collide with true, false, null, or the reserved __ prefix. That constraint is a feature: code generators turn the set into a union type, so an editor autocompletes the four valid values and flags CANCELED before the code compiles, see TypeScript code generation.

Invalid values fail early. A bad literal inside the query fails validation; the same bad value arriving through a variable fails variable coercion. Either way the request stops before execution, which is precisely what status: String could never promise.

Evolution is asymmetric. Adding an enum value is additive and safe, because no existing client can hold it. Removing one breaks every query and every client literal that uses it, so removals follow the same deprecation discipline as any other breaking change; see schema evolution rules and deprecation strategy.

Six Ways to Wrap a Type

Six Ways to Wrap a Type

List and non-null are wrappers that compose, and each combination states a different promise about the data. Reading them as a matrix is faster than memorizing six rules.

Declaration Is a list List itself null Items null
String no - -
String! no no -
[String] yes yes yes
[String!] yes yes no
[String]! yes no yes
[String!]! yes no no

Two structural rules make the matrix predictable: the ! always binds to whatever is immediately to its left, and wrapping order matters because the two wrappers are distinct types. [String]! is a non-null list of possibly-null strings; [String!] is a nullable list whose items are never null. A validator treats them as different types, so assigning one where the other is declared is an error rather than a coercion.

What each looks like on the wire:

{ "tags": null }          // legal for [String] and [String]!, illegal for [String]!
{ "tags": [] }            // legal for all three list declarations: empty is not null
{ "tags": ["a", null] }   // legal only for [String] and [String]!

The design consequences are concrete:

  • [Order!]! for a screen that cannot render without its list, and cannot render a row without an id.
  • [Order] when individual rows may fail but the screen should still show the rest.
  • [String]! when the list is guaranteed but entries may be unknown.

Note that null and [] are different answers: an empty list means "none found," null means "unknown or failed." Collapsing them in the schema forces every consumer to guess, which is the problem wrappers exist to prevent.

Arguments follow the same matrix. A field declared search(term: String!) rejects a missing argument during validation, so every caller must commit to a term; search(term: String) lets the caller omit it entirely and pushes the "no filter" decision into the resolver. Declaring it wrong in either direction is felt immediately: either clients send arguments they do not care about, or filtering silently stops happening.

When Null Breaks the Contract

When Null Breaks the Contract

Declaring a field String! is a promise that it will never be null. The specification's enforcement of that promise is the single most surprising behavior in GraphQL, and it deserves a full example.

type Query {
  event(id: ID!): Event!
}

type Event {
  id: ID!
  title: String!
  venue: Venue!
}

type Venue {
  name: String!
}

A resolver returns an event whose venue exists but whose name is null. One field violated one promise, and here is the chain reaction:

  1. name cannot be null, so the executor raises a field error for it.
  2. Since name is non-null inside Venue, Venue itself must become null - but venue: Venue! is also non-null, so the null propagates again.
  3. event: Event! is non-null, so it propagates once more to the root.
  4. The response collapses:
{
  "data": null,
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Venue.name.",
      "path": ["event", "venue", "name"]
    }
  ]
}

One bad record blanked the entire screen. The fix is a schema decision, not a code fix: make event nullable so the failure stops at the top (data: { "event": null } plus an errors entry), or make venue nullable so the rest of the event still renders. Non-null is a commitment to build the whole branch from guaranteed parts; see null propagation semantics for the full rules and standard error shape for how the error is described.

The rule that makes the matrix and the chain consistent: errors propagate to the nearest nullable ancestor. If there is none before the root, data itself becomes null.

Interview angle: "What is the difference between [String]! and [String!]?" Answer with the matrix, then show you understand the cost by walking the propagation chain above.

Practice Problems

0 / 1 solved
Predict the Response When a Non-Null Field Breaks
Null propagation

Given the schema type Query { event(id: ID!): Event! } with Event { id: ID! title: String! venue: Venue! } and Venue { name: String! }, predict the exact response for two cases. Case A: the venue record exists but its name resolves to null. Case B: the same data, but Query.event is declared nullable (event: Event) instead. Include the errors array in both answers and say which fields the client can still render.

Example:

Input: Case A: all fields non-null, venue.name resolves null

Output: {"data": null, "errors": [{"message": "Cannot return null for non-nullable field Venue.name.", "path": ["event", "venue", "name"]}]}

name is non-null, so the null propagates through venue (non-null), event (non-null), and up to the root: the entire data entry collapses even though the id resolved fine.

Optimal Solution — O(d) where d is the depth of the failing path time, O(d) space

Walk the path from the failing field upward: raise a field error, then keep replacing nulls with propagation while the current position is non-null, stopping at the first nullable ancestor or at data.

// Case A: event: Event! -> venue: Venue! -> name: String!
{
  "data": null,
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Venue.name.",
      "path": ["event", "venue", "name"]
    }
  ]
}

// Case B: event: Event (nullable) - propagation stops at event
{
  "data": { "event": null },
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Venue.name.",
      "path": ["event", "venue", "name"]
    }
  ]
}

Edge Cases:

  • Two independent failures produce one errors entry each, at their own paths
  • A nullable item inside a list ([Venue]) fails alone without touching sibling items or the list itself

Quiz

1. A schema declares tags: [String!]!. Which responses are legal?

Question 1 options

2. Why are [String]! and [String!] different types?

Question 2 options

3. venue.name is String! but the resolver returns null, and venue: Venue! and event: Event! are also non-null. What does the client receive?

Question 3 options

4. What happens when a query passes an undeclared value for an enum argument?

Question 4 options

Flashcards

Question

The six wrapper combinations

Answer

T, T!, [T], [T!], [T]!, [T!]! - the ! always binds to its left neighbor, and wrapping order makes each a distinct type.

Question

[String]! vs [String!]

Answer

[String]!: guaranteed list, items may be null. [String!]: list may be null, items never are. [String!]! guarantees both.

Question

Null propagation rule

Answer

A null in a non-null position raises a field error and propagates to the nearest nullable ancestor. If none exists before the root, data becomes null.

Question

null vs [] for a list

Answer

[] means none found; null means unknown or failed. Declare [T!]! when the screen cannot render without the list, [T] when rows may fail independently.

Question

Enum evolution rule

Answer

Adding a value is additive and safe; removing one breaks queries and clients, so it requires a deprecation window like any breaking change.

Revision Notes

Key Takeaways

  • 1. Enums turn a scattered string convention into a validated, code-generatable contract
  • 2. List and non-null compose, bind leftward, and produce six distinct types with different response shapes
  • 3. A non-null violation propagates to the nearest nullable ancestor and can collapse the whole data entry
  • 4. Nullability is a design tool: it decides how much of a response survives one bad record

Interview Tips

  • • Answer the [String]! vs [String!] question with the matrix, then volunteer the propagation consequence
  • • When designing a schema, say aloud which fields must be non-null and what happens when one of them fails

Cheat Sheet

Enums, lists, non-null

Enum

enum OrderStatus { PENDING PAID SHIPPED CANCELLED }

Values are names (no quotes); add = safe, remove = breaking; invalid literal fails validation, invalid variable fails coercion.

Wrappers (! binds left)

Decl list list null items null
T no - -
T! no no -
[T] yes yes yes
[T!] yes yes no
[T]! yes no yes
[T!]! yes no no

Propagation: null in non-null position -> field error -> nearest nullable ancestor -> data: null if none.

Shapes: [] != null; use [T!]! for required lists, [T] for rows that may fail independently.

Continue Learning