GraphQL answers with HTTP 200 for an executed operation even when parts of it failed, which surprises developers coming from REST. After this lesson you will be able to read the errors array correctly, throw errors with machine-readable codes, hide internal details from clients, and model expected failures as typed data.
A response can contain both data and errors. When a field resolver throws, the error is recorded with a path, the field becomes null, and the rest of the operation still executes:
{
"data": { "post": null, "me": { "name": "Ada" } },
"errors": [
{
"message": "Post 99 not found",
"path": ["post"],
"extensions": { "code": "NOT_FOUND" }
}
]
}Nullability decides how far the damage spreads. If post were declared Post!, its null would be invalid, so the error propagates to the nearest nullable parent, at worst making data itself null. This is why fields that can plausibly fail are usually left nullable. Errors raised before execution (syntax or validation failures) produce a response with no data key and, on spec-compliant servers, HTTP 400.
Use GraphQLError from the graphql package and put a stable code in extensions; clients switch on the code, never on the message:
import { GraphQLError } from "graphql";
post: async (_p, { id }, { db, user }) => {
if (!user) {
throw new GraphQLError("You must be logged in", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const post = await db.posts.find(id);
if (!post) {
throw new GraphQLError(`Post ${id} not found`, {
extensions: { code: "NOT_FOUND" },
});
}
return post;
},Apollo Server exports its built-in codes as the ApolloServerErrorCode enum from @apollo/server/errors (BAD_USER_INPUT, GRAPHQL_VALIDATION_FAILED, INTERNAL_SERVER_ERROR); UNAUTHENTICATED and FORBIDDEN are conventions understood by most tooling.
A thrown database exception must never reach the client with its connection string. GraphQL Yoga masks unexpected errors by default, replacing the message with "Unexpected error." and logging the original; only GraphQLError instances you throw yourself pass through. Apollo Server forwards the original message, so add a formatError hook:
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formatted, error) => {
if (formatted.extensions?.code === "INTERNAL_SERVER_ERROR") {
console.error(error);
return { message: "Internal server error", extensions: { code: "INTERNAL_SERVER_ERROR" } };
}
return formatted;
},
});Duplicate e-mails and insufficient balance are outcomes a user can act on, so they belong in the schema. Payloads use an errors list; the union pattern gives each case its own type:
type Mutation {
register(input: RegisterInput!): RegisterResult!
}
union RegisterResult = RegisterSuccess | EmailTakenError | ValidationError
type RegisterSuccess { user: User! }
type EmailTakenError { email: String! }
type ValidationError { field: String!, message: String! }mutation {
register(input: { email: "ada@example.com", password: "secret" }) {
... on RegisterSuccess { user { id } }
... on EmailTakenError { email }
... on ValidationError { field message }
}
}The client must handle every case, and code generation turns the union into a TypeScript discriminated union on __typename.
| Failure | Where to report it |
|---|---|
| Bug or outage | Top-level errors, INTERNAL_SERVER_ERROR, masked |
| Not logged in, forbidden | Top-level errors, UNAUTHENTICATED / FORBIDDEN |
| Input the user can correct | data, via payload errors or a result union |
response.ok only; a 200 can still carry errors.error.message in clients; messages change, codes should not.null silently for a missing record instead of a typed result.A nullable field resolver throws. What does the response contain?
data and errors.GraphQLError with a stable extensions.code; nullability decides how far a field error propagates.formatError.Next lesson: Authentication and Authorization — identify the caller in the context function and enforce permissions in resolvers and directives.