Error Handling Patterns: Codes, Masking and Result Unions

Intermediate
12 min

Error Handling Patterns: Codes, Masking and Result Unions

GraphQL answers with HTTP 200 for an executed operation even when parts of it failed, which surprises developers coming from REST. After this lesson you will be able to read the errors array correctly, throw errors with machine-readable codes, hide internal details from clients, and model expected failures as typed data.

Two Channels: data and errors

A response can contain both data and errors. When a field resolver throws, the error is recorded with a path, the field becomes null, and the rest of the operation still executes:

json
{ "data": { "post": null, "me": { "name": "Ada" } }, "errors": [ { "message": "Post 99 not found", "path": ["post"], "extensions": { "code": "NOT_FOUND" } } ] }

Nullability decides how far the damage spreads. If post were declared Post!, its null would be invalid, so the error propagates to the nearest nullable parent, at worst making data itself null. This is why fields that can plausibly fail are usually left nullable. Errors raised before execution (syntax or validation failures) produce a response with no data key and, on spec-compliant servers, HTTP 400.

Throwing Errors with Codes

Use GraphQLError from the graphql package and put a stable code in extensions; clients switch on the code, never on the message:

javascript
import { GraphQLError } from "graphql"; post: async (_p, { id }, { db, user }) => { if (!user) { throw new GraphQLError("You must be logged in", { extensions: { code: "UNAUTHENTICATED" }, }); } const post = await db.posts.find(id); if (!post) { throw new GraphQLError(`Post ${id} not found`, { extensions: { code: "NOT_FOUND" }, }); } return post; },

Apollo Server exports its built-in codes as the ApolloServerErrorCode enum from @apollo/server/errors (BAD_USER_INPUT, GRAPHQL_VALIDATION_FAILED, INTERNAL_SERVER_ERROR); UNAUTHENTICATED and FORBIDDEN are conventions understood by most tooling.

Masking Internal Errors

A thrown database exception must never reach the client with its connection string. GraphQL Yoga masks unexpected errors by default, replacing the message with "Unexpected error." and logging the original; only GraphQLError instances you throw yourself pass through. Apollo Server forwards the original message, so add a formatError hook:

javascript
const server = new ApolloServer({ typeDefs, resolvers, formatError: (formatted, error) => { if (formatted.extensions?.code === "INTERNAL_SERVER_ERROR") { console.error(error); return { message: "Internal server error", extensions: { code: "INTERNAL_SERVER_ERROR" } }; } return formatted; }, });

Expected Failures as Data

Duplicate e-mails and insufficient balance are outcomes a user can act on, so they belong in the schema. Payloads use an errors list; the union pattern gives each case its own type:

graphql
type Mutation { register(input: RegisterInput!): RegisterResult! } union RegisterResult = RegisterSuccess | EmailTakenError | ValidationError type RegisterSuccess { user: User! } type EmailTakenError { email: String! } type ValidationError { field: String!, message: String! }
graphql
mutation { register(input: { email: "ada@example.com", password: "secret" }) { ... on RegisterSuccess { user { id } } ... on EmailTakenError { email } ... on ValidationError { field message } } }

The client must handle every case, and code generation turns the union into a TypeScript discriminated union on __typename.

| Failure | Where to report it | |---|---| | Bug or outage | Top-level errors, INTERNAL_SERVER_ERROR, masked | | Not logged in, forbidden | Top-level errors, UNAUTHENTICATED / FORBIDDEN | | Input the user can correct | data, via payload errors or a result union |

Common Mistakes

  • Checking response.ok only; a 200 can still carry errors.
  • Matching on error.message in clients; messages change, codes should not.
  • Returning null silently for a missing record instead of a typed result.
Quick Quiz
Question 1 of 2

A nullable field resolver throws. What does the response contain?

Key Takeaways

  • An executed operation returns 200 even with field errors; read both data and errors.
  • Throw GraphQLError with a stable extensions.code; nullability decides how far a field error propagates.
  • Mask unexpected errors: Yoga does it by default, Apollo Server needs formatError.
  • Model user-correctable failures as data with payload errors or result unions.
  • Reserve top-level errors for authentication, authorization and server faults.

Next lesson: Authentication and Authorization — identify the caller in the context function and enforce permissions in resolvers and directives.

Error Handling Patterns: Codes, Masking and Result Unions - GraphQL | CodeYourCraft | CodeYourCraft