08 · Mutation Design: Payloads & Errors as Data¶
Level 1 · 06 introduced mutations; this lesson is about
designing them so they age well. The central question: when a user does something that
can't succeed — an email already registered, a handle with spaces in it — is that an
error or a result? GraphQL's errors array is built for things that went wrong with the
request or the server. A user typing an invalid email isn't that; it's an expected outcome a
UI must render next to a form field. Treating such outcomes as data in the schema is now
the mainstream approach, and there are two popular ways to do it. This lesson implements both
side by side for a registration mutation.
The shared parts¶
A users table with two unique columns, a validation function, and an insert that turns a
uniqueness violation into a value instead of an exception:
import { makeExecutableSchema } from "@graphql-tools/schema";
import { graphql } from "graphql";
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, handle TEXT NOT NULL UNIQUE);
INSERT INTO users (email, handle) VALUES ('ada@example.com', 'ada');`);
function validate({ email, handle }) {
const problems = [];
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) problems.push({ field: ["email"], message: "Enter a valid email address." });
if (!/^[a-z0-9_]{3,20}$/.test(handle)) problems.push({ field: ["handle"], message: "Handles are 3–20 lowercase letters, digits or underscores." });
return problems;
}
function insertUser(input) {
try {
const { lastInsertRowid } = db.prepare("INSERT INTO users (email, handle) VALUES (?, ?)").run(input.email, input.handle);
return { user: db.prepare("SELECT * FROM users WHERE id = ?").get(lastInsertRowid) };
} catch (e) {
// node:sqlite reports constraint failures with errcode 2067 (SQLITE_CONSTRAINT_UNIQUE)
if (e.errcode === 2067) return { taken: e.message.includes("users.email") ? "email" : "handle" };
throw e; // anything else is a genuine server error
}
}
const typeDefs = /* GraphQL */ `
type User { id: ID! email: String! handle: String! }
input RegisterInput { email: String!, handle: String! }
# Style A: payload with a userErrors list
type RegisterPayload { user: User, userErrors: [UserError!]! }
type UserError { field: [String!], message: String!, code: UserErrorCode! }
enum UserErrorCode { INVALID TAKEN }
# Style B: a union of outcomes
union RegisterResult = RegisterSuccess | InputInvalid | AlreadyTaken
type RegisterSuccess { user: User! }
type InputInvalid { problems: [FieldProblem!]! }
type FieldProblem { field: [String!]!, message: String! }
type AlreadyTaken { field: String!, message: String! }
type Query { userCount: Int! }
type Mutation {
registerA(input: RegisterInput!): RegisterPayload!
registerB(input: RegisterInput!): RegisterResult!
}
`;
const resolvers = {
Query: { userCount: () => db.prepare("SELECT count(*) AS n FROM users").get().n },
Mutation: {
registerA: (_, { input }) => {
const problems = validate(input);
if (problems.length) return { user: null, userErrors: problems.map((p) => ({ ...p, code: "INVALID" })) };
const r = insertUser(input);
if (r.taken) return { user: null, userErrors: [{ field: [r.taken], message: `That ${r.taken} is already registered.`, code: "TAKEN" }] };
return { user: r.user, userErrors: [] };
},
registerB: (_, { input }) => {
const problems = validate(input);
if (problems.length) return { __typename: "InputInvalid", problems };
const r = insertUser(input);
if (r.taken) return { __typename: "AlreadyTaken", field: r.taken, message: `That ${r.taken} is already registered.` };
return { __typename: "RegisterSuccess", user: r.user };
},
},
// __typename on each returned object lets the default type resolver pick the member
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
const run = async (label, source, variableValues) =>
console.log(`--- ${label}\n${JSON.stringify((await graphql({ schema, source, variableValues })))}`);
const A = `mutation($i: RegisterInput!) { registerA(input: $i) { user { id handle } userErrors { field message code } } }`;
const B = `mutation($i: RegisterInput!) { registerB(input: $i) {
__typename
... on RegisterSuccess { user { id handle } }
... on InputInvalid { problems { field message } }
... on AlreadyTaken { field message }
} }`;
await run("A ok", A, { i: { email: "grace@example.com", handle: "grace" } });
await run("A invalid", A, { i: { email: "nope", handle: "X" } });
await run("A taken", A, { i: { email: "ada@example.com", handle: "ada2" } });
await run("B ok", B, { i: { email: "linus@example.com", handle: "linus" } });
await run("B invalid", B, { i: { email: "nope", handle: "ok_handle" } });
await run("B taken", B, { i: { email: "new@example.com", handle: "ada" } });
await run("B old client that only knows RegisterSuccess", `mutation($i: RegisterInput!) { registerB(input: $i) { ... on RegisterSuccess { user { id } } } }`,
{ i: { email: "x@example.com", handle: "grace" } });
await run("count", `{ userCount }`);
Notice insertUser checks the error code, not just "some error happened". node:sqlite
exposes SQLite's extended result code as errcode; 2067 is SQLITE_CONSTRAINT_UNIQUE. Any
other failure is re-thrown, because a disk error or a bug is not something the user can fix
and should be a real GraphQL error (and logged).
Relying on the unique constraint rather than "check first, then insert" also avoids a race:
two requests registering the same handle at the same instant would both pass a SELECT
check, but only one can win the INSERT.
Style A: payload with userErrors¶
type RegisterPayload { user: User, userErrors: [UserError!]! }
type UserError { field: [String!], message: String!, code: UserErrorCode! }
Every mutation returns its own XxxPayload type containing the changed object (nullable) and
a list of user errors. This style is used by large public APIs (Shopify's Admin API is the
well-known example).
--- A ok
{"data":{"registerA":{"user":{"id":"2","handle":"grace"},"userErrors":[]}}}
--- A invalid
{"data":{"registerA":{"user":null,"userErrors":[{"field":["email"],"message":"Enter a valid email address.","code":"INVALID"},{"field":["handle"],"message":"Handles are 3–20 lowercase letters, digits or underscores.","code":"INVALID"}]}}}
--- A taken
{"data":{"registerA":{"user":null,"userErrors":[{"field":["email"],"message":"That email is already registered.","code":"TAKEN"}]}}}
Strengths: one uniform shape for every mutation; several problems reported at once (both
fields were invalid above); field is a path (["input", "address", "zip"] for nested
inputs) a form library can map to inputs. Weakness: nothing in the type system says which
codes a given mutation can produce, or that user is non-null exactly when userErrors is
empty — clients must trust the convention.
Style B: a union of outcomes¶
union RegisterResult = RegisterSuccess | InputInvalid | AlreadyTaken
type RegisterSuccess { user: User! }
type InputInvalid { problems: [FieldProblem!]! }
type AlreadyTaken { field: String!, message: String! }
Each outcome is its own type with exactly the fields that make sense for it.
--- B ok
{"data":{"registerB":{"__typename":"RegisterSuccess","user":{"id":"3","handle":"linus"}}}}
--- B invalid
{"data":{"registerB":{"__typename":"InputInvalid","problems":[{"field":["email"],"message":"Enter a valid email address."}]}}}
--- B taken
{"data":{"registerB":{"__typename":"AlreadyTaken","field":"handle","message":"That handle is already registered."}}}
Strengths: the schema documents every possible outcome; user is User! on success, so no
null check; typed clients get an exhaustive switch (result.__typename). The resolvers return
a __typename property on each object, which the default type resolver uses (as seen in
lesson 02) — no __resolveType needed.
Weakness: evolution. Here's a client written before AlreadyTaken existed, which only selects
the success case, hitting it:
An empty object. No error, no indication of what happened. Old clients can't know about
outcome types added later, so they must treat "a __typename I don't recognise" — or, as
here, no fields at all — as a generic failure. A common mitigation is to have all error
outcomes implement a shared interface, so even old clients can select ... on Error { message }:
interface Error { message: String! }
type AlreadyTaken implements Error { message: String!, field: String! }
Which to choose¶
userErrors payload |
Result union | |
|---|---|---|
| Possible outcomes visible in schema | no — by convention | yes |
| Multiple problems at once | natural | needs a list inside one outcome type |
| Null checks on success | user is nullable |
user is non-null |
| Adding a new failure kind | new enum value (dangerous change) | new union member (dangerous change) |
| Client code | check list length | switch on __typename |
Both are far better than throwing. Pick one per API and use it consistently; mixing styles across mutations is what really hurts client developers.
Other payload conventions¶
- Always return a payload object, never the bare entity.
registerA: RegisterPayload!can grow new fields (awarningslist, aviewerfor cache refreshes) without breaking anyone.register: Usercan't. - One input argument (
input: RegisterInput!) keeps mutation signatures uniform and lets clients send a single variable. - Return what the client needs to update its cache: the changed object with its
id, and for deletions the deletedid(deletedPostId: ID), because the object no longer exists to be queried. - Name for intent.
publishPostandarchivePostbeat a genericupdatePost(status:), because each can have its own rules, permissions and outcome types.
When an error is right¶
Keep the errors array for things the user can't fix by changing their input: not
authenticated, not authorized (arguably — some APIs model this as data too), rate limited,
the database is down, a bug. Those are typically handled generically by the client's network
layer rather than by a specific form.
How It Actually Works¶
Both styles are just ordinary output types; nothing in graphql-js treats them specially. In
style B, completeAbstractValue calls the union's type resolver; since we didn't define
__resolveType, graphql-tools leaves the default, which reads value.__typename from the
returned object. The empty {} for the old client happens during field collection: the
runtime type AlreadyTaken doesn't match the fragment's type condition RegisterSuccess, so
the collected field set is empty and the result object has no keys.
In insertUser, node:sqlite throws an Error whose errcode is SQLite's extended result
code. The base code for constraint failures is 19 (SQLITE_CONSTRAINT); extended codes refine
it as 19 | (n << 8), and n = 8 gives 2067 for a UNIQUE violation (versus 787 for a
foreign key). PostgreSQL drivers expose the equivalent as SQLSTATE 23505.
Common mistakes¶
- Throwing for validation failures, forcing clients to parse error messages to find which field was wrong.
- Catching every database error as "taken". Check the specific code and re-throw the rest.
- Check-then-insert without a unique constraint — a race that lets duplicates through.
- Messages without codes. Clients need a stable machine-readable value; messages are for display and get rewritten.
- Union outcomes without a fallback in clients, so a new outcome type renders as nothing.
Exercise¶
- Add
interface Error { message: String! }and makeInputInvalidandAlreadyTakenimplement it. Rewrite the "old client" query to handle unknown errors generically. - Add
changeHandle(input: ChangeHandleInput!)in style A. What new error codes does it need? - Make style A report both "email invalid" and "handle taken" in one response. What has to change in the order of checks?
- Add a
clientMutationId: Stringthat's echoed back in the payload, and explain what problem it was designed to solve for clients with optimistic UIs.