Every Field Is a Function Call You Have Not Written Yet
Every Field Is a Function Call You Have Not Written Yet
A schema describes a tree, a document selects a slice of it, and the server still has to produce actual values. Resolvers do that: one function per field, turning inputs into the value that field contributes to the response.
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
}
const resolvers = {
Query: {
user: (_root, { id }) => db.users.find(id),
// returns { id: 1, name: "Ada", email: "ada@example.com" }
},
// User.id, User.name, User.email need no code: the default resolver
// reads the matching property from the parent value.
};
For the document { user(id: "1") { name } } the executor performs exactly two steps:
Query.user(args) ──► { id: 1, name: "Ada", email: "ada@example.com" }
│
default resolver reads .name
▼
response: { "user": { "name": "Ada" } }
The model to keep: the response is assembled field by field; it is not a serialization of whatever the top resolver returned. That is why a client asking for two fields receives exactly two, and why adding a field to the schema disturbs nothing — it either falls back to the default resolver or gets a function of its own.
It also explains the shape of resolver maps. Keys are type names, then field names:
{ Query: { ... }, Mutation: { ... }, User: { ... }, Post: { ... } }
A resolver declared under the wrong type silently does nothing, and the field falls back to the default — one of the most common "why is this always null" debugging sessions in GraphQL.
The Four Inputs Every Resolver Receives
The Four Inputs Every Resolver Receives
Every resolver is invoked with the same four arguments, in the same order:
| Position | Name | Carries |
|---|---|---|
| 1 | parent (aka root) |
the value the parent field resolved to |
| 2 | args |
coerced arguments for this field |
| 3 | context |
request-shared state: user, data sources, loaders |
| 4 | info |
AST, schema nodes, path, and return type for this field |
parent is what makes nesting work. Query.user returns a user object, so User.orders receives that object and can query with its id:
const resolvers = {
Query: {
user: (_p, { id }, ctx) => ctx.dataSources.users.byId(id),
},
User: {
orders: (user, { first }, ctx) =>
ctx.dataSources.orders.forUser(user.id, first),
// user === whatever Query.user returned; nothing more, nothing less
},
};
If Query.user returned a plain string instead of an object, User.orders would receive that string — resolvers do not know about the schema's intent, only about the value handed to them. The full downward journey of that value is parent argument flow.
context is constructed once per request and shared by every resolver in that execution; resolver context covers what belongs there and the cross-request leak you get by building it too early. args arrives already coerced: types, defaults, and non-null rules were applied during validation.
info is the escape hatch. info.path says where you are (["user", "orders", 3]), info.parentType and info.returnType describe the schema node being filled, and info.fieldNodes holds the raw selection including aliases and directives. Reach for it when building dataloader keys, accounting query cost, or tracing — never as a substitute for arguments the schema already declared.
A resolver does not have to receive all four. (_p, args) => ... is fine when context is unused; positional destructuring keeps signatures honest about what a field actually needs.
Missing Resolvers, Undefined, and Resolver Independence
Missing Resolvers, Undefined, and Resolver Independence
Three behaviors are worth predicting before you meet them in production.
Field order in the schema and document does not sequence anything. Resolvers run as the executor reaches them, and siblings may execute concurrently. Code that assumes "the field listed first ran first" breaks intermittently under load — what actually overlaps is explained in async resolver patterns.
A missing resolver falls back to the default resolver, which reads parent[fieldName]:
// schema: type User { nickname: String }
resolveDefault({ nickname: "ada" }, "nickname") // -> "ada"
resolveDefault({ nick: "ada" }, "nickname") // -> undefined (names differ)
Renamed properties, computed values, or data keyed differently all need an explicit resolver. The default is why plain JSON backends appear to need no resolvers at all — and why renaming a property in an upstream JSON payload silently turns a field null.
undefined becomes null, or an error:
resolver returns undefined + field is nullable -> "field": null
resolver returns undefined + field is String! -> field error, null bubbles
The second line is where "this used to work" bugs live: a data source stopped returning a property, the field was non-null, and the whole parent object went null. The bubbling rule itself is resolver error propagation.
Because resolvers are independent functions, they are cheap to unit test — call the function with a hand-made parent, literal args, and a stub context, then assert the return value. No server, no HTTP, and the failure you are chasing reproducers in a file you can step through. The move from stubs to real integrations is database data source wiring.
Practice Problems
Given a schema where Query.book returns a book, Book.author resolves its author, and Author.books lists that author's books with a limit argument, write the resolver map. Scalars on User/Book must come from the default resolver, and every database access must go through the context data sources.
Example:
Input: { book(id: "b1") { title author { name books(first: 2) { title } } } }
Output: { "data": { "book": { "title": "Editing Emacs", "author": { "name": "Ada", "books": [ { "title": "Notes" }, { "title": "Signals" } ] } } } }
Book.author receives the book value returned by Query.book, so it can read book.authorId; Author.books then receives the author value and uses its own id.
Optimal Solution — O(1) time, O(1) space
One resolver per field that needs work, default resolution for plain properties, and parent values threaded downward for nested ids.
const resolvers = {
Query: {
book: (_p, { id }, ctx) => ctx.dataSources.books.byId(id),
},
Book: {
author: (book, _args, ctx) => ctx.dataSources.authors.byId(book.authorId),
// title, id, year: no resolver — default resolver reads them off the book
},
Author: {
books: (author, { first = 20 }, ctx) =>
ctx.dataSources.books.byAuthor(author.id, { limit: first }),
},
}; Edge Cases:
- A book with a null authorId must resolve author to null only if the field is nullable
- Omitting first must apply the declared default rather than returning an unbounded list
- The default resolver returning undefined on a non-null scalar turns the whole book null
Quiz
1. In User.orders, what does the first resolver argument contain?
2. The schema declares nickname: String, no resolver exists for it, and the parent value is { nick: "ada" }. What does the client receive?
3. Which of these is not one of the four standard resolver inputs?
4. Why can you not use the order of fields in a document to sequence work?
Flashcards
Question
What a resolver is
Click to reveal answer
Answer
The function that produces the value for exactly one field in the selection set. The response is assembled field by field from resolver returns.
Question
The four resolver inputs
Click to reveal answer
Answer
parent (value the parent field resolved to), args (coerced arguments), context (per-request shared state), info (AST, schema, path, return type).
Question
Default resolver behavior
Click to reveal answer
Answer
Reads parent[fieldName]. Perfect for plain JSON, silent failure when the property name differs — that mismatch is a common cause of unexpected nulls.
Question
undefined from a resolver
Click to reveal answer
Answer
Nullable field -> null in the response. Non-null field -> field error, and null bubbles to the nearest nullable ancestor.
Question
Why resolver maps key by type name
Click to reveal answer
Answer
{ Query: {...}, User: {...} } — a resolver registered under the wrong type never runs and the field silently falls back to the default resolver.
Revision Notes
Key Takeaways
- 1. Every selected field maps to one resolver call; responses are built field by field, not serialized wholesale
- 2. parent carries the value the enclosing field produced — that is the entire mechanism behind nesting
- 3. The default resolver explains both the zero-boilerplate case and the silently-null case
- 4. Resolvers are independent functions, so they run concurrently where allowed and unit-test without a server
Interview Tips
- • Trace a nested document out loud: which resolver runs, what it returns, and what the child resolver receives as parent — interviewers listen for that causal chain
- • Be ready to explain why a field is null: no resolver, property-name mismatch, or a non-null child bubbling — three different causes, three different fixes
Cheat Sheet
Resolver Fundamentals Cheat Sheet
const resolvers = {
Query: {
user: (parent, args, context, info) => context.dataSources.users.byId(args.id),
},
User: {
orders: (user, { first }, context) => context.dataSources.orders.forUser(user.id, first),
},
};
- Signature:
parent, args, context, info— in that order, every time - Missing resolver → default resolver reads
parent[fieldName]→ undefined → null (or error if non-null) - Wrong type key in the map = resolver never runs, field silently null
argsis already coerced (defaults, non-null, enums applied)infofor path/schema/selection metadata, not for arguments- Sibling fields may run concurrently — never sequence work by document order