DEV Community

Cover image for GraphQL error handling to the max with Typescript, codegen and fp-ts
TheGuildBot for The Guild

Posted on • Edited on • Originally published at the-guild.dev

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

This article was published on Monday, March 7, 2022 by Ghislain Thau @ The Guild Blog

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]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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]"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode


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
  })
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode


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>'
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
  )
Enter fullscreen mode Exit fullscreen mode

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}`))
    )
Enter fullscreen mode Exit fullscreen mode

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))))
  )
Enter fullscreen mode Exit fullscreen mode

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)
  )
Enter fullscreen mode Exit fullscreen mode

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
    )();
  }
},
Enter fullscreen mode Exit fullscreen mode

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,
      ),
    )();
  }
},
Enter fullscreen mode Exit fullscreen mode

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))
  // ...
)
Enter fullscreen mode Exit fullscreen mode

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))
  // ...
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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')
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)