How to write GraphQL resolvers effectively

Eddy Nguyen

Resolvers are the fundamental building blocks of a GraphQL server. To build a robust and scalable GraphQL server, we must understand how to write GraphQL resolvers effectively. In this blog post, we will explore:

  • how resolvers work
  • concepts such as resolver map, resolver chain, defer resolve and mappers
  • tools and best practices

Glossary

  • Resolver map: An object containing resolvers that match the types and fields in the GraphQL schema.
  • Resolver chain: The order of resolvers execution when the GraphQL server handles a request.
  • Mapper: The shape of the data returned by a resolver to become the parent parameter of the next resolver in the resolver chain.
  • Defer resolve: A technique to avoid unnecessary resolver execution of a field early in the resolver chain.

What Are Resolvers?

In a GraphQL server, a resolver is a function that “resolves” a value which means doing arbitrary combination of logic to return a value. For example:

  • returning a value statically
  • fetching data from a database or an external API to return a value
  • executing a complex business logic to return a value

Each field in a GraphQL schema has an optional corresponding resolver function. When a client queries a field, the server executes the resolver function to resolve the field.

Given this example schema:

src/graphql/schema.graphql
type Query {
  movie(id: ID!): Movie
}
 
type Movie {
  id: ID!
  name: String!
  actors: [Actor!]!
}
 
type Actor {
  id: ID!
  stageName: String!
}

We can write a resolver map like this:

src/graphql/resolvers.ts
const resolvers = {
  Query: {
    movie: () => {} // `Query.movie` resolver
  },
  Movie: {
    id: () => {}, // `Movie.id` resolver
    name: () => {}, // `Movie.name` resolver
    actors: () => {} // `Movie.actors` resolver
  },
  Actor: {
    id: () => {}, // `Actor.id` resolver
    stageName: () => {} // `Actor.stageName` resolver
  }
}

We will discuss how the code flows through resolvers when the server handles a request in the next section.

Code Flow and Resolver Chain

Using the same schema, we may send a query like this:

query Movie {
  movie(id: "1") {
    id
    name
    actors {
      id
      stageName
    }
  }
}

Once the server receives this query, it starts at Query.movie resolver, and since it returns a nullable Movie object type, two scenarios can happen:

  • If Query.movie resolver returns null or undefined, the code flow stops here, and the server returns movie: null to the client.
  • If Query.movie resolver returns anything else (e.g. objects, class instances, number, non-null falsy values, etc.), the code flow continues. Whatever being returned - usually called mapper - will be the first argument of the Movie resolvers i.e. Movie.id and Movie.name resolvers.

This process repeats itself until a GraphQL scalar field needs to be resolved. The order of the resolvers execution is called the resolver chain. For the example request, the resolver chain may look like this:

💡

There are four positonal arguments of a resolver function:

  • parent: the value returned by the parent resolver.
    • For root-level resolvers like Query.movie, parent is always undefined.
    • For other object-type resolvers like Movie.id and Movie.name, parent is the value returned by parent resolvers like Query.movie
  • args: this is the arguments passed by client operations. In our example query, Query.movie resolver would receive { id: "1" } as args
  • context: An object passed through the resolver chain. It is useful for passing information between resolvers, such as authentication information, database connection, etc.
  • info: An object containing information about the operation, such as operation AST, path to the resolver, etc.

We must return a value that can be handled by the scalars. In our example:

  • Movie.id and Actor.id resolvers must return a non-nullable value that can be coerced into the ID scalar i.e. string or number values.
  • Movie.name and Actor.stageName resolver must return a non-nullable value that can be coerced into the String scalar i.e. string, boolean or number values.
💡

You can learn about GraphQL Scalar, including native Scalar and coercion concept, in this guide here

It is important to remember that we will encounter runtime errors if the resolver returns unexpected values. Some common scenarios are:

  • a resolver returns null to a non-nullable field
  • a resolver returns a value that cannot be coerced into the expected scalar type
  • a resolver returns a non-array value to an array field, such as Movie.actors

How to Implement Resolvers

Implementing Resolvers - Or Not

If an empty resolver map is provided to the GraphQL server, the server still tries to handle incoming requests. However, it will return null for every root-level field in the schema. This means if a root-level field like Query.movie’s return type is non-nullable, we will encounter runtime error.

If object type resolvers are omitted, the server will try to return the property of the same name from parent. Here’s what Movie resolvers may look like if they are omitted:

const resolvers = {
  Movie: {}, // 👈 Empty `Movie` resolvers object,
  Movie: undefined, // 👈 or undefined/omitted `Movie` resolvers object...
 
  // 👇 ...are the equivalent of the following `Movie` resolvers:
  Movie: {
    id: parent => parent.id,
    name: parent => parent.name,
    actors: parent => parent.actors
  }
}

This means if Query.movie resolver returns an object with id, name, and actors properties, we can omit the Movie and Actor resolvers:

const resolvers = {
  Query: {
    movie: () => {
      return {
        id: '1',
        name: 'Harry Potter and the Half-Blood Prince',
        actors: [
          { id: '1', stageName: 'Daniel Radcliffe' },
          { id: '2', stageName: 'Emma Watson' },
          { id: '3', stageName: 'Rupert Grint' }
        ]
      }
    }
  }
  // 💡 `Movie` and `Actor` resolvers are omitted here but it still works,
  // Thanks to default resolvers!
}

In this very simple example where we are returning static values, this will work. However, in a real-world scenario where we may fetch data from a database or an external API, we must consider the response data shape, and the app performance. We will try to simlulate a real-world scenario in the next section.

Implementing Resolvers for Real-World Scenarios

Below is an object this can be used as an in-memory database:

const database = {
  movies: {
    // Movies table
    '1': {
      id: '1',
      movieName: 'Harry Potter and the Half-Blood Prince'
    }
  },
  actors: {
    // Actors table
    '1': {
      id: '1',
      stageName: 'Daniel Radcliffe'
    },
    '2': {
      id: '2',
      stageName: 'Emma Watson'
    },
    '3': {
      id: '3',
      stageName: 'Rupert Grint'
    }
  },
  movies_actors: {
    // Table containing movie-actor relationship
    '1': {
      // Movie ID
      actorIds: ['1', '2', '3']
    }
  }
}

We usually pass database connection through context, so we might update Query.movie to look like this:

const resolvers = {
  Query: {
    movie: (_, { id }, { database }) => {
      const movie = database.movies[id] // 🔄 Database access counter: (1)
 
      if (!movie) {
        return null
      }
 
      return {
        id: movie.id,
        name: movie.movieName,
        actors: (database.movies_actors[id] || []).actorIds // 🔄 (2)
          .map(actorId => {
            return database.actors[actorId] // 🔄 (3), 🔄 (4), 🔄 (5)
          })
      }
    }
  }
}

This works, however, there are a few issues:

  • We are accessing the database 5 times every time Query.movie runs (Counted using 🔄 emoji in the previous code snippet). Even if the client may not request for actors field, this still happens. So, we are making unnecessary database calls. This is called eager resolve.
  • We are mapping movie.movieName in the return statement. This is fine here, but our schema may scale to have multiple fields returning Movie, and we may have to repeat the same mapping logic in multiple resolvers.

To improve this implementation, we can use mappers and defer resolve to avoid unnecessary database calls and reduce code duplication:

type MovieMapper = {
  // 1.
  id: string
  movieName: string
}
type ActorMapper = string // 2.
 
const resolvers = {
  Query: {
    movie: (_, { id }, { database }): MovieMapper => {
      const movie = database.movies[id]
 
      if (!movie) {
        return null
      }
 
      return movie // 3.
    }
  },
  Movie: {
    name: (parent: MovieMapper) => parent.movieName, // 4.
    actors: (parent: MovieMapper, _, { database }): ActorMapper[] =>
      database.movies_actors[parent.id].actorIds // 5.
    // 6.
  },
  Actor: {
    id: (parent: ActorMapper) => parent, // 7.
    stageName: (parent: ActorMapper, _, { database }) => database.actors[parent].stageName // 8.
  }
}
  1. We define a MovieMapper type to represent the shape of the object returned by Query.movie resolver.
  2. Similarly, we define ActorMapper to be returned wherever Actor type is expected. Note that in this example, ActorMapper is a string representing the ID of an actor in this case, instead of an object.
  3. The MovieMapper is returned by Query.movie resolver, and it becomes the parent of Movie resolvers in (4) and (5).
  4. Since MovieMapper has movieName property, but the GraphQL type expects String scalar for name instead. So, we need to return the mapper’s movieName to the schema’s Movie.name field.
  5. MovieMapper doesn’t have actors property, so we must find all the related actors from the database. Here, we expect an array of ActorMapper (i.e. an array of string) to be returned, which is the actorIds array in the database. This just means the parent of each Actor resolver is a string in (7) and (8)
  6. We can skip implementing Movie.id resolver because by default it passes MovieMapper.id property safely.
  7. Because each ActorMapper is the ID of an actor, we return the parent instead of parent.id
  8. Similarly, we use parent as the ID of the actor to fetch the actor’s stageName from the database.
💡

By using mappers and defer resolve techniques, we avoid unnecessary database calls and reduce code duplication. The next time we need to write a resolver that returns Movie, Actor objects, we can simply return the corresponding mapper objects.

Implementing Resolvers with Mappers and Defer Resolve Best Practices

We saw the benefits of using mappers and defer resolve in the previous section. Here are some TypeScript best practices to keep in mind when implementing these techniques, failing to enforce them may result in runtime errors:

  • Mappers MUST be correctly typed as the return type of a resolver and the parent type of the next resolver.
  • Object resolvers MUST be implemented if the expected schema field does not exist in the mapper or, the mapper’s field cannot be coerced into the expected schema type of the same name.

At a small scale, it is easy to keep track of the types and mappers. However, as our schema grows, it becomes harder to maintain the typing and remembering which resolvers to implement. This is where tools like GraphQL Code Generator and Server Preset can be used:

  • GraphQL Code Generator: generates TypeScript types for the resolvers, focusing on correct nullability, array and types.
  • Server Preset: generates and wires up resolvers conveniently, and runs analysis to suggests resolvers that require implementation, reducing the risk of runtime errors.

To get started, install the required packages:

npm i -D @graphql-codegen/cli @eddeee888/gcg-typescript-resolver-files

Next, create a codegen.ts file at the root of your project:

codegen.ts
import { defineConfig } from '@eddeee888/gcg-typescript-resolver-files'
import type { CodegenConfig } from '@graphql-codegen/cli'
 
const config: CodegenConfig = {
  schema: 'src/graphql/schema.graphql',
  generates: {
    'src/graphql': defineConfig({
      resolverGeneration: 'minimal'
    })
  }
}
export default config

Then, add mappers into schema.mappers.ts file, in the same directory as schema.graphql:

src/graphql/schema.mappers.ts
type MovieMapper = {
  id: string
  movieName: string
}
type ActorMapper = string
💡

Server Preset automatically detects and wires up mappers if they follow the convention:

  1. The mappers are declared in a file in the same directory as the schema source file, and has the file name ending with .mappers.ts segment instead of the schema source file’s extension. For example:
    • If your schema file is schema.graphql, the mappers file is schema.mappers.ts.
    • If your schema file is schema.graphql.ts, the mappers file is schema.graphql.mappers.ts.
  2. The mapper type names are in the format of <TypeName>Mapper where <TypeName> is the GraphQL schema name. For example:
    • If the schema type is Movie, then the mapper type is MovieMapper.

Finally, run codegen to generate resolvers:

npm run graphql-codegen

We will see generated resolver files in src/graphql directory:

src/graphql/resolvers/Query/movie.ts
import type { QueryResolvers } from './../../types.generated'
 
export const movie: NonNullable<QueryResolvers['movie']> = async (_parent, _arg, _ctx) => {
  /* Implement Query.movie resolver logic here */
}
src/graphql/resolvers/Movie.ts
import type { MovieResolvers } from './../types.generated'
 
export const Movie: MovieResolvers = {
  /* Implement Movie resolver logic here */
  actors: async (_parent, _arg, _ctx) => {
    /* Movie.actors resolver is required because Movie.actors exists but MovieMapper.actors does not */
  },
  name: async (_parent, _arg, _ctx) => {
    /* Movie.name resolver is required because Movie.name exists but MovieMapper.name does not */
  }
}
src/graphql/resolvers/Actor.ts
import type { ActorResolvers } from './../types.generated'
 
export const Actor: ActorResolvers = {
  /* Implement Actor resolver logic here */
  id: async (_parent, _arg, _ctx) => {
    /* Actor.id resolver is required because Actor.id exists but ActorMapper.id does not */
  },
  stageName: async (_parent, _arg, _ctx) => {
    /* Actor.stageName resolver is required because Actor.stageName exists but ActorMapper.stageName does not */
  }
}

By providing mappers, codegen is smart enough to understand that we want to defer resolve, and we need to write logic for Movie.actors, Movie.name, Actor.id and Actor.stageName resolvers to ensure we don’t encounter runtime errors.

💡

Learn how to set up GraphQL Code Generator and Server Preset for GraphQL Yoga and Apollo Server in this guide here.

Summary

In this article, we have explored how resolvers work in a GraphQL server resolver code flow and resolver chain, and how to write resolvers effectively using mappers and defer resolve techniques. Finally, we add GraphQL Code Generator and Server Preset to automatically generate resolvers and their types to ensure strong type-safety and reduce runtime errors.

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.