Courses / GraphQL Complete Guide
Lesson 6 of 18
GraphQL Error Handling Patterns
Implement robust error handling in GraphQL using error extensions, union-based result types, and structured error responses.
What you'll learn
- ✓How the default GraphQL error format works and its limitations
- ✓How to use error extensions for structured error metadata
- ✓How to model errors as union types for type-safe error handling
- ✓How to implement partial error responses
- ✓How to build a consistent error handling strategy across your API
Prerequisites
- •Basic GraphQL schema design
- •Familiarity with resolvers
The Default Error Model
GraphQL has a built-in error format defined in the spec. When a resolver throws, the response includes both data and errors:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"]
}
]
}
This works, but it has problems. The message field is an unstructured string. Clients have to parse text to determine what went wrong. There is no error code, no category, and no way to distinguish a “not found” from a “permission denied” without reading the message.
Error Extensions
The GraphQL spec allows an extensions field on each error object. This is where you add structured metadata:
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }, { db }) => {
const user = await db.users.findById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
argumentName: 'id',
argumentValue: id,
},
});
}
return user;
},
},
};
The response now includes machine-readable metadata:
{
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": {
"code": "NOT_FOUND",
"argumentName": "id",
"argumentValue": "999"
}
}
]
}
Clients can switch on extensions.code instead of parsing the message string.
Building a Custom Error Class Hierarchy
Create reusable error classes that consistently populate extensions:
import { GraphQLError } from 'graphql';
class NotFoundError extends GraphQLError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, {
extensions: {
code: 'NOT_FOUND',
resource,
resourceId: id,
http: { status: 404 },
},
});
}
}
class ValidationError extends GraphQLError {
constructor(message, field, constraints) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
field,
constraints,
http: { status: 400 },
},
});
}
}
class ForbiddenError extends GraphQLError {
constructor(message = 'You do not have permission to perform this action') {
super(message, {
extensions: {
code: 'FORBIDDEN',
http: { status: 403 },
},
});
}
}
class RateLimitError extends GraphQLError {
constructor(retryAfter) {
super('Too many requests', {
extensions: {
code: 'RATE_LIMITED',
retryAfter,
http: { status: 429 },
},
});
}
}
Now resolvers throw clear, consistent errors:
const resolvers = {
Mutation: {
updateUser: async (_, { id, input }, { db, user }) => {
if (!user) {
throw new ForbiddenError('Authentication required');
}
const target = await db.users.findById(id);
if (!target) {
throw new NotFoundError('User', id);
}
if (user.id !== id && user.role !== 'ADMIN') {
throw new ForbiddenError();
}
if (input.email && !isValidEmail(input.email)) {
throw new ValidationError(
'Invalid email format',
'email',
{ format: 'Must be a valid email address' }
);
}
return db.users.update(id, input);
},
},
};
Union-Based Error Types
Error extensions work, but they live outside the type system. Clients cannot discover them through introspection, and they are not validated by the schema. Union-based errors model errors as first-class types in your schema:
type User {
id: ID!
name: String!
email: String!
}
type NotFoundError {
message: String!
resourceType: String!
resourceId: ID!
}
type ValidationError {
message: String!
field: String!
constraints: [String!]!
}
type ForbiddenError {
message: String!
}
union UserResult = User | NotFoundError | ForbiddenError
type Query {
user(id: ID!): UserResult!
}
union CreateUserResult = User | ValidationError
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}
Resolvers return the appropriate type using __typename:
const resolvers = {
Query: {
user: async (_, { id }, { db, user: currentUser }) => {
if (!currentUser) {
return {
__typename: 'ForbiddenError',
message: 'Authentication required',
};
}
const user = await db.users.findById(id);
if (!user) {
return {
__typename: 'NotFoundError',
message: `User ${id} not found`,
resourceType: 'User',
resourceId: id,
};
}
return { __typename: 'User', ...user };
},
},
Mutation: {
createUser: async (_, { input }, { db }) => {
const errors = validateUserInput(input);
if (errors.length > 0) {
return {
__typename: 'ValidationError',
message: 'Validation failed',
field: errors[0].field,
constraints: errors.map((e) => e.message),
};
}
const user = await db.users.create(input);
return { __typename: 'User', ...user };
},
},
};
Clients query with inline fragments:
query GetUser($id: ID!) {
user(id: $id) {
... on User {
id
name
email
}
... on NotFoundError {
message
resourceId
}
... on ForbiddenError {
message
}
}
}
This approach gives full type safety. The client knows exactly what error shapes are possible for each operation, code generators produce discriminated unions, and errors are discoverable through introspection.
Interface-Based Error Pattern
For consistency across many error types, define a shared interface:
interface Error {
message: String!
code: String!
}
type NotFoundError implements Error {
message: String!
code: String!
resourceType: String!
resourceId: ID!
}
type ValidationError implements Error {
message: String!
code: String!
field: String!
constraints: [String!]!
}
type InputError implements Error {
message: String!
code: String!
fields: [FieldError!]!
}
type FieldError {
path: String!
message: String!
}
Clients can handle all errors generically or drill into specific types:
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
... on User {
id
name
}
... on Error {
message
code
}
... on ValidationError {
field
constraints
}
}
}
Partial Errors and Nullable Fields
GraphQL supports partial responses. When one field in a query fails, the rest can still succeed. This is powerful but requires careful thought about nullable fields:
type Query {
dashboard: Dashboard!
}
type Dashboard {
user: User!
recentOrders: [Order!] # Nullable -- can fail independently
recommendations: [Product!] # Nullable -- can fail independently
notifications: [Notification!] # Nullable -- can fail independently
}
const resolvers = {
Dashboard: {
recentOrders: async (_, __, { db, user }) => {
try {
return await db.orders.findRecent(user.id);
} catch (error) {
// Log the error, but return null instead of failing the whole query
logger.error('Failed to fetch orders', { error, userId: user.id });
return null;
}
},
recommendations: async (_, __, { recommendationService }) => {
try {
return await recommendationService.fetch();
} catch (error) {
logger.error('Recommendation service unavailable', { error });
return null;
}
},
},
};
The client receives data for everything that succeeded and null for anything that failed. The errors array tells the client which fields had problems.
Error Formatting at the Server Level
Use the formatError option to sanitize errors before they reach clients. This prevents leaking internal details like stack traces and database errors:
import { ApolloServer } from '@apollo/server';
import { unwrapResolverError } from '@apollo/server/errors';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
const originalError = unwrapResolverError(error);
// Never expose internal server errors to clients
if (originalError instanceof DatabaseError) {
logger.error('Database error', { error: originalError });
return {
message: 'Internal server error',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
// Strip stack traces in production
if (process.env.NODE_ENV === 'production') {
delete formattedError.extensions?.stacktrace;
}
return formattedError;
},
});
Choosing the Right Pattern
Use error extensions when your API is consumed by a small number of known clients, when you want to keep the schema simple, and when errors are genuinely exceptional. This is the simpler approach and works well for most APIs.
Use union-based errors when type safety matters, when clients are generated (codegen), when you want errors to be self-documenting through introspection, and when different operations have different possible error types. This is more work upfront but pays off as the API grows.
Combine both. Use union errors for expected business-logic failures (validation, not found, insufficient permissions) and error extensions for unexpected infrastructure failures (database timeouts, third-party service errors). This gives clients type-safe handling for predictable errors while keeping unexpected errors in the standard errors array.
// Business logic errors -> return as union types
if (!product.inStock) {
return {
__typename: 'OutOfStockError',
message: 'Product is out of stock',
productId: product.id,
estimatedRestock: product.restockDate,
};
}
// Infrastructure errors -> throw with extensions
try {
await paymentService.charge(amount);
} catch (err) {
throw new GraphQLError('Payment processing failed', {
extensions: {
code: 'PAYMENT_FAILED',
retryable: err.retryable,
},
});
}
The key principle: make errors part of your API design, not an afterthought. Clients should never have to guess what went wrong.
Progress is saved locally to your browser.