06 · Mutations and Input Types¶
Queries read. Mutations write. Mechanically, a mutation is just another root operation
type — type Mutation sits next to type Query, and its fields have resolvers like any
other. Two things make it different, though: a mutation's top-level fields execute
serially, and the arguments you send are usually input object types, which follow
their own rules. This lesson covers both, with every case run.
Input types versus object types¶
You can't pass a Book object as an argument. Output types (type) can have fields with
arguments, can reference interfaces and unions, and can form cycles (Book.author.books).
None of that makes sense for data the client sends, so the spec has a separate kind:
An input type's fields can only be scalars, enums, lists, or other input types. Fields can
have defaults. It's common — and clearer — to keep input and output types separate even
when they look alike: Book has a server-generated id; AddBookInput doesn't.
What happens if you try to use an output type as an argument? Something surprising:
import { makeExecutableSchema } from "@graphql-tools/schema";
import { graphql, validateSchema } from "graphql";
const schema = makeExecutableSchema({ typeDefs: `type Query { x(b: Book): Int } type Book { id: ID }` });
console.log("built OK; validateSchema says:", validateSchema(schema).map((e) => e.message));
console.log(JSON.stringify(await graphql({ schema, source: "{ x }" })));
$ node inputerr.mjs
built OK; validateSchema says: [ 'The type of Query.x(b:) must be Input Type but got: Book.' ]
{"errors":[{"message":"The type of Query.x(b:) must be Input Type but got: Book.","locations":[{"line":1,"column":19}]}]}
The schema built without complaint. graphql-js validates a schema lazily, the first
time it's used for a request, and then every request fails. Apollo Server validates at
startup, but if you use graphql-js directly, call validateSchema (or
assertValidSchema) in your startup code or a test so a broken schema fails the deploy,
not the first user.
A small mutation API¶
import { makeExecutableSchema } from "@graphql-tools/schema";
import { graphql } from "graphql";
const books = [{ id: "1", title: "Dune", year: 1965, tags: ["sci-fi"] }];
let nextId = 2;
const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query { books: [Book!]! }
type Mutation {
addBook(input: AddBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book
deleteBook(id: ID!): Boolean!
}
input AddBookInput {
title: String!
year: Int!
tags: [String!] = []
}
input UpdateBookInput {
title: String
year: Int
}
type Book { id: ID! title: String! year: Int! tags: [String!]! }
`,
resolvers: {
Query: { books: () => books },
Mutation: {
addBook: (_, { input }) => {
const book = { id: String(nextId++), ...input };
books.push(book);
return book;
},
updateBook: (_, { id, input }) => {
const book = books.find((b) => b.id === id);
if (!book) return null;
for (const [k, v] of Object.entries(input)) {
if (v === null) throw new Error(`${k} cannot be set to null`);
book[k] = v;
}
return book;
},
deleteBook: (_, { id }) => {
const i = books.findIndex((b) => b.id === id);
if (i === -1) return false;
books.splice(i, 1);
return true;
},
},
},
});
const run = async (label, source, variableValues) => {
const r = await graphql({ schema, source, variableValues });
console.log(`--- ${label}\n${JSON.stringify(r)}`);
};
Each case below was appended to that file and run in order with node mutations06.mjs
(state carries over between cases).
Creating, with variables¶
await run("add with variables", `
mutation Add($input: AddBookInput!) { addBook(input: $input) { id title year tags } }`,
{ input: { title: "Neuromancer", year: 1984 } });
The client sent a JSON object as the variable; graphql-js coerced it into
AddBookInput, filling in tags: [] from the input field default. The mutation's
selection set works exactly like a query's: the client chooses which fields of the new
Book to read back. That's the main reason mutations return the changed object — the
client can refresh its cache in the same round trip.
Partial updates: absent versus null¶
UpdateBookInput makes every field nullable so a client can send only what it wants to
change:
await run("update partial", `
mutation { updateBook(id: "2", input: { year: 1985 }) { id title year } }`);
await run("update explicit null", `
mutation { updateBook(id: "2", input: { title: null }) { id title } }`);
--- update partial
{"data":{"updateBook":{"id":"2","title":"Neuromancer","year":1985}}}
--- update explicit null
{"errors":[{"message":"title cannot be set to null","locations":[{"line":2,"column":14}],"path":["updateBook"]}],"data":{"updateBook":null}}
In the first call the input object the resolver received was { year: 1985 } — title
was absent, so it isn't a key at all and Object.entries skipped it. In the second,
title was explicitly null, so it's present with value null. GraphQL preserves that
distinction, and a well-behaved update resolver must too: "don't change it" and "clear it"
are different requests. Here title can't be cleared, so the resolver refuses.
Because updateBook returns a nullable Book, the error nulls only that field and data
survives. That's one reason mutation return types are usually nullable or wrapped in
payload types (Level 2 · 08).
Validation of inputs happens before any resolver¶
await run("unknown input field", `
mutation { addBook(input: { title: "X", year: 1, author: "Y" }) { id } }`);
await run("missing required via variables", `
mutation Add($input: AddBookInput!) { addBook(input: $input) { id } }`,
{ input: { title: "No year" } });
--- unknown input field
{"errors":[{"message":"Field \"author\" is not defined by type \"AddBookInput\".","locations":[{"line":2,"column":52}]}]}
--- missing required via variables
{"errors":[{"message":"Variable \"$input\" got invalid value { title: \"No year\" }; Field \"year\" of required type \"Int!\" was not provided.","locations":[{"line":2,"column":16}]}]}
No data key in either: nothing ran, nothing was written. The first is caught by
validation of the literal in the query text; the second by variable coercion, which also
happens before execution. You get this for free, so your resolvers can trust that title
is a string and year an integer.
Several mutations in one request¶
await run("several in one request", `
mutation {
a: addBook(input: { title: "Emma", year: 1815 }) { id }
d: deleteBook(id: "1")
gone: deleteBook(id: "1")
}`);
await run("books now", `{ books { id title year } }`);
--- several in one request
{"data":{"a":{"id":"3"},"d":true,"gone":false}}
--- books now
{"data":{"books":[{"id":"2","title":"Neuromancer","year":1985},{"id":"3","title":"Emma","year":1815}]}}
The second deleteBook(id: "1") returned false because the first had already removed
the book. That deterministic result depends on the next rule.
Serial execution, demonstrated¶
The spec says top-level fields of a mutation execute serially: each finishes (including its sub-selection) before the next starts. Query fields have no such guarantee. To see it, add a slow field to both root types:
// in typeDefs: Query { slow(ms: Int!, label: String!): String! }
// Mutation { slowWrite(ms: Int!, label: String!): String! }
const events = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const slow = async (_, { ms, label }) => {
events.push(`start ${label}`); await sleep(ms); events.push(`end ${label}`); return label;
};
// Query.slow = slow; Mutation.slowWrite = slow;
await run("query fields", `{ a: slow(ms: 60, label: "A") b: slow(ms: 10, label: "B") }`);
await run("mutation fields", `mutation { a: slowWrite(ms: 60, label: "A") b: slowWrite(ms: 10, label: "B") }`);
The event logs (with wall-clock time rounded to 10 ms):
query fields: start A, start B, end B, end A ~60ms
mutation fields: start A, end A, start B, end B ~70ms
In the query both fields started immediately and B finished first; total time was about the
slowest field. In the mutation, B didn't start until A was completely done, so the times
add up. Serial execution makes mutation { withdraw debit } predictable — but it's
ordering within one request only. It isn't a transaction: if the second field fails, the
first one's write has already happened. Atomic multi-step writes still need a database
transaction inside a single resolver.
How It Actually Works¶
execute chooses a strategy from the operation type. For query it calls
executeFields, which invokes every top-level resolver, collects the results (some of them
promises) into an object and waits for all of them together. For mutation it calls
executeFieldsSerially, which is a reduce over the fields: each field's result promise is
awaited before the next field's resolver is even called. Below the top level, a mutation's
sub-selections are executed with the normal parallel strategy.
Input coercion is a separate step that runs first. getVariableValues walks each variable's
declared type with coerceInputValue: for an input object it rejects unknown keys, applies
field defaults for missing keys, errors on missing non-null keys, and recursively coerces
each value through scalars' parseValue. Literal arguments in the query text go through
valueFromAST instead, and validation rules (ValuesOfCorrectTypeRule) catch bad literals
before that. The result is a plain JavaScript object where absent keys are truly absent —
which is how the resolver can tell {} from { title: null }.
Common mistakes¶
- Reusing output types as inputs. It isn't allowed, and even the workaround of
mirroring them 1:1 tends to leak server-owned fields (
id,createdAt) into inputs. - Treating
nulland absent the same in update resolvers, so a client clearing a field and a client not mentioning it produce the same result. - Assuming serial execution means atomicity. It doesn't roll anything back.
- Returning only
Booleanfrom create/update mutations. The client then has to make a second request to read the result. - Not validating the schema at startup when using
graphql-jsdirectly — a bad input type passesmakeExecutableSchemaand breaks the first request.
Exercise¶
- Add
input BookFilter { tag: String, publishedAfter: Int }and a query fieldbooks(filter: BookFilter). Implement it, treating a missing filter as "everything". - Add
tags: [String!]toUpdateBookInput. What shouldtags: nullmean? Implement your decision and test absent,nulland[]separately. - Write a mutation request that adds a book and then updates it using the new id in the same request. Why can't you? (Hint: variables are fixed before execution starts.)
- Move the
deleteBookcall beforeaddBookin the "several in one request" example and predict the output, then run it.