Lesson 32 / 34
Error Handling and Authorization
Collecting errors at the field level, an error returning in the same response as partial data, an authorization layer that wraps resolvers, and measuring how a gap propagates upward through required fields.
Contents
The connection model organized which records the response would carry, but it kept one assumption: every field asked for can be read, and every field returns a value.
In the library, that is not true. A member’s fine information can be seen only by that member and by clerks; a loan record’s note can only be read at the branch where the record was opened. Some fields also come from other services, and those services can go down. This lesson answers two questions together: what happens to the rest of the response when a field crashes, and where does authorization checking get written?
The parser, flattener, and schema are used exactly as they were written in the previous lessons; the engine is rewritten in this lesson to collect errors.
Errors at the Field Level
In a resource-based endpoint, an error covers the whole response: the status code is
either success or it is not. In the query-based approach, a single response carries
hundreds of fields, and one of them crashing does not invalidate the others. The response
is therefore two-part: data carries the fields that resolved, errors carries the ones
that could not. Both are present in the same response; this is called partial
success.
Two things are added to the engine. Every field call is wrapped in a try block, and a
field that crashes is written to the error list together with its path in the response.
The second is subtler: null cannot be written into a field marked ! in the schema, so
when that field crashes, the gap moves upward.
// executor.mjs — an engine that collects errors at the field level // If a field crashes, it is written to the error list and the field becomes null. If the // field is required with "!", null cannot be written; the gap rises to the nearest // parent field that can hold null. import { parseType, fieldDef } from "./schema.mjs"; import { flatten } from "./flatten.mjs"; const LEAF = new Set(["scalar", "enum"]); class NonNullError extends Error {} // attempt to write null into a required field function resolveVariables(op, given) { const d = {}; for (const b of op.variables) { const v = given[b.name] ?? b.defaultValue; if (v === undefined && b.type.endsWith("!")) throw new Error(`variable $${b.name} is required`); d[b.name] = v; } return d; } const resolveArgs = (args, vars) => Object.fromEntries(Object.entries(args).map(([a, d]) => [a, d.kind === "variable" ? vars[d.name] : d.value])); async function executeSelection(o, typeName, selection, source, path, sequential = false) { const fields = flatten(o.schema, o.document, typeName, selection); const result = {}; for (const f of fields) result[f.alias] = null; const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source, `${path}.${f.alias}`); }; if (sequential) for (const f of fields) await run(f); else await Promise.all(fields.map(run)); return result; } async function executeField(o, typeName, field, source, path) { const def = fieldDef(o.schema.types[typeName], field.name); const required = parseType(def.type).required; const resolver = o.resolvers[typeName]?.[field.name] ?? ((k) => k?.[field.name]); const info = { field: field.name, parentType: typeName, path }; try { const value = await resolver(source, resolveArgs(field.args, o.vars), o.context, info); if ((value === null || value === undefined) && required) throw new NonNullError(); return await complete(o, def.type, value, field.selection, path); } catch (h) { // A propagated null is not reported again; the real error is already in the list. if (!(h instanceof NonNullError)) o.errors.push({ message: h.text ?? h.message, code: h.code ?? "internal_error", path }); if (required) throw new NonNullError(); return null; } } async function complete(o, typeText, value, selection, path) { if (value === null || value === undefined) return null; const t = parseType(typeText); if (t.list) { const itemRequired = /^\[[^\]]+!\]/.test(typeText); // [Loan!] or [Loan] return Promise.all(value.map(async (d, i) => { try { return await complete(o, t.name, d, selection, `${path}[${i}]`); } catch (h) { if (h instanceof NonNullError && !itemRequired) return null; throw h; } })); } const type = o.schema.types[t.name]; if (LEAF.has(type.kind)) return value; const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value); return executeSelection(o, concrete, selection, value, path); } export async function execute(schema, resolvers, document, { operationName, variables = {}, context = {} } = {}) { const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0]; const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context, errors: [] }; const root = op.type === "mutation" ? schema.mutation : schema.query; let data = null; try { data = await executeSelection(o, root, op.selection, null, "", op.type === "mutation"); } catch (h) { if (!(h instanceof NonNullError)) throw h; } // null at the root: data becomes null return o.errors.length ? { data, errors: o.errors } : { data }; }
The error record’s path field does the same job as the field-level validation errors
from the previous topic: it lets the client tie the error to its place in the response.
The difference is that here, the path points to the response tree, not the request
body.
Where Authorization Gets Written
Authorization checking could be written inside the resolver, but then it repeats in every resolver, and whoever writes a new resolver forgetting it produces a silent leak. The check is gathered into a separate layer that wraps the resolvers.
// authorization.mjs — field-level access control; wraps resolvers export class AuthorizationError extends Error { constructor(field) { super(`You do not have permission to see field "${field}".`); this.code = "unauthorized"; this.text = this.message; } } // rules: { Type: { field: (source, context) => boolean } } export function applyAuthorization(resolvers, rules) { const wrapped = { ...resolvers }; for (const [type, fields] of Object.entries(rules)) { wrapped[type] = { ...(resolvers[type] ?? {}) }; for (const [field, allowed] of Object.entries(fields)) { const original = resolvers[type]?.[field] ?? ((k) => k?.[field]); wrapped[type][field] = (source, args, context, info) => { if (!allowed(source, context)) throw new AuthorizationError(`${type}.${field}`); return original(source, args, context, info); }; } } return wrapped; }
A rule has two inputs, and both are necessary. Context carries who is making the request; source carries the record the decision is about. The rule “a member can see their own fine” cannot be written without comparing the two. This also explains why authorization checking stops at the field level: the decision cannot be made without looking at the record itself, and the record is only on hand while that field’s resolver is being called.
Two fields to be protected are added to the schema. Both are written nullable, and this
is a requirement: an unauthorized field returns null, so no field that depends on
authorization can be marked !. The reason for this constraint will be measured at the
end of the lesson.
// schema-authorization.mjs — the schema from lesson 01 with two protected fields added import { SCHEMA } from "./schema.mjs"; export const SCHEMA_A = { ...SCHEMA, types: { ...SCHEMA.types, Member: { ...SCHEMA.types.Member, fields: { ...SCHEMA.types.Member.fields, fine: "Int" } }, Loan: { ...SCHEMA.types.Loan, fields: { ...SCHEMA.types.Loan.fields, note: "String" } }, }, };
// data.mjs — loan data and resolvers; the fine and note fields will be protected export const MEMBERS = new Map([ ["U-1001", { id: "U-1001", createdAt: "2024-02-11", name: "Alice Carter", fine: 0 }], ["U-1002", { id: "U-1002", createdAt: "2025-06-03", name: "Dana Reyes", fine: 12 }], ]); export const LOANS = new Map([ ["O-1", { id: "O-1", createdAt: "2026-01-04", memberId: "U-1001", status: "OPEN", returnDate: "2026-03-20", branch: "central", note: "Received with a damaged cover.", items: [{ isbn: "978-0262033848", branch: "central" }] }], ["O-2", { id: "O-2", createdAt: "2026-01-19", memberId: "U-1002", status: "OPEN", returnDate: "2026-04-02", branch: "shore", note: "Second extension granted.", items: [{ isbn: "978-0201896831", branch: "shore" }] }], ]); export const RESOLVERS = { __type: { Record: (d) => (d.items ? "Loan" : "Member") }, Query: { loan: (_, a) => LOANS.get(a.id) ?? null, loans: () => [...LOANS.values()], member: (_, a) => MEMBERS.get(a.code) ?? null, }, Loan: { member: (o) => MEMBERS.get(o.memberId) }, };
// run.mjs — partial errors and field-level authorization import { parse } from "./parser.mjs"; import { execute } from "./executor.mjs"; import { SCHEMA_A as SCHEMA } from "./schema-authorization.mjs"; import { RESOLVERS } from "./data.mjs"; import { applyAuthorization } from "./authorization.mjs"; // Rules: fine is seen only on your own record, note only at the branch that opened the record. const RULES = { Member: { fine: (member, c) => c.user === member.id || c.role === "clerk" }, Loan: { note: (loan, c) => c.branch === loan.branch }, }; const PROTECTED = applyAuthorization(RESOLVERS, RULES); const QUERY = `{ loans { id status note member { name fine } } }`; const CONTEXTS = { "member U-1001, at the central branch": { user: "U-1001", role: "member", branch: "central" }, "clerk, at the shore branch": { user: "P-7", role: "clerk", branch: "shore" }, }; for (const [name, context] of Object.entries(CONTEXTS)) { const s = await execute(SCHEMA, PROTECTED, parse(QUERY), { context }); const fieldCount = JSON.stringify(s.data).match(/:/g).length; console.log(`\n=== ${name} ===`); console.log(`data: ${JSON.stringify(s.data)}`); console.log(`error count: ${(s.errors ?? []).length} filled field count: ${fieldCount}`); for (const h of s.errors ?? []) console.log(` ${h.code.padEnd(12)} ${h.path.padEnd(24)} ${h.message}`); }
=== member U-1001, at the central branch ===
data: {"loans":[{"id":"O-1","status":"OPEN","note":"Received with a damaged cover.","member":{"name":"Alice Carter","fine":0}},{"id":"O-2","status":"OPEN","note":null,"member":{"name":"Dana Reyes","fine":null}}]}
error count: 2 filled field count: 13
unauthorized .loans[1].note You do not have permission to see field "Loan.note".
unauthorized .loans[1].member.fine You do not have permission to see field "Member.fine".
=== clerk, at the shore branch ===
data: {"loans":[{"id":"O-1","status":"OPEN","note":null,"member":{"name":"Alice Carter","fine":0}},{"id":"O-2","status":"OPEN","note":"Second extension granted.","member":{"name":"Dana Reyes","fine":12}}]}
error count: 1 filled field count: 13
unauthorized .loans[0].note You do not have permission to see field "Loan.note".
The same query produced two different responses in two different contexts. In both
cases the loan records came back, their ids and statuses were read; only the protected
fields stayed empty, and their reasons were reported in errors.
In the clerk’s response, one of the note fields is empty: being a clerk is enough to
see a fine but not enough to see a note, because the note rule looks at branch, not
role. Two rules being able to rest on different criteria is what defining authorization
at the field level buys.
What the error message says is also worth noticing: it says the field exists but does not give its value. If a field’s existence itself needs to be hidden, authorization is not enough; that field has to be removed from the schema or rejected during query validation.
Null Propagation
A field crashing does not always stay confined to that field. The ! marks in the
schema determine how far the gap propagates.
The measurement needs a field that presents the same loan list under two different
markings. The loans field is written [Loan!]!; a second root field is added to the
schema that returns the same records as [Loan]. Both give the same data; only their
markings differ.
// schema-flexible.mjs — a root field presenting the same loan list with a nullable marking import { SCHEMA_A } from "./schema-authorization.mjs"; export const SCHEMA_F = { ...SCHEMA_A, types: { ...SCHEMA_A.types, Query: { ...SCHEMA_A.types.Query, fields: { ...SCHEMA_A.types.Query.fields, flexibleLoans: "[Loan]" } }, }, };
// null-propagation.mjs — how a crash propagates under nullable and non-nullable markings import { parse } from "./parser.mjs"; import { execute } from "./executor.mjs"; import { SCHEMA_F as SCHEMA } from "./schema-flexible.mjs"; import { LOANS, MEMBERS } from "./data.mjs"; const crashes = (field) => (o) => { if (o.id === "O-2") throw Object.assign(new Error("overdue service is not responding"), { code: "upstream" }); return o[field]; }; const BASE = { __type: { Record: (d) => (d.items ? "Loan" : "Member") }, Query: { loans: () => [...LOANS.values()], flexibleLoans: () => [...LOANS.values()] }, Loan: { member: (o) => MEMBERS.get(o.memberId) }, }; const SCENARIOS = [ ["returnDate crashes (Date — nullable)", "loans", { ...BASE, Loan: { ...BASE.Loan, returnDate: crashes("returnDate") } }], ["status crashes (inside [Loan!]!, Status!)", "loans", { ...BASE, Loan: { ...BASE.Loan, status: crashes("status") } }], ["status crashes (inside [Loan], Status!)", "flexibleLoans", { ...BASE, Loan: { ...BASE.Loan, status: crashes("status") } }], ]; for (const [name, root, resolvers] of SCENARIOS) { const s = await execute(SCHEMA, resolvers, parse(`{ ${root} { id status returnDate member { name } } }`)); const text = JSON.stringify(s.data); console.log(`\n${name}`); console.log(` data: ${text}`); console.log(` lost: ${text === "null" ? "entire response" : (text.match(/null/g) ?? []).length + " field(s)"} error: ${(s.errors ?? []).map((h) => `${h.path} (${h.code})`).join(", ") || "none"}`); }
returnDate crashes (Date — nullable)
data: {"loans":[{"id":"O-1","status":"OPEN","returnDate":"2026-03-20","member":{"name":"Alice Carter"}},{"id":"O-2","status":"OPEN","returnDate":null,"member":{"name":"Dana Reyes"}}]}
lost: 1 field(s) error: .loans[1].returnDate (upstream)
status crashes (inside [Loan!]!, Status!)
data: null
lost: entire response error: .loans[1].status (upstream)
status crashes (inside [Loan], Status!)
data: {"flexibleLoans":[{"id":"O-1","status":"OPEN","returnDate":"2026-03-20","member":{"name":"Alice Carter"}},null]}
lost: 1 field(s) error: .flexibleLoans[1].status (upstream)
The three lines show the same crash under three different schema markings.
When a nullable field crashes, only that field goes empty; the second record stays in
place. When a required field crashes, the gap moves upward and stops at the nearest
level that can hold null. Under the [Loan!]! marking, neither the item nor the list
can be null; the gap climbs all the way to the root field, and data becomes entirely
null. The same crash under the [Loan] marking stops at the second item, and the rest
of the response survives.
This shows that the ! mark is a design decision. Requiredness makes the client’s
job easier — it does not have to write a null check — but the price is that a single
field crashing can take down the entire response. The criterion is this: a field is
written required only if it truly cannot be null under any circumstance. Fields that
come from another service, that are computed, or that depend on authorization do not
meet this criterion.
Summary
- A query-based response is two-part:
datacarries the fields that resolved,errorscarries the ones that could not, and both are present in the same response. - The path in an error record shows its place in the response tree, and it lets the client tie the error to the right field.
- Authorization checking is written not inside resolvers but into a layer that wraps them; a rule has to see both who is making the request and the record the decision is about.
- The same query leaves different fields empty in different contexts; two rules can rest on different criteria, and this is what a field-level definition buys.
- An authorization error says the field exists but does not give its value; if the field’s existence needs to be hidden, it has to be removed from the schema.
- A crash in a required field carries the gap up to the nearest nullable level; under
[Loan!]!a single field takes down the entire response, under[Loan]it takes down only one item.
Next Step
Authorization bounded what a query could see, but not how much it could ask for. The Pagination Patterns lesson left this open: even though every field respected its own limit, the multiplication of nested lists could still grow the response. What is more, the type system contains cycles — from a loan record to its member, from the member to their loans, and from there back to a member again — and without a bound on query depth, there is no way to stop execution. The next lesson computes query depth and cost before running it, rejects a query that exceeds the bound, and measures where that bound should be chosen from.
To keep your progress and take notes, Log in
My notes
Log in to take notes.