Clients Cannot Branch on Prose
Clients Cannot Branch on Prose
A checkout screen needs two different reactions: "session expired" must send the user to login, "email already registered" must attach a message to the email field and keep the form open. The only thing the wire gives you by default is message — a sentence a human wrote.
So someone writes if (err.message.includes('already registered')). It works until a copy review rewords the sentence, until localization ships a translated string, until a server upgrade changes capitalization. Client logic that parses prose is a production incident waiting for a wording change.
The specification anticipated this. Alongside message, locations, and path, an errors entry may carry an extensions map. The spec fixes only its type (a map) and leaves the contents to the server, and it tells clients to tolerate keys they do not recognize. That makes extensions the sanctioned place to grow the error contract without forking the response shape:
{
"data": null,
"errors": [
{
"message": "Email already registered.",
"path": ["signup"],
"extensions": {
"code": "BAD_USER_INPUT",
"field": "email",
"requestId": "01J9F2K7QW8N3M6P4R5T",
"retryable": false
}
}
]
}
The client now branches on data, not grammar:
switch (err.extensions?.code) {
case 'UNAUTHENTICATED': return redirectToLogin();
case 'BAD_USER_INPUT': return attachToField(err.extensions.field, err.message);
case 'RATE_LIMITED': return scheduleRetry(err.extensions.retryAfter ?? 30);
default: return reportToTracker(err);
}
The division of labor this sets up matters as much as the field itself: message is for humans reading a toast or a log, extensions.code is for machines making a decision, and everything else in extensions is metadata for correlation and debugging. Servers that put the decision logic only in the sentence force every client to reimplement parsing; servers that put codes in place let the message be reworded freely.
Building the Error Catalog
Building the Error Catalog
The tempting design is one code per exception class: UserRepositoryNotFoundError, StripeTimeoutError, CacheDeserializationError. That leaks your class hierarchy to clients and multiplies codes nobody branches on. The useful design keys codes on the decision the client makes, which usually collapses dozens of exceptions into a handful of codes.
| extensions.code | Meaning | Client action |
|---|---|---|
UNAUTHENTICATED |
No valid session | Refresh token or show login |
FORBIDDEN |
Authenticated but not allowed | Show a denied state; do not retry |
NOT_FOUND |
Resource absent or not visible to this caller | 404 view |
BAD_USER_INPUT |
Caller-supplied value rejected | Attach extensions.field to the input |
RATE_LIMITED |
Throttled | Back off for extensions.retryAfter seconds |
INTERNAL_SERVER_ERROR |
Unhandled failure, message already sanitized | Generic apology plus the errorId for support |
Rules that keep a catalog stable across releases:
- Codes are API surface. Renaming
BAD_USER_INPUTbreaks clients exactly like renaming a field. Deprecate codes; do not silently swap them. - Few codes, forced by decisions. If two codes always trigger the same client branch, merge them.
- One casing convention, applied everywhere. SCREAMING_SNAKE matches the vocabulary servers already ship, so shared codes and custom codes look like one system.
- Document each code with its client action, not its server origin.
In graphql-js the code travels with the error object — since v16, the options bag is the place to put it:
import { GraphQLError } from 'graphql';
export const Codes = {
UNAUTHENTICATED: 'UNAUTHENTICATED',
BAD_USER_INPUT: 'BAD_USER_INPUT',
} as const;
throw new GraphQLError('Email already registered.', {
extensions: {
code: Codes.BAD_USER_INPUT,
field: 'email',
requestId: ctx.requestId, // same value the access log records
},
});
// Client-side union derived from the same constants file:
type KnownCode = (typeof Codes)[keyof typeof Codes];
Note what the server does when you do not set a code: an unexpected throw surfaces as INTERNAL_SERVER_ERROR with the original message hidden. The catalog therefore works in both directions — codes you set deliberately reach clients, and anything unplanned lands in the generic bucket instead of leaking its shape.
Keep extensions small. Every entry is serialized once per error, and a response can carry dozens; a result-set payload attached "for debugging" multiplies quickly.
Correlate, Mask, and Map to Status Codes
Correlate, Mask, and Map to Status Codes
A support ticket says "the app said something went wrong." The useful reply names a log line. That link is an extensions field: mint an identifier when the error is formatted, log it with the full context, and return it with the sanitized message.
// Server: formatter runs once per error before it hits the wire
function toWire(err: GraphQLError, ctx: Ctx) {
const internal = !(err.extensions?.code); // anything without a catalog code
const errorId = randomUUID();
logger.error({
errorId, // <- what support quotes back to us
code: err.extensions?.code ?? 'INTERNAL_SERVER_ERROR',
path: err.path,
stack: err.originalError?.stack, // full detail stays server-side
requestId: ctx.requestId,
});
if (!internal) return err; // catalog errors pass through
return new GraphQLError('Something went wrong on our side.', {
extensions: { code: 'INTERNAL_SERVER_ERROR', errorId, requestId: ctx.requestId },
});
}
Masking is an allowlist, not a blacklist. Do not try to scrub SQL keywords out of arbitrary messages; default to replacing anything outside the catalog with a fixed sentence plus an errorId. Stack traces are the extreme case: Apollo Server exposes includeStacktraceInErrorResponses, and production deployments keep it off so extensions never carries server internals. File paths, driver messages, and upstream URLs are all in the same category.
HTTP status codes are the wrong tool for per-field failures. Once execution began, the response is a normal GraphQL result and the guidance is to keep it at 200 — see the standard error shape for why. Where status codes still help is at the request level: authentication rejected in context (401/403), throttling (429), or a caller that failed basic request validation (400). Apollo lets you express that without leaving GraphQL semantics:
throw new GraphQLError('Slow down.', {
extensions: { code: 'RATE_LIMITED', retryAfter: 30, http: { status: 429 } },
});
Use extensions.http sparingly — clients that branch on status codes instead of extensions.code are the pattern this topic exists to replace.
Finally, treat codes as a versioned surface: count how often each code fires, announce changes to the catalog the way you announce schema changes, and tell clients to fall back to generic handling for codes they do not know. An unknown extensions key is legal; an unknown code the client maps to a specific screen is a support ticket.
Interview framing: when asked how a client tells "log in again" from "fix your input", answer with the mechanism (a stable code inside extensions), the policy (codes keyed to client decisions, frozen once shipped), and the safety story (allowlist masking, errorId correlation, status codes reserved for request-level failures).
Practice Problems
Three failure modes must reach a React client: the session cookie expired, a signup field failed validation, and a payment provider timed out. Produce the server-side error factory, the wire JSON for each case, and the client switch that routes them — with a fallback for codes the client does not know.
Example:
Input: signup(email: "a@b.c") where the email already exists
Output: { message: "Email already registered.", extensions: { code: "BAD_USER_INPUT", field: "email", requestId: "..." } } -> attached to the email input, form stays open
The client decision is "highlight one field", so field belongs in extensions and the code is the validation category.
Optimal Solution — O(1) time, O(k) space
Define codes as a frozen shared constant keyed to client actions, throw GraphQLErrors with extensions at the boundary, and pass every error through one formatter that allowlists catalog codes and replaces the rest with a sanitized message plus a logged errorId. The client switch has a default arm so new codes degrade gracefully.
// shared/catalog.ts — imported by server and client
export const Codes = {
UNAUTHENTICATED: 'UNAUTHENTICATED',
BAD_USER_INPUT: 'BAD_USER_INPUT',
PAYMENT_UPSTREAM_FAILED: 'PAYMENT_UPSTREAM_FAILED',
} as const;
export type KnownCode = (typeof Codes)[keyof typeof Codes];
// server/resolvers.ts
throw new GraphQLError('Email already registered.', {
extensions: { code: Codes.BAD_USER_INPUT, field: 'email', requestId: ctx.requestId },
});
// server/formatter.ts — runs once per error
function toWire(err: GraphQLError, ctx: Ctx): GraphQLError {
const known = typeof err.extensions?.code === 'string';
const errorId = randomUUID();
logger.error({ errorId, code: err.extensions?.code, path: err.path, stack: err.originalError?.stack });
if (known) return err;
return new GraphQLError('Something went wrong on our side.', {
extensions: { code: 'INTERNAL_SERVER_ERROR', errorId, requestId: ctx.requestId },
});
}
// client/router.ts
function route(err: { message: string; extensions?: Record<string, unknown> }) {
switch (err.extensions?.code) {
case 'UNAUTHENTICATED': return redirectToLogin();
case 'BAD_USER_INPUT': return attachToField(String(err.extensions.field), err.message);
case 'RATE_LIMITED': return scheduleRetry(Number(err.extensions.retryAfter ?? 30));
default: return reportToTracker(err.extensions?.errorId);
}
} Edge Cases:
- A server code the client build predates (default arm must handle it)
- Two errors in one response with different codes (each entry formatted independently)
- An error thrown from context rather than a resolver (no path; still must be masked)
Quiz
1. Where does a machine-readable error code belong?
2. What makes an error code safe to ship to clients?
3. A user reports a blank failure screen and quotes errorId 01J9. What does that id buy you?
4. Why must production responses strip extensions.stacktrace by default?
Flashcards
Question
Purpose of extensions on a GraphQL error
Click to reveal answer
Answer
A server-defined map for metadata (code, requestId, retryAfter) that grows the error contract without changing spec fields.
Question
message vs extensions.code
Click to reveal answer
Answer
message is prose for humans and may be reworded or localized; code is a stable value clients branch on.
Question
Granularity rule for error codes
Click to reveal answer
Answer
One code per client decision, not per exception class; two codes triggering the same branch should be merged.
Question
Correlating a support report with logs
Click to reveal answer
Answer
Mint errorId in the formatter, log it with stack/path/requestId, return it in extensions.
Question
Masking policy for unexpected errors
Click to reveal answer
Answer
Allowlist: catalog codes pass through, everything else becomes a fixed message + errorId; stacktraces off in production.
Question
Status codes and field errors
Click to reveal answer
Answer
Keep 200 once execution began; use extensions.http.status only for request-level outcomes (401, 429, 400).
Revision Notes
Key Takeaways
- 1. extensions is the spec-sanctioned growth zone for error metadata — codes live there, not in prose
- 2. Codes are versioned API surface: stable, few, keyed to what the client does next
- 3. Sanitize with an allowlist and correlate with errorId so support can find the real stack
- 4. HTTP status communicates request-level outcomes; per-field decisions travel in extensions.code
Interview Tips
- • When asked "how does the client know to show login vs a field error", name the mechanism, the catalog policy, and one example code
- • Explain why one-code-per-exception is a bad catalog and what you would do instead
- • Describe your masking approach as an allowlist with a correlation id, not a keyword filter
Cheat Sheet
Error Extensions Cheat Sheet
// server
throw new GraphQLError('Email already registered.', {
extensions: { code: 'BAD_USER_INPUT', field: 'email', requestId: ctx.requestId },
});
// client
switch (err.extensions?.code) {
case 'UNAUTHENTICATED': redirectToLogin(); break;
case 'BAD_USER_INPUT': attachToField(err.extensions.field, err.message); break;
case 'RATE_LIMITED': scheduleRetry(err.extensions.retryAfter ?? 30); break;
default: report(err.extensions.errorId);
}
Catalog rules
- code = client decision, not exception class
- SCREAMING_SNAKE, frozen once shipped, deprecate rather than rename
- message for humans, code for machines, errorId for logs
- unknown code in client -> generic handler (never crash)
Masking
- allowlist catalog codes; replace everything else with fixed text + errorId
- stacktraces off in prod; never ship SQL, paths, upstream URLs
- status codes only for request-level failures (401/403/429/400)