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"]
}
]
}
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);
};
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
}
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!
}
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
}
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
These union types serve as contracts between your API and clients, explicitly declaring all possible outcomes. This approach provides several immediate benefits:
- Explicit error handling: Clients must handle all possible error types
- Type safety: TypeScript can enforce comprehensive error handling
- Self-documenting: The schema clearly shows what can go wrong
- 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!
}
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,
});
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)
);
}
},
};
This resolver implementation demonstrates several important patterns:
- Early authentication checks: Fail fast if the user isn't authorized
- Input validation: Use schema validation (Zod) to ensure data quality
- Business logic validation: Check for conflicts and other business rules
- Proper error categorization: Different types of failures return appropriate error types
- Comprehensive error handling: Catch and categorize all possible error types
- 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
}
}
`;
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",
}
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;
}
This utility provides several key benefits:
- Type safety: TypeScript ensures all error types are handled correctly
- Flexibility: Supports custom error messages and callbacks
- Consistency: Provides uniform error handling across the application
- Rich context: Logs detailed error information for debugging
- 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>
);
}
This component demonstrates several best practices:
- Form validation: Client-side validation using Zod schema
- Loading states: Proper loading indicators during mutations
- Custom error messages: Context-specific error messages for better UX
- Success handling: Actions to perform after successful operations
- Network error handling: Separate handling for network vs. GraphQL errors
- Analytics integration: Tracking successful operations
- 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
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
}
};
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.');
});
});
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...
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
}
`;
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
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");
},
},
};
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;
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'
});
}
},
};
Performance Considerations
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.
Fragment Usage: Encourage clients to use fragments to avoid requesting unnecessary error fields.
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)