Skip to content
Codeloom
GraphQL

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.

·6 min read · By Codeloom
Intermediate 10 min read

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.