Error Handling & Validation
๐จ Error Handling & Validation
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
| Field | What It Is |
|---|---|
message | Human-readable description of what went wrong |
locations | Line/column in the original query where the failing field was requested |
path | The 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.
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.
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.
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.