Episode 11 designs an effective error handling strategy: the GraphQL error structure with extensions and path, custom error classes, the throwing versus returning patterns, error masking for production security, and result patterns with union types for type-safe error handling.

Errors are unavoidable in real applications — databases can go down, users can send wrong data, and connections can drop. What distinguishes a quality API is how errors are handled and presented. Episode 11 covers error handling thoroughly.
We'll learn the GraphQL error structure, create custom error classes, apply the throwing versus returning patterns, do error masking for security, and build result patterns with union types that make error handling type-safe from the schema all the way to the client.
When a resolver throws an error, GraphQL returns an errors array in the response:
{
"errors": [
{
"message": "Postingan tidak ditemukan",
"locations": [{ "line": 2, "column": 3 }],
"path": ["post"]
}
],
"data": null
}The locations and path fields help you pinpoint which field failed. Important note: when an error occurs on a non-null field, the error propagates to the parent — which is why the nullability design in episode 3 has a direct impact on error behavior. GraphQL also returns data: null when the root query fails.
To carry structured data, use the extensions field. Apollo Server 4 automatically adds a code to extensions for thrown errors:
{
"errors": [
{
"message": "Anda tidak berhak",
"extensions": {
"code": "FORBIDDEN",
"reason": "role: MEMBER"
}
}
]
}Apollo Server provides the GraphQLError class, which you can extend to create your own domain errors:
import { GraphQLError } from "graphql";
class NotFoundError extends GraphQLError {
constructor(resource) {
super(`${resource} tidak ditemukan`, {
extensions: { code: "NOT_FOUND", resource },
});
}
}Now a resolver just throws new NotFoundError("Post"), and the client receives a consistent error with the NOT_FOUND code. This pattern keeps the whole API consistent and is easy for other teams to understand.
There are two approaches you need to choose between deliberately:
data field in the response is null.Query: {
post: async (_, args, ctx) => {
const post = await ctx.db.posts.find(args.id);
if (!post) throw new NotFoundError("Post");
return post;
},
postOrNull: async (_, args, ctx) =>
ctx.db.posts.find(args.id),
},The practice many teams adopt: throwing for errors that stop the operation, returning (via payload/union) for errors that need to be displayed per field.
GraphQL is "all or partial": independent fields still execute even if other fields fail. Use this for partial success. Meanwhile, in production, do error masking — never leak stack traces and internal messages to the client. Log the full error on the server, and send a generic message to the client.
A modern approach: model the operation's result as a union of success and error types:
union RegisterResult = RegisterSuccess | FieldErrors
type RegisterSuccess {
user: User!
}
type FieldErrors {
errors: [FieldError!]!
}
type FieldError {
field: String!
message: String!
}
type Mutation {
register(input: RegisterInput!): RegisterResult!
}The advantages: clients can check ... on RegisterSuccess and ... on FieldErrors with inline fragments, and the TypeScript client generated by codegen (episode 23) will model every possibility. This combines error handling with type safety — a combination the throwing pattern is hard pressed to achieve.
On the client, the result pattern forces developers to handle both cases:
mutation Daftar($input: RegisterInput!) {
register(input: $input) {
... on RegisterSuccess {
user { id username }
}
... on FieldErrors {
errors { field message }
}
}
}There's no more "forgot to handle the error" case, because the schema forces both branches to be selected. This is the pattern we recommend for mutations that interact with forms and complex business logic.
Key takeaways:
message, locations, path, and extensions with a code.GraphQLError for consistent domain errors.In the next episode, episode 12, you'll learn about pagination — offset-based pagination, cursor-based pagination with stable cursors, the Relay Connection Specification with edge and pageInfo, and best practices for page sizes and loading states. Fetching large amounts of data will no longer be a problem!