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 errors array is for unexpected failures: a bug, a timeout, a lost database connection.
  • Stable code values 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 URL
function 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;
  }
}
SignalSent whenClient reaction
BAD_USER_INPUTInput failed validationShow field errors
UNAUTHENTICATEDNo or invalid credentialsRedirect to login
FORBIDDENAuthenticated but not allowedHide the action
NOT_FOUNDRequested entity missingShow an empty state
INTERNAL_SERVER_ERRORAnything elseRetry, 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.
  • serialize runs on output, parseValue on variables and parseLiteral on inline literals - all three are needed, and they must agree.
  • A scalar with an unvalidated parseValue simply moves the validation problem into every resolver.
  • Naming matters: URL, EmailAddress and UUID are understood; String2 is 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.

Building a GraphQL server Testing GraphQL APIs

Last refreshed 2026-09-18.