GraphQL error handling to the max with Typescript, codegen and fp-ts

Ghislain Thau

Contrary to REST, GraphQL does not use Status Code to differentiate a successful result from an exception. In REST, one can send a response with a appropriate status code to inform the caller about the kind of exception encountered, e.g.:

  • 400: bad request (e.g. invalid inputs)
  • 401 or 403: unauthorized (need authentication) or forbidden (insufficient permission)
  • 404: resource not found
  • 500: internal server error
  • and more.

In GraphQL, we manage errors in a different way. We’ll see in this article how to:

  • achieve a better error representation than the default offered by GraphQL
  • get type safety using Typescript and GraphQL Code Generator
  • handle the errors from lower-level code up to the resolvers
  • and finally get additional safety using a functional approach

All the code examples can be found in this repository.

GraphQL Default Error Handling

In GraphQL, all requests must go to a single endpoint:

POST https://example.com/graphql

A GraphQL API will always return a 200 OK Status Code, even in case of error. You’ll get a 5xx error in case the server is not available at all.

💡

This is actually a common practice, but it is not mandatory. You could send GraphQL responses with different Status Codes. It would however need to be implemented at the server level and not at the operation resolvers level.

As an example, you can check out graphql-helix implementation of some error cases’ responses with different Status Codes, e.g. 400 for GraphQL syntax or validation error, 405 when using not allowed HTTP method (e.g. GET for mutations or using anything else than POST or GET for queries).

The standard error handling mechanism from GraphQL with return a JSON containing:

  • a data key which contains the data corresponding to the GraphQL operation (query, mutation, subscription) invoked and
  • an errors key which contains an array of errors returned by the server, with a message and location.
{
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 6, "column": 7 }],
      "path": ["user", 1]
    }
  ]
}

This standard error handling is not satisfactory for the many reasons:

  1. it is not easily parseable, it is more meant for a human user to read
  2. it is not typed: you can add more information, e.g. an error code and other fields, but those are not part of the schema
  3. it is not self-documented: by looking at the operation signature, you don’t know what might fail
  4. it treats some domain results as errors / exceptions whereas they are just alternate results of the operation. E.g. an entity not found for a given id and a user not having permission to access an entity belong to the domain model, whereas runtime exceptions such as the GraphQL server failing to communicate with the database / external microservice, or an operation timing out because of slowness are real exceptions: we don’t get any result that relates to domain model, just an unexpected runtime error.

Better Modelisation of Errors Using Union and Interfaces

GraphQL has support for Union types. We can take advantage of it and design our schema to specify all possible results: expected and unexpected / error cases.

💡

GraphQL Union is available for Types only, not for Inputs. However, the oneOf directive will bridge the gap in the future.

If you use Envelop or GraphQL Yoga (both developed by The Guild), you can already benefit from it today by using this Envelop plugin)

We can then do queries like the following:

query {
  entity: entity(id: "10", userId: "u-4") {
    ...fields
  }
  notFound: entity(id: "100", userId: "u-1") {
    ...fields
  }
  notAllowed: entity(id: "1", userId: "u-4") {
    ...fields
  }
  invalidEntityId: entity(id: "1a", userId: "u-4") {
    ...fields
  }
}
 
fragment fields on EntityResult {
  __typename
  ... on Entity {
    id
    name
  }
  ... on BaseError {
    message
  }
}

And get the expected result or properly typed alternative results in case of errors:

{
  "data": {
    "entity": {
      "__typename": "Entity",
      "id": "10",
      "name": "Entity #10"
    },
    "notFound": {
      "__typename": "NotFoundError",
      "message": "Entity 100 not found"
    },
    "notAllowed": {
      "__typename": "NotAllowedError",
      "message": "User u-4 isn't allowed to access entity 1"
    },
    "invalidEntityId": {
      "__typename": "InvalidInputError",
      "message": "Input validation failed for fields: [entityId]"
    }
  }
}

Leveraging GraphQL Union types for error cases brings many benefits:

  1. Type-safety: the errors are also typed
  2. The consumer cannot ignore the errors (the must be handled using inline fragments)
  3. Self-documented: the operation signature includes all possible cases (result and errors) therefore less documentation is required to explain the possible error cases
  4. The unexpected results are now just other possible results

Implementing Union Types Based Error Handling

Given the following Schema:

type Entity {
  id: ID!
  name: String!
}
 
type Query {
  entity(id: ID!, userId: ID!): Entity
}

If something fails during the execution, we can:

  • throw an Error and use the default GraphQL error mechanism explained above
  • return null: this is not satisfactory because we can’t know the reason the operation returned null

Let’s instead implement error handling using Union types with the following modifications:

interface BaseError {
  message: String!
}
 
type InvalidInputError implements BaseError {
  message: String!
}
 
type NotFoundError implements BaseError {
  message: String!
}
 
type UnknownError implements BaseError {
  message: String!
}
 
type NotAllowedError implements BaseError {
  message: String!
}
 
type Entity {
  id: ID!
  name: String!
}
 
union EntityResult = Entity | NotFoundError | NotAllowedError | InvalidInputError | UnknownError
 
type Query {
  entity(id: ID!, userId: ID!): EntityResult!
}

We define a BaseError interface that all concrete Errors implement. Then, we define the query result as a Union type of the expected result (an Entity) and the other possible results (NotFoundError, NotAllowedError, etc.). In this manner, we extensively describe all possible results, with the added benefit that we can make our result type non-nullable (with the ! character).

For a more detailed explanation, you can read these articles:

  1. By Sasha Solomon: 200 OK! Error Handling in GraphQL
  2. By Laurin Quast: Handling GraphQL errors like a champ with unions and interfaces
  3. By Marc-André Giroux: A Guide to GraphQL Errors
  4. Watch this video by @notrab:

Handle Those Schema Changes into the GraphQL Server

Since we changed the Schema, we also need to change the query, mutation and types resolvers.

For simple cases, we can just return the correct object from the query resolver:

const resolvers = {
  Query: {
    entity: async (parent, { id, userId }, context) => {
      const entity = await context.api.getEntityById(id)
      const isAllowed = await context.api.isUserAllowedForEntity(id, userId)
 
      if (!entity) {
        return {
          __typename: 'NotFoundError',
          message: `Entity ${id} not found`
        }
      }
      if (!isAllowed) {
        return {
          __typename: 'NotAllowedError',
          message: `User ${userId} not allowed for entity ${id}`
        }
      }
      return {
        __typename: 'Entity',
        ...entity
      }
    }
  }
}

Since we include the __typename and the fields match the Schema type definitions, there is no need to define type resolvers.

For more details on this solution, you can read this article from Laurin Quast: Handling GraphQL errors like a champ with unions and interfaces.

Typesafe Error Handling on the Client-Side

Let’s now take it to the next level using Typescript and GraphQL Code Generator.

GraphQL Code Generator is a tool developed by The Guild which generates type definitions which corresponds to the GraphQL schema. It has several plugins, and we’ll use 3 of those:

  • typescript: generates the type definitions for Typescript
  • typescript-resolvers: generates type definitions for all operations resolvers
  • add: append/prepend text to the generated type definitions file, it allows us to import some of our types into the generated file

Specific Error Classes for All GraphQL Schema Error Types

First we defined corresponding Error classes for the different error cases:

export class InvalidInputError extends Error {}
export class NotFoundError extends Error {}
export class NoUserRightsError extends Error {}
export class UnknownError extends Error {}
💡

When using remote APIs, we often have the possibility to generate the types automatically from a JSON schema for REST APIs, from protobuf files for gRPC-based APIs, from a database schema, etc. You might even be using an external API through an SDK that already provides you with all types. In such cases, the creation of specialized Error classes is not mandatory. However, it might still be a good idea to do so to provide application-specific errors rather than bubbling up 3rd-party low-level errors. For such cases, the upcoming Ecma TC39 proposal for Error Cause is useful as it allows to chain errors. Polyfills exist: Pony Cause or error-cause.

💡

A small workaround is needed because graphql-js treats error-returning and error-throwing the same in resolvers, therefore we need to wrap our Errors before we can safely return them from the resolvers. Let’s use a simple custom wrapper type and constructor function:

export type WrappedError<E extends Error> = {
  _tag: 'WrappedError'
  err: NonNullable<E>
}
 
export const wrappedError = <E extends Error>(err: E) => ({
  _tag: 'WrappedError',
  err
})

codegen Mapping

GraphQL Code Generator is configured with a codegen.yml YAML file. In the mappers section, we map every type of our GraphQL Union type to its corresponding Typescript type.

overwrite: true
schema: '**/*/typedefs.graphql'
documents: null
generates:
  src/generated/graphql.ts:
    plugins:
      - add:
          content: '/* eslint-disable */'
      - add:
          content: "import * as E from './src/errors';"
      - 'typescript'
      - 'typescript-resolvers'
    config:
      mappers:
        InputFieldValidation: 'E.InputFieldError'
        InvalidInputError: 'E.WrappedError<E.InvalidInputError>'
        NotFoundError: 'E.WrappedError<E.NotFoundError>'
        NotAllowedError: 'E.WrappedError<E.NotAllowedError>'
        UnknownError: 'E.WrappedError<E.UnknownError>'
        Entity: './src/model/data/entity.data#Entity'
💡

We use the add plugin to import the WrappedError generic wrapper type and the Errors types. If we don’t do that, we would have to define the full path for each mapper, e.g.:

NotFoundError: 'import("./src/errors").WrappedError<import("./src/errors").NotFoundError>'

This codegen configuration generates the Typescript types from the Schema and the proper signatures for the operations (query/mutation/subscription) and types resolvers. Now we have static type safety and type inference and hints in the code editor.

Help the GraphQL Engine Decide Which Type to Return

In the first version of the query resolver, we were returning objects that matched the GraphQL types and were tagged with the __typename so the GraphQL engine could just return those without the need to have type resolvers. But now, we are returning different objects that will be mapped to the proper GraphQL type. And those objects are not tagged with __typename. So how does the GraphQL engine know to map the value returned by the query resolver?

In the codegen configuration, we have defined mappers between the GraphQL schema types and the Typescript types. But this is not used at runtime. This configuration is only used by GraphQL Code Generator to generate the proper types and resolvers signatures so that you get compile-time type safety and code editor hints.

The type resolvers have a special field resolver for this purpose: __isTypeOf is a field resolver function on each of the GraphQL type resolver of the query union type result. This field resolver is executed on all types that form the query resolver’s result Union type: when one returns true, the GraphQL engine will use this type resolver to generate the proper result object.

Rewrite the Resolvers to Use the Errors Types and __isTypeOf

Now that we have defined the proper mapping between the GraphQL schema types and the Typescript types, let’s rewrite the resolvers:

const resolvers = {
  Query: {
    async entity(parent, { id, userId }, context) {
      const entity = await context.api.getEntityById(id);
      const isAllowed = await context.api.isUserAllowedForEntity(id, userId);
 
      const error = (
        !entity ? new NotFoundError(`Entity ${id} not found`) :
        !isAllowed ? new NotAllowedError(`User ${userId} not allowed for entity ${id}`) :
        null
      );
 
      if (error) {
        return wrappedError(error);
      }
 
      return entity;
    },
  },
 
  Entity: {
    __isTypeOf: (parent): parent instanceof Entity,
    id: (parent) => parent.id,
    name: (parent) => parent.name,
  },
 
  NotFoundError: {
    __isTypeOf: (parent) => parent.err instanceof NotFoundError,
    message: (parent) => parent.err.message,
  },
 
  // same for all other error types: NotAllowedError, InvalidInputsError, UnknownError
};

Query Fields Selection

Our query can now return several types, therefore we need to adapt the query fields selection and use fragments:

query entity($id: ID!, $userId: ID!) {
  entity(id: $id, userId: $userId) {
    __typename
    ... on Entity {
      id
      name
    }
    ... on BaseError {
      message
    }
  }
}

A good practice is to always have a fragment that matches the BaseError interface: it makes the client code future-proof in case one more error is added to the Union result type.

Even Safer Error Handling Using Functional Programming Paradigm

The previous section was dedicated to showing how to get type safety and inference in resolvers and how to generate the proper GraphQL type from a query resolver Union type value.

The query resolver code above to get an Entity and check if User is allowed is oversimplified. In real-life, we would probably get this data from one or two data sources (database, distributed cache) or even another external microservice with a REST or gRPC interface. Those data fetching calls might throw errors, or return null.

We want to handle those errors, transform meaningless and unsafe null values into meaningful errors, while also being able to bubble up those errors to the query resolvers in a safe manner.

Monads to the Rescue

In order to make our throwing and unsafe APIs into safe data fetching functions, we’ll use data types and techniques from the Functional Programming paradigm. In particular, we’ll use the fp-ts library (but you can choose the FP library of your choice, e.g. purify, effect, monio, and others).

fp-ts exposes several useful data types and functions which allows to work on safe abstractions using composable functions. In particular, we’ll use:

  • Either: represents the result of a computation that might fail
  • Option: represents possible null/undefined values in a safe way
  • Task: lazy Promise, represents the result of an async operation
  • TaskEither: represents the result of an async operation that might fail (combination of Task and Either as its name implies)

Make the APIs Safer

Let’s consider a simple mock API for the purpose of this article. It represents a throwing API, such as a gRPC API.

// returns an Entity or throws
export async function fetchEntity(id: number): Promise<Entity> {
  const entity = entities.find(e => e.id === id);
  if (!entity) {
    throw new Error(`Entity ${id} not found`));
  }
  return new Entity(entity);
}
 
// returns a User or throws
export async function fetchUser(id: UserId): Promise<User> {
  const user = users.find(u => u.id === id);
  if (!user) {
    throw new Error(`User ${id} not found`);
  }
  return new User(user);
}

Since we have an async API that can throw, we’ll use TaskEither to wrap those calls. Either<E, A> (and so TaskEither<E, A>) is parametrized by two types: E which represents the Error and A which represents the type of the data if the call was successful. By convention, the left side represents the failure, the right side the data type of the successful result.

We can create an instance of it by using one of its basic constructors: of, left (to directly create an Error), right (to create a success) or one of its convenience constructors:

  • tryCatch: if the function supplied throws, store a customized Error in the left side, else store the successful result in the right side
  • fromPredicate: if the predicate applied to the value is falsy, store a customized Error in the left side, else store the value in the right side
  • fromNullable: if the value is null or undefined, store a customized Error in the left side, else store the value in the right side

This is now our safe APIs:

export const getEntity = (id: number): TE.TaskEither<NotFoundError, Entity> =>
  TE.tryCatch(
    () => fetchEntity(id),
    e => new NotFoundError(e.message)
  )
 
export const getUser = (id: UserId): TE.TaskEither<Error, User> =>
  TE.tryCatch(
    () => fetchUser(id),
    e => new NotFoundError(e.message)
  )

Not only have we made our API safer code-wise, but also better from a documentation standpoint: now we can also see which Errors the API returns, which was not the case when we were using Promise and reject (or try/catch).

Now that we have dealt with the data fetching, let’s consume this data. The first functionality we add is checking the user permissions. In this simple example, we don’t query an external service, we got the data in the Entity and User types. We can create the checking function without worrying about whether the user or entity is present since this will be handled by the monads (Either, TaskEither, Option). We can also decide to represent the absence of permission as an Error: in the second function, we use the Either.fromPredicate to transform the false value into a custom NotAllowedError:

// unsafe API
const isUserAllowedForEntity = async (user: User, entity: Entity): Promise<boolean> => {
  try {
    return !entity.restrictions.includes(user.country)
  } catch (e) {
    throw e
  }
}
 
// safe API
export const getIsUserAllowedForEntity = (
  user: User,
  entity: Entity
): TE.TaskEither<UnknownError, boolean> =>
  TE.tryCatch(
    () => isUserAllowedForEntity(user, entity),
    e => new UnknownError(e.message)
  )
 
const isNotAllowedAsError =
  (errorMsg: string) =>
  (isAllowed: boolean): TE.TaskEither<NotAllowedError, boolean> =>
    pipe(
      isAllowed,
      TE.fromPredicate(Boolean, _ => new NotAllowedError(errorMsg))
    )
 
export const isUserAllowedForEntityAsError =
  (user: User) =>
  (entity: Entity): TE.TaskEither<NotAllowedError | UnknownError, boolean> =>
    pipe(
      getIsUserAllowedForEntity(user, entity),
      TE.chainW(isNotAllowedAsError(`User ${user.id} isn't allowed to access entity ${entity.id}`))
    )

Now that both the Entity and User permission fetching calls are safer, we can combine them to create the Entity fetching function with User permission check incorporated:

export const getEntityForUser = (
  id: number,
  userId: UserId
): TE.TaskEither<NotFoundError | NotAllowedError, Entity> =>
  pipe(
    getUser(userId),
    TE.chainW(user => pipe(getEntity(id), TE.chainFirstW(isUserAllowedForEntityAsError(user))))
  )

We fetch the User, then the Entity and when we have both, we check the User permission for this entity. Two things to note here:

  1. because the isUserAllowedForEntityAsError depends on both the User and the Entity and we fetched those separately, we have a nested pipeline (the nested pipe needs to close over the user). We’ll see next an alternate syntax to get rid of this nesting.
  2. the chainFirst function allows us to execute computations in sequence but keep only the result of the first computation if the second computation is successful. Here what it does is:
    1. get the Entity (as a TaskEither)
      1. if it’s a left (some Error), it won’t execute the isUserAllowedForEntityAsError
      2. if it’s a right (Entity), it executes the isUserAllowedForEntityAsError
      3. if it’s a left (NotAllowedError), it returns the TaskEither holding this Error
      4. if it’s a right (true), it ignores it and returns the result of the previous computation (the TaskEither holding the Entity)

Avoid Nested pipe with the Do Notation

The Do notation is an adaptation of its native Haskell counterpart. It allows to build a context object that keeps track of the unwrapped values produced by the different operations in the pipeline. After its creation using the ADT Do or bind/bindTo functions, every subsequent operation in the pipeline has access to this context object, therefore eliminating the need for nesting.

Let’s rewrite our function using the Do notation:

export const getEntityForUser = (
  id: number,
  userId: UserId
): TE.TaskEither<NotFoundError | NotAllowedError, Entity> =>
  pipe(
    getUser(userId),
    TE.bindTo('user'),
    TE.bind('entity', _ => getEntity(id)),
    TE.bind('isAllowed', ({ user, entity }) => isUserAllowedForEntityAsError(user)(entity)),
    TE.map(({ entity }) => entity)
  )

Because the bind/bindTo functions return instances of ADTs (TaskEither here), we keep the fail-fast behaviour. In this example, if the getUser call returns a Left<NotFoundError>, we won’t call getEntity nor isUserAllowedForEntityAsError, it will return the TaskEither holding the NotFoundError.

💡

With the Do notation, we gain nest-free pipeline, but we lose point-free programming.

Query Resolver

Now that we have a safe API, we can move on to the query resolver. We’ll first validate the inputs and then query the data.

Inputs Validation

To validate the inputs, we’ll also create a function that runs a validator on each input and returns an Either. By doing this we can integrate the result of the validation into the pipeline.

Query: {
  entity(_, args, _2) {
    const { id: entityId, userId } = args;
 
    const validatedInputs = validate({
      entityId: validateIsNumber(entityId, 'entityId'),
      userId: validateIsUserId(userId, 'userId'),
    });
 
    return pipe(
      validatedInputs,
      // more code here
    )();
  }
},

The validateIsNumber and validateIsUserId are functions that return Either<{ field: string; message: string }, TypeOfTheInput>. If the input fails the validation, we get an object of field name and validation message in the Left side, otherwise we get the input value in the Right side.

The validate function takes a record of field to validator function and returns either an InvalidInputError or an object of input name to input value.

Fetching the Data and Returning

Query: {
  entity(_, args, _2) {
    const { id: entityId, userId } = args;
 
    const validatedInputs = validate({
      entityId: validateIsNumber(entityId, 'entityId'),
      userId: validateIsUserId(userId, 'userId'),
    });
 
    return pipe(
      validatedInputs,
      TE.fromEither,
      TE.chain(({ entityId, userId }) => getEntityForUser(Number(entityId), userId)),
      TE.foldW(
        taskWrappedError,
        T.of,
      ),
    )();
  }
},

Once the inputs are validated, the next step in the pipeline is to fetch the data using getEntityForUser. Because this call returns a TaskEither, we need to transform the previous Either into a TaskEither using TaskEither.fromEither. Of course, because of the fail-fast nature of the ADT, this won’t be called if the inputs failed the validation.

Finally, we exit the abstraction by using the TaskEither.fold (also called match) destructor: this function does pattern matching on the TaskEither and executes the first callback if it’s a Left, or the second callback if it’s a Right. At this point we want to return a WrappedError or an Entity from our query resolver, we do not want to return a TaskEither because we want simple type resolvers that acts on the correct type. The ADT abstraction stays within the boundaries of our program and we consider the query (or mutation or subscription) resolver to be this boundary.

Bonus: Make the Errors Type Resolvers Better Using currying

Currying is the technique of converting a function that takes multiple arguments into a sequence of functions that each takes a single argument. It enables point-free programming which allows for cleaner callback or function composition in pipeline.

We used above on several occasions:

pipe(
  // ...
  getEntity(id),
  TE.chainFirstW(isUserAllowedForEntityAsError(user))
  // ...
)

The isUserAllowedForEntityAsError is a curried function: when called with the user input, it returns a function that takes an entity input. If the function was not curried, we would have written:

pipe(
  // ...
  getEntity(id),
  TE.chainFirstW(entity => isUserAllowedForEntityAsError(user, entity))
  // ...
)

With this knowledge, we can improve the repetitive type resolver code:

NotFoundError: {
  __isTypeOf: (parent) => parent.err instanceof NotFoundError,
  message: (parent) => parent.err.message,
},
NotAllowedError: {
  __isTypeOf: (parent) => parent.err instanceof NotAllowedError,
  message: (parent) => parent.err.message,
},
 
// same for all other error types: InvalidInputsError, UnknownError

This code will be the same for all errors (with maybe a few additional field resolvers for some errors). To make it better, we’ll create a curried typeguard function and a curried field extraction for the error:

// curried typeguard
const isWrappedError =
  <E extends Error>(errorClass: Type<E>) =>
  (v: any): v is WrappedError<E> =>
    v?._tag === 'WrappedError' && v.err instanceof errorClass
 
// curried field extractor
const wrappedErrorField =
  <E extends Error>(errorClass: Type<E>) =>
  <TKey extends keyof E>(prop: TKey) =>
  (wrappedErr: WrappedError<E>): E[TKey] =>
    wrappedErr.err[prop]

Now we can rewrite our error type resolvers like so:

NotFoundError: {
  __isTypeOf: isWrappedError(NotFoundError),
  message: wrappedErrorField(NotFoundError)('message'),
},
NotAllowedError: {
  __isTypeOf: isWrappedError(NotAllowedError),
  message: wrappedErrorField(NotAllowedError)('message'),
},
 
// same for all other error types: InvalidInputsError, UnknownError

And since this code is common, we can extract it into a type resolver factory function:

const errorTypesCommonResolvers = <E extends Error>(errorClass: Type<E>) => ({
  __isTypeOf: isWrappedError(errorClass),
  message: wrappedErrorField(errorClass)('message')
})

And rewrite our errors type resolvers like so:

export const queryResolvers: Resolvers = {
  NotFoundError: errorTypesCommonResolvers(NotFoundError),
  NotAllowedError: errorTypesCommonResolvers(NotAllowedError)
 
  // same for all other error types: InvalidInputsError, UnknownError
}

And if we have an error with more fields (e.g. we add a validations field to InvalidInputError), we can still customize it by using the spread operator on the type resolver object:

export const queryResolvers: Resolvers = {
  NotFoundError: errorTypesCommonResolvers(NotFoundError),
  NotAllowedError: errorTypesCommonResolvers(NotAllowedError),
  // same for UnknownError type
 
  // customized error type resolver
  InvalidInputError: {
    ...errorTypesCommonResolvers(InvalidInputError),
    validations: wrappedErrorField(InvalidInputError)('validations')
  }
}

Wrapping Up

GraphQL gives you the power to model your domain errors and deliver them to the caller in a documented and type-safe way. Using Typescript you get type safety for most of your code. Combined with GraphQL Code Generator, you even get your resolvers functions fully typed. And finally with powerful functional programming concepts applied to your GraphQL resolvers, you can get the ultimate safe handling of your unsafe APIs.

Join our newsletter

Want to hear from us when there's something new?
Sign up and stay up to date!

*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.