Error Handling & Validation

GraphQL
Chapter 7 ยท Error Handling & Validation

๐Ÿšจ Error Handling & Validation

Chapter 6 built a working server. This chapter covers what happens when something goes wrong โ€” and GraphQL's approach is genuinely different from the HTTP status codes REST relies on.

Errors Don't Change the HTTP Status

A GraphQL response typically returns HTTP 200 even when a resolver throws โ€” errors are reported in a separate errors array in the response body, alongside a data field that might still be partially populated:

{
  "data": {
    "user": {
      "name": "Alice",
      "posts": null
    }
  },
  "errors": [
    {
      "message": "Failed to load posts",
      "path": ["user", "posts"],
      "locations": [{ "line": 3, "column": 5 }]
    }
  ]
}

Because a query touches many resolvers, some fields can succeed while others fail in the very same response. If Post's title field were non-null (String!, per Chapter 2) and its resolver threw, that null would propagate up to the nearest nullable parent instead โ€” GraphQL's execution guarantees a non-null field is never actually returned as null.

The Built-In Error Shape

FieldWhat It Is
messageHuman-readable description of what went wrong
locationsLine/column in the original query where the failing field was requested
pathThe exact field path that failed โ€” matches Chapter 3's resolver-nesting structure

๐Ÿท๏ธ Custom Errors: Conveying a Category

GraphQL has no equivalent to HTTP's 404/401/500 โ€” instead, an extensions.code convention plays that role:

Throwing a Custom Error

import { GraphQLError } from "graphql";

Query: {
  user: async (parent, args, context) => {
    const user = await context.userLoader.load(args.id);
    if (!user) {
      throw new GraphQLError("User not found", {
        extensions: { code: "NOT_FOUND" },
      });
    }
    return user;
  },
}

Conveying Error Category

REST

The HTTP status code itself carries the category โ€” 404, 401, 400, 500.

GraphQL

Status stays 200; category lives in extensions.code instead โ€” NOT_FOUND, UNAUTHENTICATED, BAD_USER_INPUT.

Input Validation Is a Separate Concern

The schema's ! only guarantees a field is present and the right type โ€” not that a string isn't empty, or a number is in a sensible range. That's the same "never trust input" lesson from the site's security courses, applied to mutation arguments before a write happens:

createPost: (parent, args, context) => {
  if (args.input.title.trim().length === 0) {
    throw new GraphQLError("Title cannot be empty", {
      extensions: { code: "BAD_USER_INPUT" },
    });
  }
  return db.posts.create(args.input);
}

๐Ÿ“ฆ The Payload Pattern, Revisited

Chapter 4 mentioned wrapping mutation results in a payload type. That pattern is often preferred specifically for expected, recoverable validation errors, keeping the top-level errors array reserved for unexpected, systemic failures:

Top-Level errors Array

Unexpected, systemic problems โ€” a database connection failure, a bug. The client generally can't do anything meaningful except show a generic failure state.

Payload-Level errors Field

Expected, user-facing validation problems โ€” "title too short," "email already taken." The client can show these directly next to the relevant form field.

๐Ÿ’ป Coding Challenges

Challenge 1: Throw an UNAUTHENTICATED Error

Write a resolver for a hypothetical deletePost mutation that throws a GraphQLError with extensions.code: "UNAUTHENTICATED" if context.currentUser is missing, before performing the delete.

Goal: Practice throwing a categorized error before a resolver's main logic runs.

โ†’ Solution

Challenge 2: Design a Payload Type With Errors

Define a CreatePostPayload type with post: Post and errors: [UserError!]! fields (where UserError { field: String!, message: String! }), and change createPost's return type to it.

Goal: Practice the payload-wrapper pattern for surfacing expected validation errors alongside a possibly-null result.

โ†’ Solution

Challenge 3: Explain a Client Bug

A client's code does if (response.status === 200) { showSuccess(); } after every GraphQL mutation, without ever inspecting the response body's errors array. Explain what's wrong with this and what the client should do instead.

Goal: Practice recognizing the exact misconception this chapter's tip box addresses.

โ†’ Solution

โš ๏ธ Gotcha: Checking HTTP Status Instead of the Response Body

Client code trained on REST habitually checks response.ok or status === 200 as a proxy for "the operation succeeded" โ€” for GraphQL, that check is meaningless. A 200 response with a populated errors array is a completely normal, expected shape, not an edge case. Every GraphQL client must inspect the response body's errors array directly, every time, regardless of HTTP status โ€” treating a 200 status alone as proof of success will silently miss real, reported failures sitting right there in the same response.

๐ŸŽฏ What's Next

With errors handled, the next chapter covers who's allowed to do what: Authentication & Authorization in GraphQL โ€” context-based auth, and field-level authorization.