Introduction
If you’ve built REST APIs with AWS API Gateway, you know how nice it is to return structured error responses with proper HTTP status codes, error types, and detailed context. Then you switch to GraphQL with AppSync, and suddenly your beautiful error handling becomes… generic.
{ "errors": [{ "message": "Error" }] }
That’s it. No error types. No structured context. Just a string.
This doesn’t have to be the case.
In this post, I’ll show you how to bring API Gateway-style error handling to your GraphQL APIs using AppSync JavaScript resolvers and custom exception classes. We’ll transform generic GraphQL errors into structured, type-safe responses that your clients can actually use.
By the end, you’ll have:
- Custom exception classes with type-safe context
- Middleware that automatically transforms errors
- AppSync JS resolvers that propagate structured errors
- Client-friendly error responses that rival REST APIs
All with real, production-ready code you can implement today.
The Problem: GraphQL’s Generic Error Responses
If you’ve worked with REST APIs and AWS API Gateway, you’re probably familiar with structured error responses:
// REST API error response (what we want)
{
"statusCode": 400,
"errorType": "ValidationException",
"message": "Invalid email format",
"errorInfo": {
"field": "email",
"constraint": "email",
"receivedValue": "not-an-email"
}
}
This is great! The client knows exactly what went wrong, which field failed validation, and why.
Now let’s look at what you typically get with GraphQL/AppSync when using generic error handling:
// Generic GraphQL error (what we're replacing)
{
"data": null,
"errors": [
{
"message": "Error",
"path": ["getUser"],
"locations": [{ "line": 2, "column": 3 }]
}
]
}
See the problem? You lose all that rich context. No error types, no additional information, just a vague message.
Why This Happens
When you throw errors in Lambda and don’t handle them properly:
// The problematic approach
export const handler = async (event) => {
try {
const user = await database.getUser(id);
} catch (error) {
// This becomes a generic GraphQL error
throw new Error('Something went wrong');
}
};
AppSync receives the raw error and converts it to a generic GraphQL error. You lose:
- Error types: Can’t differentiate between validation, auth, or server errors
- Structured context: No field names, constraint information, or additional data
- Client actionability: Frontend can’t handle different error scenarios appropriately
- Type safety: No TypeScript interfaces, just string messages
- Debugging context: Production errors are nearly impossible to diagnose
What We’re Building
Our goal is to bring API Gateway-style error handling to GraphQL using:
- Custom exception classes (replacing generic
Errorobjects) - Middleware-based transformation (converting exceptions to structured responses)
- AppSync JS resolvers (propagating structured errors through GraphQL)
- Type-safe error schemas (making errors as reliable as successful responses)
The result? GraphQL errors that look like this:
{
"data": null,
"errors": [
{
"message": "Invalid email format",
"errorType": "VALIDATION_EXCEPTION",
"errorInfo": {
"field": "email",
"constraint": "email",
"receivedValue": "not-an-email"
},
"path": ["getUser"],
"locations": [{ "line": 2, "column": 3 }]
}
]
}
Now we have the best of both worlds: GraphQL’s query flexibility with REST API’s structured error responses.
📌 TL;DR: We’re replacing generic GraphQL errors with API Gateway-style structured responses by:
- Using custom exception classes instead of generic
Errorobjects - Transforming exceptions to response objects via middleware
- Using AppSync JS resolvers to propagate the structure through GraphQL’s
util.error()
Result: Clients get the same rich error context they’d expect from a REST API.
Our Solution: A Three-Layer Approach
Our error handling system operates across three distinct layers:
Layer 1: Lambda (Where Errors Originate)
This is where your business logic lives and where most errors occur. We use:
- Custom exception classes for type-safe error creation
- Middy middleware to intercept and transform errors
- Structured error responses that flow to AppSync
Layer 2: AppSync (Error Transformation)
AppSync sits between your Lambda functions and clients, transforming errors into GraphQL-compliant responses:
- JavaScript resolvers detect error responses
- Built-in utilities create proper GraphQL errors
- Automatic enrichment adds path and location information
Layer 3: Client (Error Consumption)
Your frontend receives structured errors that are easy to handle:
- Type-safe error interfaces in TypeScript
- Centralized error handling logic
- User-friendly error messages
The Complete Flow
Here’s how an error travels through the system:
1️⃣ Lambda Handler
throw new ValidationException('Invalid email', { field: 'email' })
↓
2️⃣ Middy Middleware
Catches exception → Returns structured response
{ errorMessage: '...', errorType: '...', errorInfo: {...} }
↓
3️⃣ AppSync JS Resolver
Detects error response → Calls util.error()
Transforms to GraphQL error format
↓
4️⃣ GraphQL Response
{
"errors": [{
"message": "Invalid email",
"errorType": "VALIDATION_EXCEPTION",
"errorInfo": { "field": "email" }
}]
}
↓
5️⃣ Client
Receives structured error → Shows appropriate UI
Key insight: We never let AppSync see a thrown error. We always return structured response objects that the JS resolver transforms into GraphQL errors with full context.
Let’s build each layer.
Building Custom Exception Classes
First, we create a base exception class that all our custom errors will extend:
export class CustomException<TInfo extends Record<string, unknown> | null = null> extends Error {
readonly info: TInfo | null;
constructor(message: string, info: TInfo | null = null) {
super(message);
this.info = info;
Error.captureStackTrace(this, this.constructor);
}
}
The magic here is the generic TInfo parameter. This lets us attach type-safe context to any error:
// Define what information this error carries
interface UserValidationError {
field: string;
constraint: string;
receivedValue: unknown;
allowedValues?: string[];
}
// Throw with type-safe context
throw new ValidationException<UserValidationError>('Invalid user role', {
field: 'role',
constraint: 'enum',
receivedValue: 'super_admin',
allowedValues: ['user', 'admin', 'moderator'],
});
TypeScript now knows exactly what’s in error.info—no guessing, no casting.
The Four Exception Types
We define four core exception types that cover most scenarios:
// 1. ValidationException - Bad input from client
export class ValidationException<TInfo extends Record<string, unknown> | null = null>
extends CustomException<TInfo> {}
// 2. UnauthorizedException - Missing or invalid auth
export class UnauthorizedException<TInfo extends Record<string, unknown> | null = null>
extends CustomException<TInfo> {}
// 3. ForbiddenException - Valid auth but insufficient permissions
export class ForbiddenException<TInfo extends Record<string, unknown> | null = null>
extends CustomException<TInfo> {}
// 4. InternalServiceError - Server-side failures
export class InternalServiceError<TInfo extends Record<string, unknown> | null = null>
extends CustomException<TInfo> {}
These map nicely to HTTP status codes (400, 401, 403, 500) while remaining GraphQL-friendly.
Middleware: The Error Transformer
Now comes the key piece: middleware that intercepts exceptions and transforms them into structured responses.
We use Middy, a popular middleware framework for Lambda:
import { MiddlewareObj } from '@middy/core';
export const exceptionHandlerMiddleware = (): MiddlewareObj => {
return {
onError: (request) => {
const { error } = request;
// Handle ValidationException
if (error instanceof ValidationException) {
return (request.response = {
errorMessage: error.message,
errorType: 'VALIDATION_EXCEPTION',
errorInfo: error.info || null,
});
}
// Handle UnauthorizedException
if (error instanceof UnauthorizedException) {
return (request.response = {
errorMessage: error.message,
errorType: 'UNAUTHORIZED_EXCEPTION',
errorInfo: error.info || null,
});
}
// Handle ForbiddenException
if (error instanceof ForbiddenException) {
return (request.response = {
errorMessage: error.message,
errorType: 'FORBIDDEN_EXCEPTION',
errorInfo: error.info || null,
});
}
// Handle unknown errors
return (request.response = {
errorMessage: error?.message || 'INTERNAL_SERVER_ERROR',
});
},
};
};
The beauty of this approach: your business logic just throws exceptions naturally, and the middleware handles the transformation. No try/catch blocks everywhere, no manual response formatting.
Putting It Together: Lambda Handler
Here’s what a complete Lambda handler looks like:
import middy from '@middy/core';
import { AppSyncResolverEvent } from 'aws-lambda';
import { exceptionHandlerMiddleware } from '../utils/middlewares/exceptions';
import { ValidationException, UnauthorizedException } from '../utils/exceptions/main';
export const base = async (event: AppSyncResolverEvent<{ id: string }>) => {
// Simple validation - just throw!
if (!event.arguments?.id) {
throw new ValidationException('ID is required', {
field: 'id',
constraint: 'required',
});
}
// Auth check
if (!event.identity?.sub) {
throw new UnauthorizedException('Authentication required');
}
// Business logic
const item = await fetchItemFromDatabase(event.arguments.id);
if (!item) {
throw new ValidationException('Item not found', {
field: 'id',
value: event.arguments.id,
});
}
return item;
};
// Wrap with middleware
export const handler = middy(base).use(exceptionHandlerMiddleware());
Notice how clean the business logic is? No error handling boilerplate—just throw and let the middleware handle it.
AppSync JS Resolvers: The Critical Bridge
Here’s where the magic happens. This is what transforms our structured Lambda responses into proper GraphQL errors—without this piece, we’d still have generic errors.
The key insight: Instead of letting AppSync convert Lambda errors into generic GraphQL errors, we:
- Return structured error responses from Lambda (not thrown errors)
- Use AppSync JS resolvers to detect these error responses
- Transform them into GraphQL errors with
util.error(), preserving all our structure
This is how we replicate API Gateway’s error handling in GraphQL:
import { util } from '@aws-appsync/utils';
export function request(ctx) {
return {
operation: 'Invoke',
invocationType: 'RequestResponse',
payload: {
arguments: ctx.arguments,
identity: ctx.identity,
info: ctx.info,
source: ctx.source,
stash: ctx.stash,
request: ctx.request,
},
};
}
export function response(ctx) {
const { result } = ctx;
// Handle Lambda execution errors (timeout, crash, etc.)
if (ctx.error) {
util.error('Internal Error', 'InternalError');
}
// HERE'S THE KEY: Detect our structured error responses
// Lambda returned: { errorMessage, errorType, errorInfo }
// We transform it to a GraphQL error while preserving structure
if (result && result.errorMessage) {
util.error(
result.errorMessage, // Human-readable message
result.errorType, // Error type (VALIDATION_EXCEPTION, etc.)
result.data || null, // Partial response can be provided in case of errors
result.errorInfo // Our custom context object!
);
}
// Success case - return the result
return result;
}
Why This Works
Without JS Resolvers (Generic GraphQL):
Lambda throws Error → AppSync catches → Generic GraphQL error
Result: { "errors": [{ "message": "Error" }] }
With JS Resolvers (Structured Errors):
Lambda returns { errorMessage, errorType, errorInfo }
→ JS Resolver detects error response
→ util.error() creates GraphQL error with full context
Result: { "errors": [{ "message": "...", "errorType": "...", "errorInfo": {...} }] }
The util.error() Function
This is AppSync’s built-in function for creating GraphQL errors. It accepts four parameters:
util.error(
message, // string: Human-readable error message
errorType, // string: Error type identifier (like HTTP status)
data, // any: Optional data to return (rarely used)
errorInfo // object: Custom structured context (our secret sauce!)
);
When called, it:
- Immediately stops resolver execution
- Sets
data: nullin the GraphQL response - Creates an error object in the
errorsarray - Preserves our errorType and errorInfo fields
This is how we get API Gateway-style structured errors in GraphQL.
The Final Result: API Gateway-Style GraphQL Errors
After all this processing, here’s what your client receives. Notice how it combines GraphQL’s structure with REST API’s rich error context:
{
"data": null,
"errors": [
{
"message": "Invalid email format",
"errorType": "VALIDATION_EXCEPTION",
"errorInfo": {
"field": "email",
"constraint": "email",
"receivedValue": "not-an-email"
},
"path": ["getUser"],
"locations": [{ "line": 2, "column": 3 }]
}
]
}
Compare this to a REST API Gateway response:
{
"statusCode": 400,
"errorType": "ValidationException",
"message": "Invalid email format",
"errorInfo": {
"field": "email",
"constraint": "email",
"receivedValue": "not-an-email"
}
}
We’ve replicated the structure! The client gets:
- ✅ Error type (
errorType) - Just like REST status codes, identify error categories - ✅ Human-readable message (
message) - Clear explanation of what went wrong - ✅ Structured context (
errorInfo) - Field-level details, constraints, and debugging data - ✅ GraphQL metadata (
path,locations) - Bonus: know exactly where in the query it failed
This is no longer a generic { "message": "Error" }. This is a production-grade error response.
Client-Side Error Handling
With structured errors, client-side handling becomes straightforward:
// Define error types
type ErrorType = 'VALIDATION_EXCEPTION' | 'UNAUTHORIZED_EXCEPTION' | 'FORBIDDEN_EXCEPTION';
interface GraphQLError {
message: string;
errorType?: ErrorType;
errorInfo?: Record<string, unknown>;
path: string[];
locations: Array<{ line: number; column: number }>;
}
// Centralized error handler
function handleGraphQLError(error: GraphQLError) {
const { errorType, message, errorInfo } = error;
switch (errorType) {
case 'VALIDATION_EXCEPTION':
// Show specific field errors
showToast({
type: 'error',
title: 'Validation Error',
message: message,
details: errorInfo,
});
break;
case 'UNAUTHORIZED_EXCEPTION':
// Redirect to login
redirectToLogin();
break;
case 'FORBIDDEN_EXCEPTION':
// Show access denied message
showToast({
type: 'error',
title: 'Access Denied',
message: message,
});
break;
default:
// Generic error handling
showToast({
type: 'error',
title: 'Error',
message: message || 'An unexpected error occurred',
});
}
}
Production Considerations
Security: Don’t Leak Sensitive Information
Never expose sensitive data in error messages:
// BAD - Exposes internal details
throw new ValidationException(
`Database query failed: SELECT * FROM users WHERE api_key='sk_live_abc123'`
);
// GOOD - Generic message with safe context
throw new InternalServiceError('Database operation failed', {
operation: 'query',
table: 'users'
});
Consider sanitizing error info in production:
export const exceptionHandlerMiddleware = (): MiddlewareObj => {
return {
onError: (request) => {
const { error } = request;
const isProd = process.env.NODE_ENV === 'production';
if (error instanceof ValidationException) {
return (request.response = {
errorMessage: error.message,
errorType: 'VALIDATION_EXCEPTION',
errorInfo: isProd ? sanitizeErrorInfo(error.info) : error.info,
});
}
// ... other handlers
},
};
};
function sanitizeErrorInfo(info: any) {
const sanitized = { ...info };
delete sanitized.internalId;
delete sanitized.stackTrace;
delete sanitized.apiKeys;
return sanitized;
}
Benefits of This Approach
After implementing this system, you’ll notice several improvements:
- Consistency: Every error follows the same structure
- Type Safety: TypeScript catches error handling mistakes at compile time
- Debuggability: Rich context makes production debugging easier
- Developer Experience: Clear error messages and types make the API a joy to use
- Separation of Concerns: Business logic stays clean, error handling is centralized
- Client-Friendly: Structured errors are easy to handle in frontend code
Common Patterns and Examples
Validation Errors
// Required field
if (!input.email) {
throw new ValidationException('Email is required', {
field: 'email',
constraint: 'required',
});
}
// Format validation
if (!isValidEmail(input.email)) {
throw new ValidationException('Invalid email format', {
field: 'email',
constraint: 'email',
receivedValue: input.email,
});
}
// Range validation
if (input.age < 18 || input.age > 120) {
throw new ValidationException('Age must be between 18 and 120', {
field: 'age',
constraint: 'range',
receivedValue: input.age,
min: 18,
max: 120,
});
}
Authorization Errors
// Missing authentication
if (!ctx.identity?.sub) {
throw new UnauthorizedException('Authentication required', {
reason: 'missing_token',
});
}
// Expired token
if (isTokenExpired(token)) {
throw new UnauthorizedException('Token has expired', {
reason: 'token_expired',
expiredAt: token.expiresAt,
});
}
// Insufficient permissions
const hasPermission = await checkPermission(userId, 'delete:items');
if (!hasPermission) {
throw new ForbiddenException('Insufficient permissions', {
requiredPermission: 'delete:items',
userRole: userRole,
});
}
Service Errors
// Database errors
try {
await database.query(sql);
} catch (error) {
throw new InternalServiceError('Database operation failed', {
operation: 'query',
table: 'users',
retryable: true,
});
}
// External API failures
try {
const response = await externalAPI.fetch(url);
} catch (error) {
throw new InternalServiceError('External service unavailable', {
service: 'PaymentProvider',
statusCode: error.statusCode,
retryable: error.statusCode >= 500,
});
}
Why This Matters
Building a robust error handling system takes effort upfront, but pays dividends as your application grows. You’ll spend less time debugging vague errors and more time building features. More importantly, your clients get the same quality of error handling they’d expect from a REST API, but with GraphQL’s query flexibility.
Further Reading
- AWS AppSync Documentation
- Middy Middleware Framework
- GraphQL Error Handling Best Practices
- AWS Lambda Error Handling
Have questions or suggestions? Found this helpful? Let me know in the comments below!