Structured GraphQL Errors with Extension Codes
Implement structured error handling in GraphQL with custom error classes, extension codes, and consistent error formatting for clients
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Structured GraphQL Errors with Extension Codes
GraphQL errors are just objects with a message field. Without structure, clients resort to string matching to determine what went wrong. Adding extensions.code gives clients a machine-readable error category, while additional extension fields carry context like validation details, retry hints, or affected fields. Here is how to how to build a consistent error system across your GraphQL API.
When to Use This
- Any GraphQL API consumed by multiple clients (web, mobile, third-party)
- APIs where clients need to programmatically handle different error types
- Services that need to distinguish between validation, auth, not-found, and server errors
Prerequisites
- A GraphQL server (Apollo Server, GraphQL Yoga, or Mercurius)
- Basic understanding of GraphQL error responses
Solution
1. Define Error Codes
// errors/codes.ts
export const ErrorCodes = {
VALIDATION_ERROR: 'VALIDATION_ERROR',
UNAUTHENTICATED: 'UNAUTHENTICATED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
} as const;
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
2. Create a Base GraphQL Error
// errors/base.ts
import { GraphQLError } from 'graphql';
import { ErrorCodes, type ErrorCode } from './codes';
export class GraphQLErrorExtension extends GraphQLError {
constructor(
message: string,
code: ErrorCode,
extensions?: Record<string, unknown>
) {
super(message, {
extensions: {
code,
...extensions,
},
});
this.name = this.constructor.name;
}
}
export class ValidationError extends GraphQLErrorExtension {
constructor(errors: { field: string; message: string }[]) {
super('Input validation failed', ErrorCodes.VALIDATION_ERROR, { errors });
}
}
export class AuthenticationError extends GraphQLErrorExtension {
constructor(message = 'Authentication required') {
super(message, ErrorCodes.UNAUTHENTICATED);
}
}
export class ForbiddenError extends GraphQLErrorExtension {
constructor(message = 'Insufficient permissions') {
super(message, ErrorCodes.FORBIDDEN);
}
}
export class NotFoundError extends GraphQLErrorExtension {
constructor(resource: string, id: string) {
super(`${resource} not found`, ErrorCodes.NOT_FOUND, { resource, id });
}
}
export class ConflictError extends GraphQLErrorExtension {
constructor(message: string, conflicts?: Record<string, unknown>) {
super(message, ErrorCodes.CONFLICT, { conflicts });
}
}
export class RateLimitError extends GraphQLErrorExtension {
constructor(retryAfter: number) {
super('Rate limit exceeded', ErrorCodes.RATE_LIMITED, { retryAfter });
}
}
3. Use Errors in Resolvers
// resolvers.ts
import { ValidationError, NotFoundError, AuthenticationError, ForbiddenError } from './errors/base';
export const resolvers = {
Query: {
post: async (_: unknown, { id }: { id: string }, ctx: Context) => {
if (!ctx.user) throw new AuthenticationError();
const post = await ctx.db.posts.findById(id);
if (!post) throw new NotFoundError('Post', id);
if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new ForbiddenError('You can only view your own posts');
}
return post;
},
},
Mutation: {
createPost: async (_: unknown, { input }: { input: any }, ctx: Context) => {
if (!ctx.user) throw new AuthenticationError();
const errors = validatePostInput(input);
if (errors.length > 0) throw new ValidationError(errors);
const existing = await ctx.db.posts.findBySlug(input.slug);
if (existing) throw new ConflictError('Slug already exists', { slug: input.slug });
return ctx.db.posts.create({ ...input, authorId: ctx.user.id });
},
},
};
function validatePostInput(input: any): { field: string; message: string }[] {
const errors: { field: string; message: string }[] = [];
if (!input.title || input.title.length < 3) {
errors.push({ field: 'title', message: 'Title must be at least 3 characters' });
}
if (!input.content || input.content.length < 10) {
errors.push({ field: 'content', message: 'Content must be at least 10 characters' });
}
return errors;
}
4. Configure Error Formatting in Apollo Server
// server.ts
import { ApolloServer } from '@apollo/server';
import { GraphQLError } from 'graphql';
import { ErrorCodes } from './errors/codes';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formatted, error) => {
if (error?.extensions?.code) {
return {
message: formatted.message,
extensions: {
code: error.extensions.code,
...error.extensions,
},
};
}
return {
message: 'Internal server error',
extensions: {
code: ErrorCodes.INTERNAL_ERROR,
},
};
},
});
5. Client-Side Error Handling
// client.ts
async function createPost(input: CreatePostInput) {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: CREATE_POST_MUTATION, variables: { input } }),
});
const { data, errors } = await response.json();
if (errors) {
for (const error of errors) {
switch (error.extensions.code) {
case 'VALIDATION_ERROR':
console.error('Validation:', error.extensions.errors);
break;
case 'UNAUTHENTICATED':
window.location.href = '/login';
break;
case 'NOT_FOUND':
console.error('Not found:', error.extensions.resource);
break;
case 'RATE_LIMITED':
const retryAfter = error.extensions.retryAfter;
console.log(`Rate limited. Retry in ${retryAfter}s`);
break;
default:
console.error('Unexpected:', error.message);
}
}
return null;
}
return data.createPost;
}
How It Works
GraphQLErrorExtensionwrapsGraphQLErrorwith a requiredcodein extensions, ensuring every error has a machine-readable category- Specialized error classes (
ValidationError,NotFoundError, etc.) carry domain-specific context in extensions while sharing a consistent structure formatErrorin Apollo Server acts as a safety net — it ensures internal errors don’t leak stack traces and all errors have a code- Clients switch on
extensions.codeinstead of parsing message strings, making error handling reliable against message changes
Variants
Error Logging Plugin
Log errors with structured metadata without leaking to clients:
const errorLogger = {
async requestDidStart() {
return {
async didEncounterErrors(requestContext) {
for (const error of requestContext.errors) {
if (error.extensions?.code === ErrorCodes.INTERNAL_ERROR) {
logger.error({
query: requestContext.operationName,
variables: requestContext.request.variables,
error: error.message,
stack: error.stack,
});
}
}
},
};
},
};
Union Types for Expected Errors
For errors that are part of the domain (not exceptional), model them as union types:
union CreatePostResult = Post | ValidationError | AuthError
type Mutation {
createPost(input: CreatePostInput!): CreatePostResult!
}
This gives clients type-safe error handling without try/catch.
Best Practices
-
For a deeper guide, see Detect and Fix N+1 Queries in GraphQL Resolvers.
-
Always include
extensions.code— it is the contract between server and client for error handling -
Never leak stack traces in production —
formatErrorshould strip them -
Log internal errors server-side — clients see a generic message, your logs see the full trace
-
Keep error messages actionable — tell the client what to fix, not just what broke
Common Mistakes
- Using HTTP status codes for GraphQL errors — GraphQL always returns 200; errors are in the response body
- Throwing plain
Error— lacksextensions.code, forcing clients to string-match messages - Exposing internal error messages — database errors, file paths, or stack traces leak implementation details
- Overloading
INTERNAL_ERROR— if an error is expected (not found, validation), give it its own code
FAQ
Q: Should I use GraphQL errors or union types for expected failures? A: Use errors for exceptional cases (server down, auth failure). Use union types for domain outcomes that clients need to handle in the UI (validation, not found, conflict).
Q: How do I test error responses?
A: Assert on errors[0].extensions.code in your test client. This is more stable than asserting on message text.
Q: Can I add custom extension fields?
A: Yes. Extensions is an open map. Add retryAfter, field, conflicts, or any metadata clients need.
Q: Should I localize error messages? A: Return error codes and field names in extensions. Let clients localize the message based on the code and user locale.
Should I use partial data or null on errors?
GraphQL spec allows returning both data and errors in the same response. If a non-null field errors, the field and its parent nullify up the tree. Design your schema with nullable fields where errors are expected, so partial data still reaches the client. Only use non-null for fields that truly cannot fail.
How do I handle authentication errors vs authorization errors?
Return UNAUTHENTICATED (code 401) when no valid credential is provided. Return FORBIDDEN (code 403) when the credential is valid but lacks permissions. Include the required permission in extensions.requiredRole so clients can display meaningful messages or request elevated access.
Common Mistakes
- Returning HTTP 200 with only
errorsand nodata— clients expectdatato be present even if null - Exposing stack traces in production — always strip
extensions.exception.stacktrace - Using non-null fields where errors are possible — causes cascading nulls that wipe useful partial data
- Not logging errors server-side — the client sees the error, but the server should log full context for debugging
- Using generic
INTERNAL_SERVER_ERRORfor all failures — clients cannot differentiate between network issues, validation errors, and server bugs - Not wrapping resolver functions in try-catch — unhandled exceptions crash the entire request pipeline
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Validate and Sanitize GraphQL Input Types Server-Side
Implement centralized input validation in GraphQL using custom validation functions, Zod schemas, and input type transforms
RecipeBuild a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
RecipeHandle Errors in APIs
Patterns for consistent, predictable API error handling across multiple languages and frameworks.
RecipeField-Level Auth with Custom GraphQL Schema Directives
Implement field-level authorization in GraphQL using custom schema directives that check user roles and permissions per field
RecipeMock GraphQL Resolvers for Frontend Development
Set up mocked GraphQL resolvers with Apollo Server so frontend teams can develop against a fake API before the backend is ready