Error Extensions
A message is for a human reading a log. But a client cannot reliably branch on a sentence — strings get reworded, translated, and reformatted. To let clients react programmatically to a failure, GraphQL gives every error an extensions map: a free-form, server-defined object hanging off the error entry. This is where typed errors live.
The extensions map
Section titled “The extensions map”extensions is the one error key the specification leaves open for you to fill. The near-universal convention is to put a stable code string in it:
{ "errors": [ { "message": "No track exists with that id.", "path": ["track"], "extensions": { "code": "NOT_FOUND" } } ]}The message may change over time, but extensions.code is a contract: "NOT_FOUND", "UNAUTHENTICATED", "FORBIDDEN", "BAD_USER_INPUT". A client switches on the code, not the prose. You can attach anything else you find useful too — a retry hint, a field name, a correlation id — as long as it is safe to expose.
Throwing a typed error
Section titled “Throwing a typed error”The reference implementation exports a GraphQLError class. Throw an instance of it from a resolver and pass your extensions in its options, and the engine threads them straight into the response:
import { GraphQLError } from 'graphql';
throw new GraphQLError('No track exists with that id.', { extensions: { code: 'NOT_FOUND' },});Compare that to throwing a plain Error. A plain Error still produces a valid error entry — you saw that in earlier lessons — but it has no code, so clients are left parsing the message. Reaching for GraphQLError is how you make a failure part of the typed contract instead of an accident of wording.
Masking internal errors
Section titled “Masking internal errors”There is a security angle. An unexpected exception — a failed database call, a null dereference deep in your code — often carries a stack trace or an internal message you must never ship to a client. The defensive pattern: let expected failures throw GraphQLError with a deliberate code, and catch everything else, replacing it with a generic masked error before it leaves the server.
flowchart TD
Throw["Resolver throws"]
Check{"Is it a deliberate GraphQLError?"}
Keep["Expected: keep message + code (NOT_FOUND, FORBIDDEN, ...)"]
Mask["Unexpected: log server-side, return generic INTERNAL_SERVER_ERROR"]
Client["Client reads extensions.code"]
Throw --> Check
Check -->|"yes"| Keep
Check -->|"no"| Mask
Keep --> Client
Mask --> Client In production you typically log the real exception server-side (with a correlation id) and return the client only a bland { message: "Internal server error", extensions: { code: "INTERNAL_SERVER_ERROR" } }. The user gets enough to retry or report; an attacker learns nothing about your internals. Many GraphQL servers do this masking for you by default; the principle is the same whether the framework or your own catch block does it.
A typed error, run for real
Section titled “A typed error, run for real”The runner below throws a GraphQLError with extensions: { code: 'NOT_FOUND' } when a track is missing. Press Run and inspect the error entry: it carries your code right alongside the message and path.
In the output, data.track is null because the field is nullable, and the single error entry now carries extensions.code === "NOT_FOUND" plus the argument hint we attached. A client can read that code and render “track not found” without ever parsing the English message — exactly the programmatic contract a plain Error could not give us.
| Pros | Trade-offs |
|---|---|
| Error codes let clients handle errors programmatically | Excessive extensions bloat the error response |
| A timestamp or requestId in extensions helps debugging | Sensitive information in extensions can leak |
| Custom extensions carry domain-specific error detail | No standard format for extensions — teams must agree on one |
| Stack traces in extensions help local development | Stack traces in production are a security risk |
Common mistakes
Section titled “Common mistakes”Stack trace in the production response
Symptoms:
extensionsstill carries astacktracearray in production- this exposes internal code paths and file structure to an attacker
- Fix: strip the stack trace when
NODE_ENV === 'production'— aformatErrorhook, or Apollo Server’sincludeStacktraceInErrorResponses: false
Undocumented error codes
Symptoms:
- the client receives
"code": "ERR_42"with no idea what it means - developers have to ask around or read the source to learn what a code means
- Fix: document every error code in the schema description or your API reference
💡 Real-world examples
Apollo Server:
- error extensions carry a
codeplus anhttp.statusfor a custom HTTP status (Apollo Server error docs)- production builds omit
stacktraceby default viaincludeStacktraceInErrorResponses(same docs)Stripe:
- error codes are machine-readable strings such as
card_declinedandinsufficient_funds(Stripe error codes, decline codes)- clients branch on each code without parsing the human-readable message