Error handling, validation and custom scalars
Errors versus transport failures, extension codes clients can switch on, input validation with unions and custom scalars done properly.
A model clients can code against
type Mutation {
placeOrder(input: PlaceOrderInput!): PlaceOrderPayload!
}
type PlaceOrderPayload {
order: Order
errors: [UserError!]!
}
type UserError {
field: String
code: String!
message: String!
}
input PlaceOrderInput {
items: [OrderItemInput!]!
couponCode: String
}{
"data": {
"placeOrder": {
"order": null,
"errors": [
{ "field": "couponCode", "code": "COUPON_EXPIRED", "message": "This coupon expired on 1 June." }
]
}
}
}- Expected, user-fixable failures belong in the payload as typed data - a client should be able to render them without parsing prose.
- The top-level
errorsarray is for unexpected failures: a bug, a timeout, a lost database connection. - Stable
codevalues are a contract. Renaming one is a breaking change, so treat the list as part of the schema. - Never put a stack trace, an SQL fragment or an internal hostname into a client-visible message.
💡
extensions.code is the machine-readable field, and the code inside your own error type is your application vocabulary. Keep the two separate: the first tells the client how to react to the transport, the second tells the user what to fix.Input validation
input CreateBookInput {
title: String!
isbn: String!
pageCount: Int!
publishedOn: Date
}
scalar Date
scalar DateTime
scalar URLfunction assertValid(input) {
const errors = [];
if (!input.title.trim()) errors.push({ field: "title", code: "REQUIRED", message: "Title is required." });
if (input.pageCount <= 0) errors.push({ field: "pageCount", code: "OUT_OF_RANGE", message: "Page count must be positive." });
if (!/^\d{13}$/.test(input.isbn)) errors.push({ field: "isbn", code: "INVALID_FORMAT", message: "ISBN must be 13 digits." });
if (errors.length) {
const error = new GraphQLError("Validation failed", {
extensions: { code: "BAD_USER_INPUT", errors },
});
throw error;
}
}| Signal | Sent when | Client reaction |
|---|---|---|
BAD_USER_INPUT | Input failed validation | Show field errors |
UNAUTHENTICATED | No or invalid credentials | Redirect to login |
FORBIDDEN | Authenticated but not allowed | Hide the action |
NOT_FOUND | Requested entity missing | Show an empty state |
INTERNAL_SERVER_ERROR | Anything else | Retry, then report |
Validate before touching the database, and validate again in the data layer. Schema validation proves the shape; it cannot prove the ISBN is unique or the coupon has not expired.
Custom scalars
import { GraphQLScalarType, Kind } from "graphql";
export const DateScalar = new GraphQLScalarType({
name: "Date",
description: "An ISO 8601 calendar date, for example 2026-09-18",
serialize(value) {
if (!(value instanceof Date)) throw new TypeError("Date cannot represent this value");
return value.toISOString().slice(0, 10);
},
parseValue(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new TypeError("Date must be an ISO 8601 date string");
}
return new Date(value + "T00:00:00Z");
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) throw new TypeError("Date must be a string");
return new Date(ast.value + "T00:00:00Z");
},
});- A scalar is only worth adding when its invariants are non-trivial. Wrapping a plain string in a scalar adds client code generation work for nothing.
serializeruns on output,parseValueon variables andparseLiteralon inline literals - all three are needed, and they must agree.- A scalar with an unvalidated
parseValuesimply moves the validation problem into every resolver. - Naming matters:
URL,EmailAddressandUUIDare understood;String2is not.
FAQ
Should a mutation return a union of success and error types?
It is the most type-safe option and worth it for complex flows, because the client must handle both branches. For simpler cases a payload with an
errors list is easier to consume and easier to evolve.Why is my field null?
A resolver threw, or returned null for a non-null field which propagates null upward to the nearest nullable parent. Check the
errors array for the path of the failing field, and enable detailed logging in development.Related
Building a GraphQL server Testing GraphQL APIs
Last refreshed 2026-09-18.