DEV Community

Marcello Novelli
Marcello Novelli

Posted on

Better GraphQL Error Handling with Typed Union Responses

Unlike traditional REST APIs that leverage HTTP status codes (404 for not found, 400 for validation errors, 500 for server errors), GraphQL typically returns a 200 status code with all responses, whether successful or not, embedding errors within the response payload.

The standard GraphQL error handling approach often results in generic error messages that lack the rich context needed for proper client-side error handling, making it difficult to create user-friendly applications with meaningful error feedback.

In this post, we'll explore an elegant and comprehensive solution to GraphQL error handling by treating errors as first-class citizens in our schema. We'll demonstrate how to type errors as GraphQL types and use union types to create predictable, type-safe error responses that provide rich context and enable excellent developer and user experiences.

Just show me the code 💻:

If you want to see how I implemented this, please check this starter graphql template on my github

The Challenge with Traditional GraphQL Error Handling

Before diving into the solution, let's examine the common approaches to error handling in GraphQL and understand why they fall short of providing an optimal developer experience.

1. Generic Error Responses

The most common approach is relying on GraphQL's built-in error handling mechanism. When a resolver throws an error, GraphQL catches it and returns it in the errors array alongside the data:

{
  "data": {
    "createTodo": null
  },
  "errors": [
    {
      "message": "Todo with this title already exists",
      "locations": [{"line": 2, "column": 3}],
      "path": ["createTodo"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

While this works, it has several limitations:

  • Unpredictable structure: Clients can't know what properties an error might have
  • No type safety: TypeScript can't help with error handling since the structure is unknown
  • Generic handling: All errors look the same, making it hard to provide specific user feedback

2. Exception-Based Error Handling

Another common pattern is throwing exceptions in resolvers:

const createTodo = async (_, { input }) => {
  if (!input.title) {
    throw new Error("Title is required");
  }

  const existingTodo = await findTodoByTitle(input.title);
  if (existingTodo) {
    throw new Error("Todo already exists");
  }

  return await createNewTodo(input);
};
Enter fullscreen mode Exit fullscreen mode

This approach has similar problems:

  • Loss of error context: Important details about the error are lost
  • Inconsistent error codes: No standardized way to categorize errors
  • Poor client experience: Clients receive generic error messages without context

3. Field-Level Nullability with Error Fields

Some developers try to solve this by making fields nullable and adding error fields:

type Mutation {
  createTodo(input: TodoInput!): TodoResponse
}

type TodoResponse {
  todo: Todo
  error: String
}
Enter fullscreen mode Exit fullscreen mode

While this provides some structure, it still has issues:

  • Weak typing: Error information is just a string
  • Inconsistent patterns: Different mutations might handle errors differently
  • No error categorization: Can't distinguish between validation, authorization, or server errors

The Root Problem

All these approaches share a fundamental issue: they treat errors as second-class citizens in the GraphQL schema. Errors lack the rich typing and structure that make GraphQL so powerful for regular data. This results in:

  • Lack of type safety: Clients can't predict what error types they might receive
  • Poor client experience: Generic error messages don't provide enough context for proper error handling
  • Difficult testing: Without predictable error structures, it's hard to test error scenarios comprehensively
  • Inconsistent error handling: Different resolvers might handle similar errors differently
  • Poor developer experience: No IDE autocompletion or compile-time checks for error handling

The Solution: Typed Error Responses with Union Types

Now that we understand the limitations of traditional approaches, let's explore a more robust solution. The key insight is to treat errors as first-class citizens in our GraphQL schema by defining them as proper GraphQL types and using union types to represent all possible outcomes of an operation.

This approach transforms error handling from an afterthought into a core part of your API design. Instead of relying on generic error structures, we create specific, typed error responses that provide rich context and enable type-safe error handling throughout the entire stack.

Step 1: Designing the Error Type Hierarchy

The foundation of our approach is creating a well-structured hierarchy of error types. We start with a base Error interface that defines common properties all errors should have, then create specific error types for different categories of failures.

The Base Error Interface

interface Error {
  message: String!
  code: String!
}
Enter fullscreen mode Exit fullscreen mode

The interface establishes a contract that all error types must follow:

  • message: A human-readable description of the error
  • code: A machine-readable error code for programmatic handling

Specific Error Type Implementations

Now we can create specific error types that implement this interface while adding context relevant to their particular error category:

type ValidationError implements Error {
  message: String!
  code: String!
  field: String      # Which field failed validation
}

type NotFoundError implements Error {
  message: String!
  code: String!
  resourceId: ID!    # The ID that wasn't found
  resourceType: String!  # The type of resource (e.g., "Todo", "User")
}

type UnauthorizedError implements Error {
  message: String!
  code: String!
  operation: String!  # What operation was attempted
}

type ServerError implements Error {
  message: String!
  code: String!
  details: String    # Additional debug information
}

type ConflictError implements Error {
  message: String!
  code: String!
  conflictingField: String  # Which field caused the conflict
}
Enter fullscreen mode Exit fullscreen mode

Each error type includes fields that provide specific context for that type of failure:

  • ValidationError: Includes the field that failed validation, enabling targeted error messages
  • NotFoundError: Specifies what resource wasn't found and its type, helpful for debugging
  • UnauthorizedError: Indicates what operation was attempted, useful for permission debugging
  • ServerError: Provides additional details for debugging while keeping sensitive info from clients
  • ConflictError: Identifies which field caused the conflict, enabling specific user guidance

Step 2: Creating Union Types for Operations

With our error types defined, we can now create union types that represent all possible outcomes of our GraphQL operations. These unions combine success types (our actual data) with all relevant error types:

union TodoResult = 
  | Todo                # Success case
  | ValidationError     # Invalid input data
  | NotFoundError       # Referenced resource not found
  | ServerError         # Database or server issues
  | ConflictError       # Duplicate data conflicts
  | UnauthorizedError   # Permission issues

union CategoryResult = 
  | Category            # Success case
  | ValidationError     # Invalid input data
  | NotFoundError       # Referenced resource not found
  | ServerError         # Database or server issues
  | ConflictError       # Duplicate data conflicts
  | UnauthorizedError   # Permission issues
Enter fullscreen mode Exit fullscreen mode

These union types serve as contracts between your API and clients, explicitly declaring all possible outcomes. This approach provides several immediate benefits:

  1. Explicit error handling: Clients must handle all possible error types
  2. Type safety: TypeScript can enforce comprehensive error handling
  3. Self-documenting: The schema clearly shows what can go wrong
  4. Consistent patterns: All operations follow the same error handling approach

Step 3: Updating Operation Signatures

Now we update our GraphQL operations to use these union types instead of returning nullable fields:

type Mutation {
  createTodo(todo: TodoCreateInput!): TodoResult!
  updateTodo(todo: TodoUpdateInput!): TodoResult!
  deleteTodo(id: ID!): TodoResult!

  createCategory(category: CategoryCreateInput!): CategoryResult!
  updateCategory(category: CategoryUpdateInput!): CategoryResult!
  deleteCategory(id: ID!): CategoryResult!
}

type Query {
  todoById(id: ID!): TodoResult!
  categoryById(id: ID!): CategoryResult!
}
Enter fullscreen mode Exit fullscreen mode

Notice that all operations now return non-nullable union types. This is a key improvement over traditional approaches where operations might return null or throw exceptions. With union types, every operation has a guaranteed response structure, but the content varies based on success or failure.

Step 4: Implementing Server-Side Resolver Logic

With our schema defined, let's implement the server-side logic that makes this error handling approach work in practice. The key is creating utility functions for consistent error creation and implementing resolvers that use these utilities.

Creating Error Utility Functions

First, we create utility functions that ensure consistent error creation across all resolvers:

// Error creation utilities with proper typing
export const createValidationError = (error: ZodError): ValidationError => ({
  __typename: "ValidationError",
  message: "Validation failed",
  code: "VALIDATION_ERROR",
  field: error.issues[0]?.path.join('.') || null,
});

export const createNotFoundError = (id: string, type: string): NotFoundError => ({
  __typename: "NotFoundError",
  message: `${type} with id ${id} not found`,
  code: "NOT_FOUND",
  resourceId: id,
  resourceType: type,
});

export const createUnauthorizedError = (operation: string): UnauthorizedError => ({
  __typename: "UnauthorizedError",
  message: `Unauthorized to ${operation}`,
  code: "UNAUTHORIZED",
  operation,
});

export const createConflictError = (
  message: string,
  conflictingField?: string
): ConflictError => ({
  __typename: "ConflictError",
  message,
  code: "CONFLICT",
  conflictingField: conflictingField || null,
});

export const createServerError = (
  message: string,
  details?: string
): ServerError => ({
  __typename: "ServerError",
  message,
  code: "SERVER_ERROR",
  details: details || null,
});

// Utility to add __typename to success responses
export const withTypename = <T, K extends string>(
  obj: T,
  typename: K
): T & { __typename: K } => ({
  ...obj,
  __typename: typename,
});
Enter fullscreen mode Exit fullscreen mode

These utilities serve several important purposes:

  • Consistency: All errors of the same type have the same structure
  • Type safety: TypeScript ensures the correct fields are included
  • Maintainability: Changes to error structure only need to be made in one place
  • Debugging: The __typename field enables GraphQL to resolve union types correctly

Implementing Comprehensive Resolver Logic

Now let's implement a complete resolver that demonstrates how to handle all the different error scenarios:

export const TodoMutations: MutationResolvers = {
  createTodo: async (_, { todo }, { prisma, user }) => {
    // Step 1: Authentication check
    if (!user?.id) {
      return createUnauthorizedError("create todo");
    }

    try {
      // Step 2: Input validation using Zod
      TodoCreateInputSchema.parse(todo);

      // Step 3: Business logic validation
      const existingTodo = await prisma.todo.findFirst({
        where: { 
          title: todo.title, 
          authorId: user.id 
        }
      });

      if (existingTodo) {
        return createConflictError(
          `A todo with title '${todo.title}' already exists`,
          "title"
        );
      }

      // Step 4: Data creation with proper error handling
      const newTodo = await prisma.todo.create({
        data: {
          title: todo.title,
          content: todo.content ?? "",
          completed: todo?.completed ?? false,
          dueDate: todo?.dueDate,
          authorId: user.id,
          // Handle category relationships if provided
          categories: todo.categoryIds?.length ? {
            create: todo.categoryIds.map((categoryId) => ({
              category: { connect: { id: categoryId } }
            }))
          } : undefined,
        },
        include: {
          categories: {
            include: { category: true }
          }
        },
      });

      // Step 5: Transform data to match GraphQL schema
      const transformedTodo = {
        ...newTodo,
        categories: newTodo.categories.map((relation) =>
          withTypename(relation.category, "Category")
        ),
      };

      return withTypename(transformedTodo, "Todo");

    } catch (error) {
      // Step 6: Comprehensive error handling
      console.error("Error creating todo:", error);

      // Handle validation errors
      if (error instanceof ZodError) {
        return createValidationError(error);
      }

      // Handle database constraint violations
      if (error instanceof Prisma.PrismaClientKnownRequestError) {
        if (error.code === "P2002") {
          // Unique constraint violation
          return createConflictError(
            "A todo with this title already exists",
            "title"
          );
        }
        if (error.code === "P2003") {
          // Foreign key constraint violation
          return createNotFoundError("category", "Category");
        }
      }

      // Fallback to server error for unexpected issues
      return createServerError(
        "Failed to create todo",
        error instanceof Error ? error.message : String(error)
      );
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

This resolver implementation demonstrates several important patterns:

  1. Early authentication checks: Fail fast if the user isn't authorized
  2. Input validation: Use schema validation (Zod) to ensure data quality
  3. Business logic validation: Check for conflicts and other business rules
  4. Proper error categorization: Different types of failures return appropriate error types
  5. Comprehensive error handling: Catch and categorize all possible error types
  6. Rich error context: Include relevant information like field names and resource IDs

Step 5: Building Type-Safe Client-Side Error Handling

The real power of this approach becomes evident on the client side, where we can now handle errors in a completely type-safe manner. Let's walk through creating a comprehensive client-side error handling system.

Setting Up GraphQL Queries with Error Fragments

First, we need to set up our GraphQL queries to request all possible error types. We'll use fragments to keep our queries organized and reusable:

// Define reusable error fragments
export const ERROR_FRAGMENTS = gql`
  fragment ValidationErrorFragment on ValidationError {
    message
    code
    field
  }

  fragment NotFoundErrorFragment on NotFoundError {
    message
    code
    resourceId
    resourceType
  }

  fragment ConflictErrorFragment on ConflictError {
    message
    code
    conflictingField
  }

  fragment ServerErrorFragment on ServerError {
    message
    code
    details
  }

  fragment UnauthorizedErrorFragment on UnauthorizedError {
    message
    code
    operation
  }
`;

// Use fragments in mutations
const CREATE_TODO = gql`
  mutation CREATE_TODO($todo: TodoCreateInput!) {
    createTodo(todo: $todo) {
      ... on Todo {
        id
        title
        content
        completed
        dueDate
        createdAt
        updatedAt
        categories {
          id
          name
          color
        }
      }
      ...ValidationErrorFragment
      ...NotFoundErrorFragment
      ...ConflictErrorFragment
      ...ServerErrorFragment
      ...UnauthorizedErrorFragment
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Notice how we use inline fragments (... on Todo) for the success case and include all our error fragments. This ensures we get all the necessary data regardless of whether the operation succeeds or fails.

Creating Type-Safe Error Response Types

Next, we define TypeScript types that correspond to our GraphQL error types:

// Base error response type
type ErrorResponse = {
  __typename?: string;
  message?: string;
  code?: string;
  // Fields that might exist on specific error types
  field?: string | null;
  resourceId?: string;
  resourceType?: string;
  details?: string | null;
  conflictingField?: string | null;
  operation?: string;
};

// Specific typed interfaces for each error type
interface ValidationErrorResponse extends ErrorResponse {
  __typename: "ValidationError";
  field: string | null;
}

interface NotFoundErrorResponse extends ErrorResponse {
  __typename: "NotFoundError";
  resourceId: string;
  resourceType: string;
}

interface ServerErrorResponse extends ErrorResponse {
  __typename: "ServerError";
  details: string | null;
}

interface ConflictErrorResponse extends ErrorResponse {
  __typename: "ConflictError";
  conflictingField: string | null;
}

interface UnauthorizedErrorResponse extends ErrorResponse {
  __typename: "UnauthorizedError";
  operation: string;
}

// Enum for error types to enable exhaustive checking
export enum GraphQLErrorType {
  ValidationError = "ValidationError",
  NotFoundError = "NotFoundError", 
  ServerError = "ServerError",
  ConflictError = "ConflictError",
  UnauthorizedError = "UnauthorizedError",
}
Enter fullscreen mode Exit fullscreen mode

These types enable TypeScript to provide compile-time checking and autocompletion when handling errors.

Building a Comprehensive Error Handling Utility

Now we can create a powerful, reusable error handling utility that leverages our typed error system:

// Configuration options for error handling
type ErrorHandlerOptions = {
  showToast?: boolean;           // Whether to show toast notifications
  logToConsole?: boolean;        // Whether to log errors to console
  onSuccess?: () => void;        // Callback for successful operations
  customMessages?: CustomMessagesMap;  // Custom error messages
};

// Type-safe custom messages map
type CustomMessagesMap = {
  [GraphQLErrorType.ValidationError]?: 
    | string 
    | ((error: ValidationErrorResponse) => string);
  [GraphQLErrorType.NotFoundError]?: 
    | string 
    | ((error: NotFoundErrorResponse) => string);
  [GraphQLErrorType.ServerError]?: 
    | string 
    | ((error: ServerErrorResponse) => string);
  [GraphQLErrorType.ConflictError]?: 
    | string 
    | ((error: ConflictErrorResponse) => string);
  [GraphQLErrorType.UnauthorizedError]?: 
    | string 
    | ((error: UnauthorizedErrorResponse) => string);
};

// Default error messages
const defaultErrorMessages: Record<string, string> = {
  [GraphQLErrorType.ValidationError]: "Please check your input and try again",
  [GraphQLErrorType.NotFoundError]: "The requested resource was not found",
  [GraphQLErrorType.ServerError]: "A server error occurred. Please try again later",
  [GraphQLErrorType.ConflictError]: "This action conflicts with existing data",
  [GraphQLErrorType.UnauthorizedError]: "You don't have permission to perform this action",
};

/**
 * Handles GraphQL mutation responses with comprehensive error handling
 */
export function handleGraphQLResponse<T extends ErrorResponse>(
  response: T | null | undefined,
  entityName: string,
  operation: string,
  options: ErrorHandlerOptions = {}
): boolean {
  const {
    showToast = true,
    logToConsole = true,
    onSuccess,
    customMessages = {},
  } = options;

  // Handle null/undefined responses
  if (!response) {
    if (showToast) {
      toast.error(`No response received for ${operation} ${entityName.toLowerCase()}`);
    }
    if (logToConsole) {
      console.error(`No response for ${operation} ${entityName}`);
    }
    return false;
  }

  // Check for success case
  if (response.__typename === entityName) {
    if (showToast) {
      toast.success(`${entityName} ${operation}d successfully!`);
    }
    if (onSuccess) {
      onSuccess();
    }
    return true;
  }

  // Handle error cases with type safety
  const errorType = response.__typename as GraphQLErrorType;

  if (errorType && Object.values(GraphQLErrorType).includes(errorType)) {
    let errorMessage: string;

    // Check for custom messages first
    if (errorType in customMessages) {
      const customMessage = customMessages[errorType];
      if (typeof customMessage === "function") {
        // Type-safe function calls based on error type
        switch (errorType) {
          case GraphQLErrorType.ValidationError:
            errorMessage = customMessage(response as ValidationErrorResponse);
            break;
          case GraphQLErrorType.NotFoundError:
            errorMessage = customMessage(response as NotFoundErrorResponse);
            break;
          case GraphQLErrorType.ServerError:
            errorMessage = customMessage(response as ServerErrorResponse);
            break;
          case GraphQLErrorType.ConflictError:
            errorMessage = customMessage(response as ConflictErrorResponse);
            break;
          case GraphQLErrorType.UnauthorizedError:
            errorMessage = customMessage(response as UnauthorizedErrorResponse);
            break;
          default:
            errorMessage = defaultErrorMessages[errorType] || "An error occurred";
        }
      } else {
        errorMessage = customMessage;
      }
    } else {
      errorMessage = defaultErrorMessages[errorType] || "An error occurred";
    }

    if (showToast) {
      toast.error(errorMessage);
    }

    if (logToConsole) {
      console.error(`Error ${operation}ing ${entityName}:`, {
        type: errorType,
        details: response,
      });
    }

    return false;
  }

  // Fallback for unexpected response structure
  if (showToast) {
    toast.error(`An unexpected error occurred while ${operation}ing the ${entityName.toLowerCase()}`);
  }
  if (logToConsole) {
    console.error(`Unexpected response structure:`, response);
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

This utility provides several key benefits:

  1. Type safety: TypeScript ensures all error types are handled correctly
  2. Flexibility: Supports custom error messages and callbacks
  3. Consistency: Provides uniform error handling across the application
  4. Rich context: Logs detailed error information for debugging
  5. User experience: Shows appropriate toast notifications

Practical Usage in React Components

Finally, let's see how this all comes together in a real React component:

// TodoCreateForm.tsx
export function CreateTodoForm() {
  const form = useForm<TodoCreateFormData>({
    resolver: zodResolver(TodoCreateInputSchema),
    defaultValues: {
      title: "",
      content: "",
      completed: false,
      categoryIds: [],
    },
  });

  const [createTodo, { loading }] = useMutation(CREATE_TODO);

  const handleCreateTodo = async (data: TodoCreateFormData) => {
    try {
      const result = await createTodo({ 
        variables: { todo: data },
        // Optimistic updates and cache management
        refetchQueries: [{ query: GET_TODOS }],
      });

      // Use our type-safe error handler
      const success = handleGraphQLResponse(
        result.data?.createTodo, 
        "Todo", 
        "create", 
        {
          onSuccess: () => {
            form.reset();
            // Additional success handling like navigation
          },
          customMessages: {
            ConflictError: (error) => 
              `A todo with the title "${data.title}" already exists. Please choose a different title.`,
            ValidationError: (error) => 
              error.field 
                ? `Please check the ${error.field} field and try again.`
                : "Please check your input and try again.",
          },
        }
      );

      // You can also handle success/failure programmatically
      if (success) {
        // Perform additional success actions
        analytics.track('todo_created', { title: data.title });
      }

    } catch (error) {
      // Handle network errors or other unexpected issues
      console.error("Network error creating todo:", error);
      toast.error("Unable to connect to the server. Please check your connection.");
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleCreateTodo)} className="space-y-4">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Title</FormLabel>
              <FormControl>
                <Input placeholder="Enter todo title" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea placeholder="Enter todo description" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" disabled={loading}>
          {loading ? "Creating..." : "Create Todo"}
        </Button>
      </form>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component demonstrates several best practices:

  1. Form validation: Client-side validation using Zod schema
  2. Loading states: Proper loading indicators during mutations
  3. Custom error messages: Context-specific error messages for better UX
  4. Success handling: Actions to perform after successful operations
  5. Network error handling: Separate handling for network vs. GraphQL errors
  6. Analytics integration: Tracking successful operations
  7. Cache management: Proper cache updates after mutations

This utility provides several key benefits:

1. **Type safety**: TypeScript ensures all error types are handled correctly
2. **Flexibility**: Supports custom error messages and callbacks
3. **Consistency**: Provides uniform error handling across the application
4. **Rich context**: Logs detailed error information for debugging
5. **User experience**: Shows appropriate toast notifications
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach

Now that we've seen the complete implementation, let's examine the concrete benefits this approach provides over traditional GraphQL error handling patterns.

1. Complete Type Safety Throughout the Stack

The most significant advantage is end-to-end type safety. Unlike traditional approaches where errors are loosely typed or untyped:

Server-side benefits:

  • TypeScript enforces that all error creation follows the correct structure
  • Resolvers must handle all possible error scenarios explicitly
  • Code completion and IntelliSense work perfectly for error handling
  • Compile-time checks prevent common error handling mistakes

Client-side benefits:

  • GraphQL code generation creates precise TypeScript types for all error responses
  • Exhaustive checking ensures all error types are handled
  • IDE autocompletion shows exactly what fields are available on each error type
  • Impossible to access non-existent error fields or miss required handling
// TypeScript will enforce that you handle all union members
const handleResponse = (response: TodoResult) => {
  switch (response.__typename) {
    case "Todo":
      // Handle success
      break;
    case "ValidationError":
      // TypeScript knows this has 'field' property
      console.log(response.field);
      break;
    case "NotFoundError":
      // TypeScript knows this has 'resourceId' and 'resourceType'
      console.log(`${response.resourceType} ${response.resourceId} not found`);
      break;
    // TypeScript will error if you don't handle all cases
  }
};
Enter fullscreen mode Exit fullscreen mode

2. Predictable and Consistent Error Structure

With traditional GraphQL error handling, clients never know what structure errors might have. Our approach provides:

Schema-driven predictability:

  • All possible error types are explicitly declared in the schema
  • Clients can discover error types through introspection
  • Error structure is self-documenting through GraphQL's type system
  • Breaking changes to error types are caught at compile time

Consistency across operations:

  • All mutations follow the same error handling pattern
  • Similar operations return similar error types
  • Error codes and structures are standardized across the API
  • No surprises or edge cases in error handling

3. Rich Contextual Error Information

Unlike generic error messages, our typed errors carry rich contextual information:

Validation errors include:

  • The specific field that failed validation
  • Structured error codes for programmatic handling
  • Clear, actionable error messages

Not found errors include:

  • The exact resource ID that wasn't found
  • The type of resource (Todo, Category, User, etc.)
  • Context for debugging and user feedback

Conflict errors include:

  • Which field caused the conflict
  • Specific information about the conflicting data
  • Guidance for resolving the conflict

This rich context enables:

  • Better user experiences: Specific, actionable error messages
  • Easier debugging: Complete context for investigating issues
  • Programmatic error handling: Clients can respond differently to different error types

4. Enhanced Developer Experience

The development experience improvements are substantial:

For API developers:

  • Clear patterns for handling all error scenarios
  • Utility functions ensure consistent error creation
  • Easy to add new error types without breaking existing code
  • Self-documenting error handling through types

For client developers:

  • Autocompletion for all error properties
  • Compile-time validation of error handling
  • Clear understanding of all possible error scenarios
  • Easy to write comprehensive error handling code

For QA and testing:

  • Predictable error structures enable comprehensive test coverage
  • Easy to test all error scenarios systematically
  • Type safety prevents runtime errors in test code
  • Error scenarios are explicit and discoverable

5. Superior Testability

Testing becomes much more straightforward and comprehensive:

// Server-side resolver tests
describe('createTodo', () => {
  it('should return ValidationError for invalid input', async () => {
    const result = await createTodo(invalidInput);
    expect(result.__typename).toBe('ValidationError');
    expect(result.field).toBe('title');
    expect(result.code).toBe('VALIDATION_ERROR');
  });

  it('should return ConflictError for duplicate titles', async () => {
    const result = await createTodo(duplicateInput);
    expect(result.__typename).toBe('ConflictError');
    expect(result.conflictingField).toBe('title');
  });
});

// Client-side error handling tests
describe('handleGraphQLResponse', () => {
  it('should show specific message for validation errors', () => {
    const validationError = {
      __typename: 'ValidationError',
      field: 'title',
      message: 'Title is required'
    };

    const result = handleGraphQLResponse(validationError, 'Todo', 'create');
    expect(result).toBe(false);
    expect(toast.error).toHaveBeenCalledWith('Please check the title field and try again.');
  });
});
Enter fullscreen mode Exit fullscreen mode

6. Better Client-Side User Experience

The improved error handling directly translates to better user experiences:

Context-aware error messages:

  • "Please check the title field" instead of "Validation error"
  • "A todo with this title already exists" instead of "Conflict error"
  • "Category not found" instead of "Resource not found"

Appropriate error handling:

  • Form field highlighting for validation errors
  • Retry suggestions for server errors
  • Clear guidance for resolving conflicts
  • Proper error boundaries for different error types

Consistent error patterns:

  • All forms handle errors the same way
  • Users learn to expect consistent error feedback
  • Error states are predictable across the application

Implementation Considerations and Best Practices

While this approach provides significant benefits, there are important considerations and best practices to keep in mind when implementing it in your own projects.

GraphQL Schema Organization and Fragment Strategy

Organizing your error types and fragments properly is crucial for maintainability:

Centralized Error Type Definitions

Keep all error type definitions in a central schema file to ensure consistency:

# error.graphql
interface Error {
  message: String!
  code: String!
}

type ValidationError implements Error {
  message: String!
  code: String!
  field: String
}

type NotFoundError implements Error {
  message: String!
  code: String!
  resourceId: ID!
  resourceType: String!
}

# Add other error types...
Enter fullscreen mode Exit fullscreen mode

Reusable Error Fragments

Create a comprehensive fragment library for error handling:

// errorFragments.ts
export const ERROR_FRAGMENTS = gql`
  fragment ValidationErrorFragment on ValidationError {
    message
    code
    field
  }

  fragment NotFoundErrorFragment on NotFoundError {
    message
    code
    resourceId
    resourceType
  }

  fragment ConflictErrorFragment on ConflictError {
    message
    code
    conflictingField
  }

  fragment ServerErrorFragment on ServerError {
    message
    code
    details
  }

  fragment UnauthorizedErrorFragment on UnauthorizedError {
    message
    code
    operation
  }
`;

// Base error fragment that includes all error types
export const ALL_ERRORS_FRAGMENT = gql`
  fragment AllErrorsFragment on Error {
    ...ValidationErrorFragment
    ...NotFoundErrorFragment
    ...ConflictErrorFragment
    ...ServerErrorFragment
    ...UnauthorizedErrorFragment
  }
`;
Enter fullscreen mode Exit fullscreen mode

Domain-Specific Union Types

Create domain-specific union types for different areas of your application:

# User domain
union UserResult = User | ValidationError | NotFoundError | ConflictError | UnauthorizedError | ServerError
union UserDeleteResult = User | NotFoundError | UnauthorizedError | ServerError

# Todo domain  
union TodoResult = Todo | ValidationError | NotFoundError | ConflictError | UnauthorizedError | ServerError
union TodoDeleteResult = Todo | NotFoundError | UnauthorizedError | ServerError

# Category domain
union CategoryResult = Category | ValidationError | NotFoundError | ConflictError | UnauthorizedError | ServerError
Enter fullscreen mode Exit fullscreen mode

Union Type Resolution Strategy

Proper union type resolution is critical for GraphQL to work correctly.
This way, GraphQL can resolve the correct type based on the __typename field.

// resolvers/index.ts
export const resolvers: Resolvers = {
  // Ensure proper __resolveType functions for all union types
  TodoResult: {
    __resolveType(obj) {
      if ("__typename" in obj && typeof obj.__typename === "string") {
        return obj.__typename;
      }
      throw new Error("Could not resolve type for TodoResult: __typename is missing");
    },
  },

  CategoryResult: {
    __resolveType(obj) {
      if ("__typename" in obj && typeof obj.__typename === "string") {
        return obj.__typename;
      }
      throw new Error("Could not resolve type for CategoryResult: __typename is missing");
    },
  },

  // Interface resolution for the base Error interface
  Error: {
    __resolveType(obj) {
      if ("__typename" in obj && typeof obj.__typename === "string") {
        return obj.__typename;
      }
      throw new Error("Could not resolve type for Error interface: __typename is missing");
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Error Creation Utility Patterns

You can also create comprehensive utilities that ensure consistent error creation:

// utils/errors.ts

// Generic error creation with validation
export const createError = <T extends Record<string, any>>(
  typename: string,
  base: Pick<ErrorBase, 'message' | 'code'>,
  extra: T
): T & ErrorBase & { __typename: string } => ({
  ...base,
  ...extra,
  __typename: typename,
});

// Specific error creators with validation
export const createValidationError = (
  error: ZodError,
  customMessage?: string
): ValidationError => {
  const firstIssue = error.issues[0];
  return createError('ValidationError', {
    message: customMessage || 'Validation failed',
    code: 'VALIDATION_ERROR',
  }, {
    field: firstIssue?.path.join('.') || null,
  });
};

export const createNotFoundError = (
  resourceId: string,
  resourceType: string,
  customMessage?: string
): NotFoundError => createError('NotFoundError', {
  message: customMessage || `${resourceType} with id ${resourceId} not found`,
  code: 'NOT_FOUND',
}, {
  resourceId,
  resourceType,
});

// Utility to ensure __typename is added to success responses
export const withTypename = <T, K extends string>(
  obj: T,
  typename: K
): T & { __typename: K } => ({
  ...obj,
  __typename: typename,
});

// Result wrapper for consistent resolver returns
export const createSuccessResult = <T>(data: T, typename: string) => 
  withTypename(data, typename);

export const createErrorResult = (error: any) => error;
Enter fullscreen mode Exit fullscreen mode

Database Integration Patterns

When integrating with databases, create patterns for handling common database errors:

// utils/databaseErrorHandling.ts
export const handleDatabaseError = (
  error: unknown,
  context: { resourceId?: string; resourceType?: string; operation?: string }
): GraphQLError => {
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    switch (error.code) {
      case 'P2002': // Unique constraint violation
        const field = error.meta?.target as string;
        return createConflictError(
          `A ${context.resourceType} with this ${field} already exists`,
          field
        );

      case 'P2025': // Record not found
        return createNotFoundError(
          context.resourceId || 'unknown',
          context.resourceType || 'Resource'
        );

      case 'P2003': // Foreign key constraint violation
        return createNotFoundError(
          'related resource',
          'Related Resource'
        );

      default:
        return createServerError(
          'Database operation failed',
          error.message
        );
    }
  }

  if (error instanceof ZodError) {
    return createValidationError(error);
  }

  return createServerError(
    `Failed to ${context.operation}`,
    error instanceof Error ? error.message : String(error)
  );
};

// Usage in resolvers
export const TodoMutations = {
  createTodo: async (_, { todo }, { prisma, user }) => {
    try {
      // ... business logic
    } catch (error) {
      return handleDatabaseError(error, {
        resourceType: 'Todo',
        operation: 'create todo'
      });
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

  1. Query Complexity: Union types can increase query complexity. Use query complexity analysis to prevent abuse. I use Graphql Armor to limit query depth and complexity.

  2. Fragment Usage: Encourage clients to use fragments to avoid requesting unnecessary error fields.

  3. Caching: Union types work well with GraphQL caching strategies since each response has a clear type.

Conclusion

Typing errors as GraphQL types and using union responses provides a robust, type-safe approach to error handling in GraphQL APIs. This pattern offers significant benefits over traditional error handling approaches:

  • Predictable error structures that clients can rely on
  • Type safety throughout the entire stack
  • Rich error context for better user experiences
  • Improved testability and developer experience

While this approach requires more upfront design work, the long-term benefits in terms of maintainability, type safety, and developer experience make it a worthwhile investment for any serious GraphQL API.

The pattern scales well as your API grows, allowing you to add new error types and operations while maintaining consistency across your entire GraphQL schema. By embracing errors as first-class citizens in your GraphQL schema, you create a more robust and developer-friendly API.

Reference repo:

💻 If you want to see how I implemented this, please check this starter graphql template on my github

Top comments (0)