Concept
The beginner framing: a GraphQL schema is a contract, written in Schema Definition Language (SDL), declaring every type, field, and argument the API exposes, and it's the single source of truth both the client and server agree on.
type User {
id: ID!
name: String!
bio: String
}
type Query {
user(id: ID!): User
}Nullability: opt-IN to non-null, and the null-bubbling consequence
In GraphQL, fields are nullable by default, bio: String means "a string, or null." Adding ! makes a field non-null, name: String! means "this will never be null; if the resolver can't produce a value, that's a genuine error, not an empty result."
type User { id: ID!, name: String!, bio: String }
type Query { user: User } # nullable, no `!`Confirmed by running this exact scenario: if the name resolver throws, the error doesn't just null out name, GraphQL walks up the response tree looking for the nearest nullable ancestor to absorb the null. Here, user (the Query field) is nullable, so the null stops there: { "data": { "user": null } }, and the successfully-resolved id/bio are discarded even though they never errored.
type Query { user: User! } # NON-null this timeConfirmed by running the identical failing resolver against this schema: since user is now also non-null, there's no nullable ancestor to absorb it at that level either, the null keeps bubbling, all the way to the response root: { "data": null }. The entire response is discarded because one deeply-nested field failed.
This is the single most consequential schema-design decision in GraphQL: marking fields non-null (!) is a real guarantee to callers, but stacking non-null fields on top of each other means a single failure anywhere in that chain can wipe out an increasingly large portion, potentially all, of the response. A common, deliberate schema-design practice is keeping fields nullable more often than intuition suggests, specifically to contain failure blast radius.
Interfaces vs. unions: shared shape vs. genuinely different shapes
interface Node { id: ID! }
type User implements Node { id: ID!, name: String! }
type Post implements Node { id: ID!, title: String! }An interface declares fields that multiple types share, User and Post both guarantee an id, queryable generically through the Node interface when the specific concrete type doesn't matter yet.
union SearchResult = Book | Movie
type Query { search: [SearchResult!]! }query {
search {
... on Book { title }
... on Movie { title director }
}
}Confirmed by executing this exact query: a union groups genuinely different shapes with no required shared fields (Book and Movie don't need anything in common), the client uses inline fragments (... on TypeName) to request type-specific fields, and the server determines the concrete type per result (via __typename, or an explicit resolveType function in code-first setups).
Input types: structured arguments for mutations
input CreateUserInput {
name: String!
email: String!
role: String = "member" # default value
}
type Mutation {
createUser(input: CreateUserInput!): User!
}An input type is structurally similar to a regular type, but specifically for arguments, bundling several related arguments into one named, reusable shape rather than a long flat list of individual arguments, with support for default values on fields that are commonly omitted.
Schema-first vs. code-first
# schema-first: SDL written directly, resolvers supplied separately
type Query { user(id: ID!): User }// code-first (e.g. Pothos, Nexus): the schema is BUILT programmatically,
// often generating the SDL from TypeScript types rather than the reverse
builder.queryField("user", (t) => t.field({ type: User, args: { id: t.arg.id() }, resolve: ... }));Schema-first (writing SDL directly, as every example above does) is simpler to start with and keeps the contract maximally explicit and tooling-agnostic. Code-first frameworks generate the schema from code (often deriving GraphQL types from existing TypeScript types), trading some of that explicitness for stronger compile-time guarantees that the schema and the resolvers/types backing it can't drift out of sync, a real tradeoff, not a strictly-better replacement.
Try It
Predict the outcome before checking the solution.
type Comment { id: ID!, text: String! }
type Post { id: ID!, title: String!, comments: [Comment!]! }
type Query { post: Post }If the comments resolver throws an error, but title resolves successfully, what does the response look like?
Solution
comments is non-null ([Comment!]!), so its failure nulls out the nearest nullable ancestor. Walking up: comments itself has no nullable wrapper, so the null bubbles to Post, but Post here is accessed via post: Post, which is nullable (no ! on the Query field). So the null stops there: { "data": { "post": null } }, discarding title too, even though it succeeded, exactly the same bubbling behavior confirmed above, just one level deeper. If post: Post! were non-null instead, the null would continue bubbling all the way to data: null.
Implement It Yourself
Build a minimal null-bubbling simulator, to internalize the exact mechanism:
function resolveField(fieldName, isNonNull, resolverResult, parentIsNullable) {
if (resolverResult.error) {
if (isNonNull && !parentIsNullable) {
return { bubbleFurther: true }; // propagate the null UP another level
}
return { value: null, bubbleFurther: false }; // absorbed HERE
}
return { value: resolverResult.value, bubbleFurther: false };
}
// simulate: name (non-null) fails, user (nullable) is its parent
const step1 = resolveField("name", true, { error: true }, true); // parent user IS nullable
console.
This is a deliberately simplified stand-in, the real algorithm walks the actual response tree recursively, but the core rule is exactly this: a non-null field's error propagates upward until it finds a nullable position to stop at, nulling everything in between.
Under the Hood
The nullability/error-bubbling behavior confirmed here is the exact mechanism N+1 Problem & DataLoader builds on when discussing what happens if a batched DataLoader call fails for one key among many, understanding null-bubbling first makes that failure-handling story make sense rather than seeming like a separate, unrelated concern.
Common Mistakes
1. Marking fields non-null reflexively, without considering failure blast radius
type Query {
post: Post! # if Post's resolver fails, the ENTIRE response is discarded
}Confirmed: stacking ! on deeply nested fields means a single failure anywhere in that chain can null out far more of the response than the failure alone would justify, nullability should be a deliberate choice, not a default habit of "it'll always have a value, so why not."
2. Assuming a failed non-null field only affects that one field
{ user { id name bio } }
# name fails → the WHOLE user object is null, not just the name keyConfirmed by execution, this is the single most surprising GraphQL behavior for people coming from REST, where a missing field would typically just be omitted or null, not take down sibling fields that resolved successfully.
3. Using a union where an interface would be more appropriate, or vice versa
union UserOrAdmin = User | Admin # ❌ if User and Admin share nearly ALL fields, this forces
# duplicate field declarations and awkward inline fragments everywhereIf the types genuinely share most fields with only minor differences, an interface (with the differing fields added per implementing type) is usually a better fit than a union, which is meant for genuinely dissimilar shapes.
Best Practices
- Default to nullable, and mark
!deliberately, specifically for values that are structurally guaranteed to exist (like an object's ownid), not just "usually" present. - Consider the null-bubbling blast radius before stacking non-null on deeply nested fields, a single failure should ideally only take down the smallest reasonable portion of the response.
- Use interfaces for shared-shape polymorphism, unions for genuinely different shapes, the wrong choice forces awkward workarounds in either direction.
- Bundle related mutation arguments into input types rather than long flat argument lists, especially once a mutation has more than 2-3 arguments.
Performance Tips
- Schema design has no direct runtime performance cost of its own, but poor nullability choices can cause more re-fetching than necessary (a client retrying an entire nulled-out response because one deeply nested field failed, when most of the data was actually fine), an indirect but real performance/UX consequence.
- Union type resolution (determining the concrete type per result) adds a small per-item overhead compared to a single concrete type, negligible for typical list sizes, worth being aware of for very large result sets.
