GraphQL Error Handling Patterns and Extensions
Implement robust error handling in GraphQL with error extensions, union-based errors, and structured error responses for better client DX.
What you'll learn
- ✓How GraphQL error responses differ from REST error responses
- ✓How to use error extensions for structured error metadata
- ✓How to implement union-based error types for type-safe error handling
Prerequisites
- •Basic GraphQL knowledge
- •Experience with Apollo Server or similar
How GraphQL Errors Work
In REST, errors are communicated through HTTP status codes. A 404 means not found, a 403 means forbidden. GraphQL always returns HTTP 200, even when errors occur. Instead, errors appear in the errors array of the response:
{
"data": {
"user": null
},
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"],
"extensions": {
"code": "NOT_FOUND"
}
}
]
}
A key feature of GraphQL errors is partial responses. If a query requests three fields and one fails, the other two can still return data. This is powerful but requires careful handling on both server and client.
Throwing Errors in Resolvers
The simplest approach is throwing GraphQLError from your resolvers:
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }) => {
const user = await db.users.findById(id);
if (!user) {
throw new GraphQLError('User not found', {
extensions: {
code: 'NOT_FOUND',
argumentName: 'id',
},
});
}
return user;
},
},
};
The extensions object is where you attach structured metadata that clients can programmatically consume.
Error Extensions
Extensions transform opaque error messages into actionable error data. Define a consistent set of error codes:
// Define error codes as constants
const ErrorCode = {
NOT_FOUND: 'NOT_FOUND',
FORBIDDEN: 'FORBIDDEN',
UNAUTHENTICATED: 'UNAUTHENTICATED',
VALIDATION_ERROR: 'VALIDATION_ERROR',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
};
// Helper function for consistent error creation
function createAppError(message, code, details = {}) {
return new GraphQLError(message, {
extensions: {
code,
...details,
},
});
}
// Usage in resolvers
const resolvers = {
Mutation: {
updateUser: async (_, { input }, context) => {
if (!context.userId) {
throw createAppError(
'You must be logged in',
ErrorCode.UNAUTHENTICATED
);
}
if (context.userId !== input.id && !context.isAdmin) {
throw createAppError(
'You can only update your own profile',
ErrorCode.FORBIDDEN,
{ requiredRole: 'ADMIN' }
);
}
return db.users.update(input.id, input);
},
},
};
Clients can then switch on error.extensions.code instead of parsing error messages:
if (error.extensions?.code === 'UNAUTHENTICATED') {
redirectToLogin();
} else if (error.extensions?.code === 'FORBIDDEN') {
showAccessDenied();
}
The Problem with Top-Level Errors
Top-level GraphQL errors have limitations. They are untyped — clients cannot know at build time what errors a field might return. They are also not part of the schema, so they cannot be documented or validated. This leads to an alternative approach: union-based errors.
Union-Based Error Pattern
Instead of throwing errors, return them as part of the schema using unions:
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}
union CreateUserResult = CreateUserSuccess | ValidationError | EmailTakenError
type CreateUserSuccess {
user: User!
}
type ValidationError {
message: String!
field: String!
}
type EmailTakenError {
message: String!
suggestedEmail: String!
}
The resolver returns the appropriate type:
const resolvers = {
Mutation: {
createUser: async (_, { input }) => {
// Validate input
if (!isValidEmail(input.email)) {
return {
__typename: 'ValidationError',
message: 'Invalid email format',
field: 'email',
};
}
// Check for duplicates
const existing = await db.users.findByEmail(input.email);
if (existing) {
return {
__typename: 'EmailTakenError',
message: 'This email is already registered',
suggestedEmail: suggestAlternative(input.email),
};
}
// Create user
const user = await db.users.create(input);
return {
__typename: 'CreateUserSuccess',
user,
};
},
},
};
Clients query using inline fragments:
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
... on CreateUserSuccess {
user {
id
name
}
}
... on ValidationError {
message
field
}
... on EmailTakenError {
message
suggestedEmail
}
}
}
Interface-Based Error Pattern
To avoid repeating common error fields, use an interface:
interface Error {
message: String!
}
type ValidationError implements Error {
message: String!
field: String!
constraint: String!
}
type NotFoundError implements Error {
message: String!
resourceType: String!
resourceId: ID!
}
type AuthorizationError implements Error {
message: String!
requiredPermission: String!
}
This lets clients handle any error generically through the interface or specifically through the concrete type.
Combining Both Approaches
In practice, many APIs use both patterns. Union-based errors handle expected business logic errors (validation failures, duplicate entries, insufficient funds). Top-level GraphQL errors handle unexpected failures (database down, third-party service unavailable).
const resolvers = {
Mutation: {
transferFunds: async (_, { input }, context) => {
// Unexpected error: throw as GraphQL error
if (!context.userId) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
// Expected error: return as union type
const account = await db.accounts.findById(input.fromAccountId);
if (account.balance < input.amount) {
return {
__typename: 'InsufficientFundsError',
message: 'Not enough funds',
currentBalance: account.balance,
requestedAmount: input.amount,
};
}
const transfer = await db.transfers.create(input);
return {
__typename: 'TransferSuccess',
transfer,
};
},
},
};
Error Formatting
Apollo Server lets you format errors before they reach the client. Use this to sanitize internal errors in production:
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Never expose internal errors to clients
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
console.error('Internal error:', error);
return {
message: 'An unexpected error occurred',
extensions: { code: 'INTERNAL_ERROR' },
};
}
// Remove stack traces in production
if (process.env.NODE_ENV === 'production') {
delete formattedError.extensions?.stacktrace;
}
return formattedError;
},
});
Client-Side Error Handling
On the client, handle errors at multiple levels:
import { ApolloClient, ApolloLink } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err.extensions?.code) {
case 'UNAUTHENTICATED':
refreshToken();
break;
case 'RATE_LIMITED':
showRateLimitWarning();
break;
default:
logError(err);
}
}
}
if (networkError) {
showOfflineWarning();
}
});
const client = new ApolloClient({
link: ApolloLink.from([errorLink, httpLink]),
cache: new InMemoryCache(),
});
Summary
GraphQL error handling requires a deliberate strategy because HTTP status codes are not available. Use error extensions with consistent codes for unexpected errors that clients need to handle programmatically. Use union-based error types for expected business errors that should be part of your schema contract. Format errors to strip sensitive information in production, and handle errors on the client at both the link level and the component level. Combining these patterns gives you robust, type-safe error handling across your entire GraphQL stack.
Related articles
- GraphQL GraphQL Schema Design Patterns and Conventions
Master GraphQL schema design with practical patterns for naming, nullability, input types, enums, and evolving schemas without versioning.
- GraphQL GraphQL Error Handling Best Practices
Compare the errors array, union result types, and partial responses to design predictable, typed error handling for your GraphQL APIs and clients.
- GraphQL GraphQL Pagination: Relay-Style Connections
Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.
- JavaScript JavaScript Error Handling Patterns and Best Practices
Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.