DEV Community

loading...
IT Minds

Errors are results

Jonas Antvorskov
GraphQL and Typescript enthusiast.
・4 min read

Errors, Faults & Failures

When writing a server, errors are inevitable, but a distinction should be made between errors caused by things outside the client's control, such as your database crashing - I'll refer to these as Faults. Errors caused by things that are within the client's control, such as attempting to get an entity the client doesn't have access to - I'll refer to these as Failures.

Faults are in their nature temporal and fleeting, whether through automatic corrections made by the system itself, such as the database restarting, or fixes manually implemented by the maintainers of the system. This means that they can safely be omitted from your GraphQL schema because when they occur, the client has no meaningful course of action to mitigate the problem other than waiting for the issue to be fixed.

Failures, on the other hand, are persistent when attempting to get an entity the client doesn't have access to, the client does have meaningful courses of action to correct the issue, for example: not requesting the resource again or informing the user they don't have access. Because of this, it makes sense to treat these failures the same as you would a result. This means that any failures that can occur when resolving a field in a GraphQL server should be declared as part of a union type expressing the possible value types of a given field.

Making Failures Type-safe

In order to make failures as results type-safe without having to add extensive logic around handling errors to the majority of functions within your GraphQL server, while making sure that all failure results are handled.

I suggest using a Result pattern inspired by Rust. This pattern can be used to great effect in TypeScript. It allows us to have both the successful result typed and any possible failures to be typed.

  getUserById(id: string): AsyncResult<UserDataModel, NotFoundError | InvalidIdentifierError> {
    return this.identifierService.user.parse(id)
      .andThenAsync((id) => this.users.load(id.value));
  }
Enter fullscreen mode Exit fullscreen mode

This is an example of what the Result pattern looks like, when in use. This is a function on a user service that attempts to fetch a user. First, the identifierService parses the given string ID into the correct format for the underlying service. This results in an object of type Result<Identifier<'User'>, InvalidIdentifierError> if the ID is valid, the result object contains that parsed ID value. If it is invalid, it contains the InvalidIdentifierError. We can then call andThenAsync on the result object. This is essentially a map function that is only invoked if the result isn't an error and must return a new result object. The lambda function in this example returns the type Result<UserDataModel, NotFoundError>, this type is merged with the result type returned by the identifierService into the final return type of the function.

All of this simplifies the way failures are handled because we only need to care about them when we actually want to process the errors specifically. If we don't have any way to remedy the issue on the server, it should ultimately be returned to the client as a result, and if we don't encounter an error, we can just map an intermediate result to a new result.

When generating a new result, the pattern is also trivial to use. The following is an example of how the identifierService.user.parse(id) method is implemented:

  idMatcher = /(\w+)\/(0|[1-9][0-9]*)/;
  parse(id: string): Result<Identifier<Prefix>, InvalidIdentifierError> {
    const idMatch = idMatcher.exec(id);
    return idMatch && idMatch[1] === this.prefix
      ? Result.ok(new Identifier(this.prefix, parseInt(idMatch[2], 10)))
      : Result.error(new InvalidIdentifierError(id));
  }
Enter fullscreen mode Exit fullscreen mode

Here, the string is matched against a regex and a prefix. If it doesn't match, Result.error() is called, and generates an error result. If it does match, Result.ok() is called to generate a successful result.

Declaring the Schema

When declaring fields on the schema where the field should be resolved with the Result pattern described above, the field should resolve to a union type. As an example, this is how this would be declared using GraphQL Nexus:

export const UserResponse = unionType({
  name: 'UserResponse',
  description: 'The type of the possible results from the user query',
  definition(t) {
    t.members(User, GraphQLNotFoundError, GraphQLInvalidIdentifierError);
    t.resolveType((root) => root.__typename);
  },
});
Enter fullscreen mode Exit fullscreen mode

Unfortunately, there doesn't seem to be any way to add a resolver to the union type, so we can pass it a Result type object and let it unpack the object to the underlying value, thus we have to add a method to the Result class which should be called on the Result object returned to a field that should resolve to a union type like this.

A field that resolves to a union type like this in GraphQL Nexus would be declared like this:

  t.field('user', {
    type: UserResponse,
    nullable: false,
    args: {
      id: idArg({ required: true }),
    },
    resolve: (_, { id }, ctx) => ctx.users.getUserById(id).collect(),
  }),
Enter fullscreen mode Exit fullscreen mode

Here the .collect() function call unwraps the AsyncResult<UserDataModel, NotFoundError | InvalidIdentifierError> type object to a Promise<UserDataModel | NotFoundError | InvalidIdentifierError> type object.

Summary

Treating failures as results and declaring them as return options on the field level, makes it apparent and encourages the consumer of your GraphQL API to handle the failures that can be encountered for any given field, and it localizes the data to where it belongs in the result data structure, instead of having it in the errors array on the request-response with a path to where the error originated.

Resources

A demo project utilizing all the techniques described can be found here.

Discussion (0)