Guide: GraphQL Yoga / Apollo Server with Server Preset
GraphQL Code Generator’s server preset, @eddeee888/gcg-typescript-resolver-files
, helps GraphQL APIs work at any scale by enforcing best practices such as type-safety and schema module conventions.
Guide
A GraphQL API such as GraphQL Yoga or Apollo Server is the central system where many teams develop their own features without blocking other teams. However, teams may have different standards and practices that can lead to friction. The server preset has features to help solve these issues:
- Type safety: Resolvers are strictly generated and typed to eliminate the chance of unimplemented resolvers.
- Schema module conventions: These conventions make ownership clear at domain and code levels to help teams focus.
Setup
1. Create Schema Modules
The server preset works best when the schema is split into smaller modules. This approach keeps each module small and maintainable. So, instead of one schema file, you can split it into smaller schema modules:
├── src/
│ ├── schema/
│ │ ├── base/
│ │ │ ├── schema.graphql
│ │ ├── user/
│ │ │ ├── schema.graphql
│ │ ├── book/
│ │ │ ├── schema.graphql
Here’s the content of each schema module:
# src/schema/base/schema.graphql
type Query
type Mutation
# src/schema/user/schema.graphql
extend type Query {
user(id: ID!): User
}
type User {
id: ID!
fullName: String!
isAdmin: Boolean!
}
# src/schema/book/schema.graphql
extend type Query {
book(id: ID!): Book
}
extend type Mutation {
markBookAsRead(id: ID!): Book!
}
type Book {
id: ID!
isbn: String!
}
2. Install Server Preset
npm i -D @graphql-codegen/cli @eddeee888/gcg-typescript-resolver-files
3. Configure Codegen Config
Create or update your Codegen config as follows:
import type { CodegenConfig } from '@graphql-codegen/cli'
import { defineConfig } from '@eddeee888/gcg-typescript-resolver-files'
const config: CodegenConfig = {
schema: '**/schema.graphql',
generates: {
'src/schema': defineConfig()
}
}
export default config
Remove existing @graphql-codegen/typescript
and @graphql-codegen/typescript-resolvers
config
The server preset comes with these plugins built-in with stricter defaults. You can override the defaults using the typesPluginsConfig option.
Generate Files
Now, run codegen:
npx graphql-codegen
This results in the following structure:
├── src/
│ ├── schema/
│ │ ├── base/
│ │ │ ├── schema.graphql
│ │ ├── user/
│ │ │ ├── resolvers/
│ │ │ │ ├── Query/
│ │ │ │ │ ├── user.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── User.ts # Generated, changes not overwritten by codegen
│ │ │ ├── schema.graphql
│ │ ├── book/
│ │ │ ├── resolvers/
│ │ │ │ ├── Query/
│ │ │ │ │ ├── book.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── Mutation/
│ │ │ │ │ ├── markBookAsRead.ts # Generated, changes not overwritten by codegen
│ │ │ │ ├── Book.ts # Generated, changes not overwritten by codegen
│ │ │ ├── schema.graphql
│ │ ├── resolvers.generated.ts # Entirely generated by codegen
│ │ ├── typesDefs.generated.ts # Entirely generated by codegen
│ │ ├── types.generated.ts # Entirely generated by codegen
Generated Files Overview
types.generated.ts
: TypeScript types generated by@graphql-codegen/typescript
and@graphql-codegen/typescript-resolvers
typeDefs.generated.ts
: Static TypeScript Schema AST to be used by the serveruser/resolvers/Query/user.ts
,book/resolvers/Query/book.ts
,book/resolvers/Mutation/markBookAsRead.ts
: Typed operation resolvers of each moduleuser/resolvers/User.ts
,book/resolvers/Book.ts
: Typed object type resolvers of each moduleresolvers.generated.ts
: Resolver map that contains all generated operation and object type resolvers
Integration With GraphQL API
We can use generated files in GraphQL API implementation:
import { createYoga, createSchema } from 'graphql-yoga'
import { createServer } from 'http'
import { typeDefs } from './schema/typeDefs.generated'
import { resolvers } from './schema/resolvers.generated'
const yoga = createYoga({ schema: createSchema({ typeDefs, resolvers }) })
const server = createServer(yoga)
server.listen(3000)
Implementing Resolvers
Operation resolvers are generated like this example:
import type { QueryResolvers } from './../../../types.generated'
export const user: NonNullable<QueryResolvers['user']> = async (_parent, _arg, _ctx) => {
/* Implement Query.user resolver logic here */
}
Generated operation resolvers always fail TypeScript check without implementation.
This is intentional because it eliminates unimplemented resolvers at runtime.
Object type resolvers are generated like this example:
import type { UserResolvers } from './../../types.generated'
export const User: UserResolvers = {
/* Implement User resolver logic here */
}
All operation and object type resolvers are automatically put into the generated resolver map:
/* This file was automatically generated. DO NOT UPDATE MANUALLY. */
import type { Resolvers } from './types.generated'
import { book as Query_book } from './book/resolvers/Query/book'
import { markBookAsRead as Mutation_markBookAsRead } from './book/resolvers/Mutation/markBookAsRead'
import { Book } from './book/resolvers/Book'
import { user as Query_user } from './user/resolvers/Query/user'
import { User } from './user/resolvers/User'
export const resolvers: Resolvers = {
Query: {
book: Query_book,
user: Query_user
},
Mutation: {
markBookAsRead: Mutation_markBookAsRead
},
Book: Book,
User: User
}
The server preset handles all resolver types and imports. So, you only need to implement your resolver logic.
Conventions to Support Schema Modules
The server preset has module-centric conventions
This means instead of updating the codegen.ts
config file, you make changes in each module. This keeps the GraphQL API maintainable at any scale.
Read more about this concept on our blog: Scalable APIs with GraphQL Server Codegen Preset
All conventions are customizable
Check out the documentation for more options.
Adding Custom GraphQL Scalars
GraphQL does not have a lot of scalars by default. Luckily, graphql-scalars has an extensive list of scalars.
The server preset automatically uses scalar implementation from graphql-scalars
if it finds a matching name.
First, install graphql-scalars
:
npm i graphql-scalars
Then, add a scalar to your schema:
type Query
type Mutation
# https://github.com/Urigo/graphql-scalars/blob/master/src/scalars/iso-date/DateTime.ts
scalar DateTime
Running codegen automatically imports the scalar implementation into the resolver map:
import type { Resolvers } from './types.generated'
import { book as Query_book } from './book/resolvers/Query/book'
import { markBookAsRead as Mutation_markBookAsRead } from './book/resolvers/Mutation/markBookAsRead'
import { Book } from './book/resolvers/Book'
import { user as Query_user } from './user/resolvers/Query/user'
import { User } from './user/resolvers/User'
import { DateTimeResolver } from 'graphql-scalars'
export const resolvers: Resolvers = {
Query: {
book: Query_book,
user: Query_user
},
Mutation: {
markBookAsRead: Mutation_markBookAsRead
},
Book: Book,
User: User,
DateTime: DateTimeResolver
}
Furthermore, the type is updated to use the recommended type from graphql-scalars
:
// ... other generated types
export type Scalars = {
ID: { input: string; output: string | number }
String: { input: string; output: string }
Boolean: { input: boolean; output: boolean }
Int: { input: number; output: number }
Float: { input: number; output: number }
DateTime: { input: Date | string; output: Date | string } // Type comes from graphql-scalars
}
// ... other generated types
The type of any custom scalar is any
by default. Without the server preset, you have to configure the DateTime
type by manually updating codegen.ts
.
Adding Mappers To Chain Resolvers
By default, the generated types make resolvers return objects that match the schema types. However, this means we must handle all field mapping in the root-level resolvers.
This is where we can use mappers to enable resolver chaining. When a mapper is used this way, it can be returned in one resolver, and become the parent
argument in the next resolver in the chain.
With the server preset, you can add mappers by exporting interfaces or types with Mapper
suffix from *.mappers.ts
files in appropriate modules:
export interface UserMapper {
id: string
firstName: string
lastName: string
isAdmin: 'YES' | 'NO'
}
Running codegen again does a few things:
- automatically imports and uses this mapper in schema types
// ... other imports
import { UserMapper } from './user/schema.mappers'
export type ResolversTypes = {
// ... other types
User: ResolverTypeWrapper<UserMapper>
}
export type ResolversParentTypes = {
// ... other types
User: UserMapper
}
- automatically compares schema type and mapper type to create required field resolvers
import type { UserResolvers } from './../../types.generated'
export const User: UserResolvers = {
fullName: async (_parent, _arg, _ctx) => {
/* User.fullName resolver is required because User.fullName exists but UserMapper.fullName does not */
},
isAdmin: ({ isAdmin }, _arg, _ctx) => {
/* User.isAdmin resolver is required because User.isAdmin and UserMapper.isAdmin are not compatible */
return isAdmin
}
}
You can now update your resolvers to use the mapper interface:
// src/schema/user/resolvers/Query/user.ts
import type { QueryResolvers } from './../../../types.generated'
export const user: NonNullable<QueryResolvers['user']> = async (_parent, _arg, _ctx) => {
return { id: '001', firstName: 'Bart', lastName: 'Simpson', isAdmin: 'YES' }
}
// src/schema/user/resolvers/User.ts
import type { UserResolvers } from './../../types.generated'
export const User: UserResolvers = {
fullName: ({ firstName, lastName }) => `${firstName} ${lastName}`
isAdmin: ({ isAdmin }) => isAdmin === 'YES'
}