TechLead
Lesson 13 of 20
5 min read
GraphQL

Error Handling

Master GraphQL error handling patterns including typed errors, partial responses, error codes, and client-side error management

How GraphQL Errors Work

Unlike REST where HTTP status codes indicate errors (404, 500, etc.), GraphQL always returns HTTP 200 and includes errors in the response body alongside any partial data. This is one of GraphQL's most important design decisions — a single query can partially succeed.

// GraphQL error response format
{
  "data": {
    "user": {
      "name": "Alice",
      "email": null  // This field errored
    },
    "posts": null    // This entire field errored
  },
  "errors": [
    {
      "message": "You don't have permission to view email",
      "locations": [{ "line": 4, "column": 5 }],
      "path": ["user", "email"],
      "extensions": {
        "code": "FORBIDDEN",
        "timestamp": "2025-04-05T12:00:00Z"
      }
    },
    {
      "message": "Database connection timeout",
      "path": ["posts"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR"
      }
    }
  ]
}

Key Insight: Partial Responses

GraphQL can return data AND errors in the same response. If one field fails, other fields can still succeed. This is fundamentally different from REST where a request either succeeds or fails entirely. Design your clients to handle partial data gracefully.

Throwing Errors in Resolvers

import { GraphQLError } from 'graphql';

// Standard error codes
const ErrorCode = {
  BAD_USER_INPUT: 'BAD_USER_INPUT',
  UNAUTHENTICATED: 'UNAUTHENTICATED',
  FORBIDDEN: 'FORBIDDEN',
  NOT_FOUND: 'NOT_FOUND',
  CONFLICT: 'CONFLICT',
  RATE_LIMITED: 'RATE_LIMITED',
  INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
} as const;

// Helper functions for common errors
function notFound(resource: string): never {
  throw new GraphQLError(`${resource} not found`, {
    extensions: { code: ErrorCode.NOT_FOUND },
  });
}

function unauthorized(): never {
  throw new GraphQLError('You must be logged in', {
    extensions: { code: ErrorCode.UNAUTHENTICATED },
  });
}

function forbidden(message = 'You do not have permission'): never {
  throw new GraphQLError(message, {
    extensions: { code: ErrorCode.FORBIDDEN },
  });
}

function badInput(message: string, field?: string): never {
  throw new GraphQLError(message, {
    extensions: {
      code: ErrorCode.BAD_USER_INPUT,
      field,
    },
  });
}

// Usage in resolvers
const resolvers = {
  Mutation: {
    updatePost: async (_: unknown, args: any, ctx: Context) => {
      if (!ctx.currentUser) unauthorized();

      if (args.input.title && args.input.title.length < 3) {
        badInput('Title must be at least 3 characters', 'title');
      }

      const post = await ctx.prisma.post.findUnique({
        where: { id: args.id },
      });

      if (!post) notFound('Post');
      if (post.authorId !== ctx.currentUser.id) forbidden('You can only edit your own posts');

      return ctx.prisma.post.update({
        where: { id: args.id },
        data: args.input,
      });
    },
  },
};

Error Formatting

// Apollo Server error formatting
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Log all errors server-side
    console.error('GraphQL Error:', {
      message: formattedError.message,
      code: formattedError.extensions?.code,
      path: formattedError.path,
      stack: error instanceof Error ? error.stack : undefined,
    });

    // In production, hide internal error details
    if (process.env.NODE_ENV === 'production') {
      // Don't expose internal errors
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return {
          message: 'An unexpected error occurred. Please try again later.',
          extensions: { code: 'INTERNAL_SERVER_ERROR' },
        };
      }

      // Remove stack traces
      delete formattedError.extensions?.stacktrace;
    }

    return formattedError;
  },
});

Union-Based Error Types

For mutations that can fail in predictable ways, use union return types. This makes errors part of the schema and provides type-safe error handling on the client:

# Schema with typed errors
type Mutation {
  createAccount(input: CreateAccountInput!): CreateAccountResult!
  login(email: String!, password: String!): LoginResult!
}

union CreateAccountResult = CreateAccountSuccess | ValidationError | EmailTakenError

type CreateAccountSuccess {
  user: User!
  token: String!
}

type ValidationError {
  message: String!
  field: String!
}

type EmailTakenError {
  message: String!
  suggestedEmail: String
}

union LoginResult = LoginSuccess | InvalidCredentialsError | AccountLockedError

type LoginSuccess {
  user: User!
  token: String!
}

type InvalidCredentialsError {
  message: String!
  attemptsRemaining: Int
}

type AccountLockedError {
  message: String!
  unlockAt: String!
}
// Client-side handling of typed errors
const LOGIN = gql`
  mutation Login($email: String!, $password: String!) {
    login(email: $email, password: $password) {
      __typename
      ... on LoginSuccess {
        user { id name }
        token
      }
      ... on InvalidCredentialsError {
        message
        attemptsRemaining
      }
      ... on AccountLockedError {
        message
        unlockAt
      }
    }
  }
`;

function LoginForm() {
  const [login, { data }] = useMutation(LOGIN);

  const result = data?.login;

  if (result?.__typename === 'LoginSuccess') {
    localStorage.setItem('token', result.token);
    redirect('/dashboard');
  }

  if (result?.__typename === 'InvalidCredentialsError') {
    return <p className="text-red-500">{result.message} ({result.attemptsRemaining} attempts remaining)</p>;
  }

  if (result?.__typename === 'AccountLockedError') {
    return <p className="text-red-500">Account locked until {result.unlockAt}</p>;
  }

  // ... render form
}

Error Handling Best Practices

  • Use extension codes: Always include a machine-readable code in error extensions
  • Typed errors for mutations: Use union return types when errors are predictable and the client needs to handle them
  • GraphQLError for exceptions: Use thrown errors for unexpected failures and auth issues
  • Never expose internals: Filter stack traces and internal messages in production
  • Log server-side: Always log errors with full context on the server for debugging

Continue Learning