How to write GraphQL resolvers effectively
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:
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:
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 returnsnull
orundefined
, the code flow stops here, and the server returnsmovie: 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
andMovie.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 alwaysundefined
. - For other object-type resolvers like
Movie.id
andMovie.name
,parent
is the value returned by parent resolvers likeQuery.movie
- For root-level resolvers like
args
: this is the arguments passed by client operations. In our example query,Query.movie
resolver would receive{ id: "1" }
asargs
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
andActor.id
resolvers must return a non-nullable value that can be coerced into theID
scalar i.e.string
ornumber
values.Movie.name
andActor.stageName
resolver must return a non-nullable value that can be coerced into theString
scalar i.e.string
,boolean
ornumber
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 foractors
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 returningMovie
, 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.
}
}
- We define a
MovieMapper
type to represent the shape of the object returned byQuery.movie
resolver. - Similarly, we define
ActorMapper
to be returned whereverActor
type is expected. Note that in this example,ActorMapper
is astring
representing the ID of an actor in this case, instead of an object. - The
MovieMapper
is returned byQuery.movie
resolver, and it becomes theparent
ofMovie
resolvers in (4) and (5). - Since
MovieMapper
hasmovieName
property, but the GraphQL type expects String scalar forname
instead. So, we need to return the mapper’smovieName
to the schema’sMovie.name
field. MovieMapper
doesn’t haveactors
property, so we must find all the related actors from the database. Here, we expect an array ofActorMapper
(i.e. an array ofstring
) to be returned, which is theactorIds
array in the database. This just means theparent
of eachActor
resolver is a string in (7) and (8)- We can skip implementing
Movie.id
resolver because by default it passesMovieMapper.id
property safely. - Because each
ActorMapper
is the ID of an actor, we return theparent
instead ofparent.id
- Similarly, we use
parent
as the ID of the actor to fetch the actor’sstageName
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:
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
:
type MovieMapper = {
id: string
movieName: string
}
type ActorMapper = string
Server Preset automatically detects and wires up mappers if they follow the convention:
- 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 isschema.mappers.ts
. - If your schema file is
schema.graphql.ts
, the mappers file isschema.graphql.mappers.ts
.
- If your schema file is
- 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 isMovieMapper
.
- If the schema type is
Finally, run codegen to generate resolvers:
npm run graphql-codegen
We will see generated resolver files in src/graphql
directory:
import type { QueryResolvers } from './../../types.generated'
export const movie: NonNullable<QueryResolvers['movie']> = async (_parent, _arg, _ctx) => {
/* Implement Query.movie resolver logic here */
}
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 */
}
}
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.