GraphQL Error Handling
GraphQL never uses HTTP status codes to signal failure — every response returns 200 OK, and problems surface inside the JSON errors array. That makes consistent, well-structured errors essential: clients parse the extensions.code field to branch logic, and a leaked stack trace can expose database internals or secrets. In NestJS you shape these errors at two layers: per-request with a GqlExceptionFilter that maps thrown exceptions, and globally with the Apollo formatError hook that polishes the final wire shape. This page shows how to combine both, attach stable error codes, and strip internals in production.
How GraphQL errors travel
When a resolver throws, Apollo catches the exception and serializes it into the errors array. A typical error contains a message, the path to the failing field, source locations, and an extensions object where structured metadata lives. NestJS’s built-in HttpException classes already feed sensible messages into this pipeline, but the default extensions.code is often generic, and unhandled errors can dump full stack traces in development.
Output:
{
"errors": [
{
"message": "User not found",
"path": ["user"],
"locations": [{ "line": 2, "column": 3 }],
"extensions": {
"code": "NOT_FOUND",
"userId": "42"
}
}
],
"data": { "user": null }
}
Throwing meaningful errors
The simplest approach is throwing Apollo’s GraphQLError directly, supplying an extensions.code so clients have a stable contract. This keeps the error self-describing without any filter wiring.
import { Resolver, Query, Args } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { User } from './models/user.model';
import { UsersService } from './users.service';
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly users: UsersService) {}
@Query(() => User)
async user(@Args('id') id: string): Promise<User> {
const found = await this.users.findById(id);
if (!found) {
throw new GraphQLError('User not found', {
extensions: { code: 'NOT_FOUND', userId: id },
});
}
return found;
}
}
You can also keep throwing standard NestJS exceptions like NotFoundException or ForbiddenException — they integrate with guards, interceptors, and the rest of the framework. A custom filter then translates them into clean GraphQL errors.
Mapping exceptions with a GqlExceptionFilter
A GqlExceptionFilter is an exception filter whose catch method receives the GraphQL execution context instead of an HTTP request/response. Returning the error from catch lets Apollo attach it to the errors array. Use GqlArgumentsHost to recover the GraphQL context (handy for logging the field name or arguments).
import { Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { GqlExceptionFilter, GqlArgumentsHost } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
@Catch()
export class GraphqlExceptionFilter implements GqlExceptionFilter {
private readonly logger = new Logger(GraphqlExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): GraphQLError {
const gqlHost = GqlArgumentsHost.create(host);
const info = gqlHost.getInfo();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const response = exception.getResponse();
const message =
typeof response === 'string'
? response
: (response as { message?: string }).message ?? exception.message;
return new GraphQLError(message, {
extensions: { code: this.codeFor(status), status },
});
}
this.logger.error(
`Unhandled error in field "${info?.fieldName}"`,
exception instanceof Error ? exception.stack : String(exception),
);
return new GraphQLError('Internal server error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
}
private codeFor(status: number): string {
const map: Record<number, string> = {
400: 'BAD_REQUEST',
401: 'UNAUTHENTICATED',
403: 'FORBIDDEN',
404: 'NOT_FOUND',
409: 'CONFLICT',
};
return map[status] ?? 'INTERNAL_SERVER_ERROR';
}
}
Register it like any other filter. For a single resolver use @UseFilters(GraphqlExceptionFilter); to cover the whole app, bind it globally in the module.
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { GraphqlExceptionFilter } from './graphql-exception.filter';
@Module({
providers: [
{ provide: APP_FILTER, useClass: GraphqlExceptionFilter },
],
})
export class AppModule {}
Use
@Catch()with no arguments so the filter handles every exception type. Filters bound viaAPP_FILTERare instantiated by the DI container, so you can inject services such as a logger or metrics client.
Polishing the wire shape with formatError
formatError is an Apollo Server hook that runs on every error just before it leaves the server. It is the right place to enforce a uniform shape and to hide internals in production. Because it runs last, it sees errors from filters, validation, and the GraphQL engine alike.
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLFormattedError } from 'graphql';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
formatError: (formattedError): GraphQLFormattedError => {
const code =
(formattedError.extensions?.code as string) ?? 'INTERNAL_SERVER_ERROR';
const isProd = process.env.NODE_ENV === 'production';
if (isProd && code === 'INTERNAL_SERVER_ERROR') {
return {
message: 'Something went wrong. Please try again later.',
extensions: { code },
};
}
return {
message: formattedError.message,
path: formattedError.path,
extensions: {
code,
...(isProd ? {} : { stacktrace: formattedError.extensions?.stacktrace }),
},
};
},
}),
],
})
export class GraphqlConfigModule {}
In production this collapses unexpected errors to a generic message while preserving the machine-readable code. Stack traces are emitted only outside production.
Filter vs. formatError
| Concern | GqlExceptionFilter | formatError |
|---|---|---|
| Layer | NestJS request pipeline | Apollo Server output |
| Sees DI / NestJS services | Yes | No (plain config function) |
| Maps thrown exceptions to codes | Yes | No (only reshapes) |
| Best for | Translating HttpException and domain errors | Final shape, redaction, hiding internals |
| Runs per | Caught exception | Every error on the wire |
Best practices
- Always set a stable
extensions.code— clients should branch on the code, never on the human-readable message. - Keep a single source of truth for codes (an enum or constant map) shared by filters and tests.
- Hide internals in production: redact
INTERNAL_SERVER_ERRORmessages and never ship stack traces to clients. - Log the original exception inside the filter before returning a sanitized error, so observability is not lost.
- Reuse NestJS exceptions (
NotFoundException,ForbiddenException) so guards and interceptors keep working, and let the filter translate them. - Validate inputs with
ValidationPipe/class-validatorso bad requests fail withBAD_REQUESTbefore reaching resolvers. - Avoid leaking partial data shapes — return
nullfor failed non-nullable fields only when the schema allows it, and prefer explicit errors otherwise.