I had six integration tests for error codes on a small GraphQL + Postgres backend: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT and friends. The resolvers threw the right errors. CI still failed the tests, and every error came back the same:
{ "errors": [{ "message": "Unexpected error.", "extensions": { "code": "INTERNAL_SERVER_ERROR" } }] }
Nothing was wrong with the resolvers. The server's error masking had decided our errors weren't GraphQL errors at all.
The setup
Expected failures are typed. Everything else gets masked so stack traces and SQL never reach the client:
export class AppError extends GraphQLError {
constructor(code: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
super(message, { extensions: { code, ...extra } });
}
}
// resolver
if (!canEdit) throw new AppError('FORBIDDEN', 'Not allowed');
The masking rule is the usual one: if the thrown error is a GraphQLError (or wraps one), pass it through; otherwise replace it with INTERNAL_SERVER_ERROR. In GraphQL Yoga that's maskedErrors: true; in Apollo it's a formatError with an instanceof check.
The cause: two GraphQLError classes
instanceof compares against one specific class object. If the module that defines GraphQLError is loaded twice, there are two classes with the same name, and an error built from one is not an instanceof the other.
graphql v16 ships both a CommonJS build (index.js) and an ES module build (index.mjs). Your code can get one while your server library gets the other, depending on how each is imported and how your test runner or bundler resolves them. Vitest, Jest and some bundlers are common places for this to happen. A monorepo with two graphql versions in node_modules does the same thing more obviously.
So the chain was:
- The resolver throws
AppError, which extends copy A ofGraphQLError. - The server checks it against copy B.
-
instanceofis false, so the error counts as unexpected. - The client gets
INTERNAL_SERVER_ERROR, and the real code is only in the server log.
In production this looks like "the frontend can't tell a wrong password from an outage." It's easy to ship, because a manual test of the happy path never sees it.
How to check if you have it
Throw an AppError from any resolver and log this where your server handles errors:
import { GraphQLError } from 'graphql';
console.log(
err.originalError?.constructor?.name, // "AppError"
err.originalError instanceof GraphQLError, // false means two copies
);
Then look for duplicates:
npm ls graphql
More than one version listed is the obvious case. One version but false above means the CJS/ESM split.
The fix: recognize your errors by a brand, not by class identity
Give your error class a property that survives any number of module copies, and check that instead:
export class AppError extends GraphQLError {
readonly isAppError = true;
constructor(code: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
super(message, { extensions: { code, ...extra } });
}
}
export function isAppError(err: unknown): err is AppError {
return typeof err === 'object' && err !== null && (err as { isAppError?: unknown }).isAppError === true;
}
Then do the masking yourself, so the decision uses the brand:
export function maskError(error: unknown, message: string): Error {
const e = error as { originalError?: unknown; nodes?: any; path?: readonly (string | number)[] };
const app = isAppError(e) ? e : isAppError(e?.originalError) ? e.originalError : null;
if (app) {
return new GraphQLError(app.message, { nodes: e.nodes, path: e.path, extensions: app.extensions });
}
console.error(error);
return new GraphQLError(message, { nodes: e?.nodes, path: e?.path, extensions: { code: 'INTERNAL_SERVER_ERROR' } });
}
// GraphQL Yoga
createYoga({ schema, maskedErrors: { maskError } });
For Apollo Server, use the same isAppError check inside formatError.
Deduplicating graphql (one version, one import style) is worth doing too. But the brand check keeps working when a future dependency upgrade brings the second copy back, and that's the failure you won't be watching for.
The test that caught it
One assertion per error code, against a real server and a real Postgres:
it('forbids viewers from creating issues', async () => {
const res = await gql(
'mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }',
{ i: { projectId: ids.alpha, title: 'Pour slab' } },
ids.bo, // a member with the viewer role
);
expect(res.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
If your error-code tests only call resolver functions directly, they skip the masking layer and pass while clients get 500s. Send the request through the server.
Checklist
- Typed errors carry a brand property; masking checks the brand, not
instanceof. - Masking runs in one function you own, with the same rule for every server.
-
npm ls graphqlshows one version. - Each error code has a test that goes through the HTTP layer and asserts
extensions.code.
Making Claude Code write it this way
This is one of the rules in the typed-errors skill of my Claude Code skills pack for TypeScript + GraphQL + Postgres backends. The starter backend in the pack is where CI caught this bug.
- Two skills are free (GraphQL N+1 fixes and safe Postgres migrations): github.com/adnan909/free-sample
- The full pack, with the typed-errors skill, 6 more skills and the tested starter backend: Backend Skills Pack on Gumroad
Top comments (0)