GraphQL: Code-First & Schema-First
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), @nestjs/graphql 14 (installed 14.0.3) with @nestjs/apollo 14 (14.0.3, Apollo Server 5 as @apollo/server 5.5.1) and @nestjs/mercurius 14 (14.0.3, Mercurius 16.10.1), graphql 16 (16.14.2; graphql 17 is current on npm but is not what @nestjs/graphql 14 resolves), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, as of 2026-10-02. The examples use projects generated by the Nest 12 CLI (ESM layout, Vitest, supertest); the SDL and the JSON responses shown are the ones that code produces.
GraphQL itself (queries, fields, the type system, resolvers as a concept, schema design, security, caching, pagination) is covered once, framework-neutrally, in the GraphQL section; see in particular Schema & Type System, Execution & Resolvers and Interfaces, Unions & Inputs. This page covers only the NestJS integration: how GraphQLModule is configured, how TypeScript classes and decorators become a schema (code first), how SDL files become typed resolvers (schema first), and what the Nest-specific features (drivers, scalars, directives, extensions, mapped types, the CLI plugin, schematics) look like. Subscriptions, guards and error formatting in GraphQL, field middleware in depth, Apollo plugins and query complexity are on GraphQL: Subscriptions, Security & Plugins; Federation is on GraphQL: Federation.
What the integration is made of
NestJS does not implement GraphQL itself. @nestjs/graphql is a thin layer over the graphql reference implementation that reads decorator metadata (or SDL files) and builds an executable schema, and a driver package hands that schema to a GraphQL HTTP server. Two drivers are official:
| Driver | Package and server | HTTP platform |
|---|---|---|
|
|
Express ( |
|
|
Fastify only |
Install the set that matches your platform:
# Express and Apollo (default)
npm i @nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/express5 graphql
# Fastify and Apollo
npm i @nestjs/graphql @nestjs/apollo @apollo/server @as-integrations/fastify graphql
# Fastify and Mercurius
npm i @nestjs/graphql @nestjs/mercurius graphql mercurius
@nestjs/apollo 14 requires Apollo Server 5 (the @apollo/server package). The schema-first typings generator additionally needs ts-morph, an optional peer dependency of @nestjs/graphql that is only used at build time; it is covered in Schema first: typePaths and generated typings.
The driver option is mandatory. GraphQLModule.forRoot() throws as soon as it is called without one:
Missing "driver" option. In the latest version of "@nestjs/graphql" package (v10) a new required
configuration property called "driver" has been introduced. ...
Source: NestJS documentation: GraphQL quick start, "Getting started with GraphQL & TypeScript" (the message is the one the package throws).
The smallest working configuration is therefore:
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true, // or typePaths for schema first, see below
}),
],
})
export class AppModule {}
The generic argument selects the options type of the driver (ApolloDriverConfig or MercuriusDriverConfig), so the object you pass is type-checked against the options of the underlying server. forRoot() passes the rest of the options object through to the Apollo Server (or Mercurius) instance, which is how settings such as plugins, introspection, formatError or csrfPrevention reach the server. Without either autoSchemaFile or typePaths the module has no schema to build and fails at start-up with Query root type must be provided.
Where GraphQL sits in the Nest request lifecycle
With the Apollo driver, GraphQLModule registers an Apollo Server route (default path /graphql) on the underlying Express or Fastify instance when the application initialises; with Mercurius it registers the Mercurius Fastify plugin. From that point:
-
HTTP-level concerns run first and are unchanged: middleware bound with
forRoutes('graphql')runs before the GraphQL operation (it sees eachPOSTto the endpoint), as do CORS and body parsing. Nest’s HTTP route handlers are not involved at all, because the GraphQL endpoint is a route owned by the driver, not a controller. -
Each resolver method is a Nest handler. For every top-level field of an operation Nest runs the usual enhancers around the method: guards, interceptors, pipes (a global
ValidationPipevalidates@Args()classes and@InputType()classes, as the example below shows) and exception filters, in the order described on Request Lifecycle. TheExecutionContextis a GraphQL context; useGqlExecutionContext.create(context)to reach theroot,args,contextandinfoobjects. -
@ResolveField()methods are different. By default the driver setsfieldResolverEnhancersto an empty list, so guards, interceptors and filters bound to a resolver class run for its@Query()and@Mutation()methods but not for its field resolvers. Opt in withfieldResolverEnhancers: ['guards'](or'interceptors','filters').
The next listing records which handlers a class-level guard saw for the query { profile { nick badge } }, where badge is a @ResolveField(), with the default option and with fieldResolverEnhancers: ['guards']:
// default: ["Query.profile"]
// fieldResolverEnhancers: ['guards']: ["Query.profile","Profile.badge"]
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
fieldResolverEnhancers: ['guards'],
});
Source: NestJS documentation: GraphQL other features, "Execute enhancers at the field resolver level" (the option is documented there; the two arrays above are the result of a class-level guard).
The result of a mutation or query is also not an HTTP status. Apollo Server answers execution errors with HTTP 200 and an errors array, and the Nest driver additionally resets any status an exception would have set (preserveHttpStatusForExecutionErrors, enabled by default in ApolloDriverConfig), so a BadRequestException thrown by a pipe still comes back as 200 with extensions.code set to BAD_REQUEST. Request-level failures (a query that does not parse or validate) keep their 4xx status.
Code first or schema first
Nest offers two ways to produce the schema, and the choice is made by one option:
| Code first | Schema first | |
|---|---|---|
Source of truth |
TypeScript classes with decorators |
SDL ( |
Enabled by |
|
|
Types in TypeScript |
the decorated classes themselves |
generated by |
Resolver decorators |
|
|
Unions and interfaces |
|
|
Features that exist only here |
mapped types, CLI plugin, |
|
Both approaches build the same kind of executable schema, the same driver serves it, and the same guards, interceptors and pipes apply. The choice is about where you want to author types. Code first suits teams that live in TypeScript and want one definition of each type; schema first suits teams that design the API in SDL first (or share the SDL with other languages) and want typed resolvers generated from it.
Code first: the module configuration
In the code-first approach add autoSchemaFile to the options. It is either a path, in which case the generated SDL is written there when the application starts (the file begins with a "THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)" banner), or true, which keeps the schema in memory only:
// 1. write the SDL to disk (committed to the repository, useful for review and codegen)
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
});
// 2. in memory only
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
});
// 3. sort the types, fields and arguments lexicographically (stable diffs of the SDL file)
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
});
By default the types in the generated schema follow the order of the included modules; sortSchema: true sorts them, which is why the SDL printed below is alphabetical.
Schema first: typePaths and generated typings
In the schema-first approach point the module at your SDL files with typePaths. The files are merged in memory, so you can keep one .graphql file per domain entity next to its resolvers:
directive @upper on FIELD_DEFINITION
scalar Date
enum BookStatus {
DRAFT
PUBLISHED
OUT_OF_PRINT
}
interface Node {
id: ID!
}
type Author implements Node {
id: ID!
name: String!
books: [Book!]!
}
type Book implements Node {
id: ID!
title: String! @upper
status: BookStatus!
publishedAt: Date!
author: Author!
}
union SearchResult = Author | Book
input CreateBookInput {
title: String!
authorId: ID!
}
type Query {
books(status: BookStatus, limit: Int = 10): [Book!]!
book(id: ID!): Book
search(term: String!): [SearchResult!]!
}
type Mutation {
createBook(input: CreateBookInput!): Book!
}
Source: NestJS documentation: GraphQL quick start, "Schema first" (the schema is the Bookshelf catalogue used throughout this page).
Hand-writing a TypeScript class for every SDL type is redundant, so @nestjs/graphql can generate the typings from the SDL abstract syntax tree. There are two ways to trigger it.
The first is the definitions option of the module, which regenerates the file every time the application starts:
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
typePaths: ['./**/*.graphql'],
definitions: {
path: join(process.cwd(), 'src/graphql.ts'),
outputAs: 'class', // interfaces are the default
},
});
The second is a stand-alone script that you run on demand (for example from an npm script or in CI), which also exposes the options that the module-level definitions object forwards:
import { GraphQLDefinitionsFactory } from '@nestjs/graphql';
import { join } from 'node:path';
const definitionsFactory = new GraphQLDefinitionsFactory();
await definitionsFactory.generate({
typePaths: ['./src/graphql-schema-first/*.graphql'],
path: join(process.cwd(), 'src/graphql-schema-first/graphql.ts'),
outputAs: 'class',
emitTypenameField: true,
defaultScalarType: 'unknown',
customScalarTypeMapping: { Date: 'Date' },
});
Run with node generate-typings.ts (Node 24 strips the types itself; on older versions compile the script with tsc first, or use ts-node), this prints The definitions have been updated. and writes the file below. The generator needs the ts-morph package: without it the script fails with Cannot find package 'ts-morph', so add it as a dev dependency of any project that generates typings.
export enum BookStatus {
DRAFT = "DRAFT",
PUBLISHED = "PUBLISHED",
OUT_OF_PRINT = "OUT_OF_PRINT"
}
export class CreateBookInput {
title: string;
authorId: string;
}
export interface Node {
id: string;
}
export class Book implements Node {
__typename?: 'Book';
id: string;
title: string;
status: BookStatus;
publishedAt: Date;
author: Author;
}
export abstract class IQuery {
__typename?: 'IQuery';
abstract books(status?: Nullable<BookStatus>, limit?: Nullable<number>): Book[] | Promise<Book[]>;
abstract book(id: string): Nullable<Book> | Promise<Nullable<Book>>;
abstract search(term: string): SearchResult[] | Promise<SearchResult[]>;
}
export type SearchResult = Author | Book;
type Nullable<T> = T | null;
Source: NestJS documentation: GraphQL quick start, "Schema first" and Resolvers, "Generating types".
The options of the generator are:
| Option | Effect |
|---|---|
|
where to read the SDL and where to write the TypeScript |
|
|
|
on |
|
add an optional |
|
generate queries, mutations and subscriptions as plain fields without arguments |
|
generate enums as TypeScript union types instead of enums |
|
TypeScript type for custom scalars that are not in the mapping ( |
|
map each custom scalar to a TypeScript type name (or type reference such as |
|
extra text, usually imports, put at the top of the file |
Do not edit the generated file: decorators added to it are overwritten on the next run. To add validation, extend the generated class in a separate file (export class CreateBookDto extends CreateBookInput { @MinLength(3) title: string }), and register ValidationPipe as shown in Resolvers: @Resolver, @Query, @Args, @ResolveField, @Parent.
The running example: the Bookshelf catalogue
The code-first examples build the GraphQL side of the Bookshelf: authors, the books they wrote, search over both, and mutations to create and update books. All files are small and are shown in the order Nest needs them. They live in one BookshelfModule that provides an in-memory BookshelfStore (a stand-in for a repository that also counts author look-ups, so the DataLoader section can show batching) and the resolvers.
Object types and @Field()
An object type is a class annotated with @ObjectType(); each exposed property is annotated with @Field(). The Book below also uses a custom scalar, an enum, a directive and an extension, all introduced in later sections:
import { Directive, Extensions, Field, Int, ObjectType } from '@nestjs/graphql';
import { GraphQLISBN } from 'graphql-scalars';
import { Author } from './author.model.js';
import { BookStatus } from './book-status.enum.js';
import { checkRoleMiddleware } from '../check-role.middleware.js';
import { Node } from './node.interface.js';
@ObjectType({ implements: () => [Node] })
export class Book implements Node {
id: string;
@Directive('@upper')
@Field({ description: 'Title as printed on the cover.' })
title: string;
@Field(() => GraphQLISBN, { nullable: true })
isbn?: string;
@Field(() => Int, { nullable: true })
pages?: number;
@Field(() => BookStatus)
status: BookStatus;
@Field()
publishedAt: Date;
@Extensions({ role: 'editor' })
@Field({ nullable: true, description: 'Editorial notes, editors only.', middleware: [checkRoleMiddleware] })
internalNotes?: string;
// not a @Field(): the BooksResolver resolves it with @ResolveField()
authorId: string;
// resolved by BooksResolver; the type only exists for TypeScript
author?: Author;
}
import { Field, ObjectType } from '@nestjs/graphql';
import { Book } from './book.model.js';
import { Node } from './node.interface.js';
@ObjectType({ implements: () => [Node], description: 'Someone who wrote at least one book.' })
export class Author implements Node {
id: string;
@Field()
name: string;
@Field(() => [Book])
books: Book[];
}
Properties without @Field() (id, authorId, author) are not part of the schema. id comes from the Node interface that both classes implement (see Interfaces), and author is added to Book by a field resolver. The generated SDL for the whole module is at the end of the page.
TypeScript’s metadata reflection cannot tell the schema three things, which is why @Field() takes a type function and options:
-
Number type.
stringandbooleanmap toStringandBooleanwithout a type function, butnumbercould beIntorFloat. Without a type function it becomesFloat(see theratiofield in Scalars); write@Field(() ⇒ Int)for integers. -
Nullability. Every field is non-nullable by default;
{ nullable: true }makes the field nullable. For arrays,nullable: 'items'makes the items nullable and'itemsAndList'makes both the list and its items nullable. A nullable field must still be guarded in the resolver, because anullcan reach it. -
Arrays. The type function must spell the array:
@Field(() ⇒ [Book]). The bracket nesting is the depth (is a matrix).
@Field() options are nullable, description, deprecationReason, defaultValue (for input and args types), complexity and middleware (both are used in the next GraphQL page). @ObjectType() takes a name, a description, implements, isAbstract (no SDL type is emitted, the class only provides fields to subclasses) and registerIn (see Several GraphQL endpoints in one application).
Resolvers: @Resolver, @Query, @Args, @ResolveField, @Parent
A resolver is an ordinary provider annotated with @Resolver(). In code first, @Resolver(() ⇒ Book) names the parent type that the field resolvers of the class belong to. @Query() and @Mutation() methods add fields to the root Query and Mutation types; @ResolveField() adds or overrides a field of the parent type; @Parent() injects the object being resolved (the same thing as @Root()).
import { Args, Context, ID, Int, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { NotFoundException } from '@nestjs/common';
import { BooksArgs } from './dto/books.args.js';
import { CreateBookInput } from './dto/create-book.input.js';
import { UpdateBookInput } from './dto/update-book.input.js';
import type { GqlContext } from './loaders.js';
import { Author } from './models/author.model.js';
import { Book } from './models/book.model.js';
import { SearchResult } from './models/search-result.union.js';
import { BookshelfStore } from './store.js';
@Resolver(() => Book)
export class BooksResolver {
constructor(private readonly store: BookshelfStore) {}
@Query(() => [Book], { description: 'A page of books, newest catalogue order.' })
books(@Args() args: BooksArgs): Book[] {
return this.store.findBooks(args.offset, args.limit, args.status, args.titleContains);
}
@Query(() => Book, { name: 'book', nullable: true })
getBook(@Args('id', { type: () => ID }) id: string): Book | undefined {
return this.store.findBook(id);
}
@Query(() => [SearchResult])
search(@Args('term') term: string): Array<typeof SearchResult> {
const needle = term.toLowerCase();
const books = this.store.books.filter((b) => b.title.toLowerCase().includes(needle));
const authors = this.store.authors.filter((a) => a.name.toLowerCase().includes(needle));
return [...books, ...authors];
}
@Mutation(() => Book)
createBook(@Args('input') input: CreateBookInput): Book {
return this.store.create(input);
}
@Mutation(() => Book)
updateBook(@Args('id', { type: () => ID }) id: string, @Args('input') input: UpdateBookInput): Book {
const updated = this.store.update(id, input);
if (!updated) throw new NotFoundException(`Book ${id} does not exist`);
return updated;
}
@ResolveField(() => Author)
author(@Parent() book: Book, @Context() ctx: GqlContext): Promise<Author | undefined> {
return ctx.loaders.authorById.load(book.authorId);
}
@ResolveField(() => Int, { description: 'How many years ago the book came out.' })
ageInYears(@Parent() book: Book): number {
return new Date('2026-10-02T00:00:00Z').getUTCFullYear() - book.publishedAt.getUTCFullYear();
}
}
import { Args, ID, Query, ResolveField, Resolver, Parent } from '@nestjs/graphql';
import { Author } from './models/author.model.js';
import { Book } from './models/book.model.js';
import { BookshelfStore } from './store.js';
@Resolver(() => Author)
export class AuthorsResolver {
constructor(private readonly store: BookshelfStore) {}
@Query(() => Author, { nullable: true })
author(@Args('id', { type: () => ID }) id: string): Author | undefined {
return this.store.findAuthor(id);
}
@ResolveField(() => [Book])
books(@Parent() author: Author): Book[] {
return this.store.booksOf(author.id);
}
}
The resolver classes are plain providers: list them in the providers of a module and import that module. If a resolver is not provided anywhere, its queries and fields silently do not exist in the schema, which is the most common reason for a Cannot query field error.
@Module({
providers: [BookshelfStore, BooksResolver, AuthorsResolver, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true }) }],
exports: [BookshelfStore],
})
export class BookshelfModule {}
@Module({
imports: [
BookshelfModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [BookshelfModule],
inject: [BookshelfStore],
useFactory: (store: BookshelfStore) => ({
autoSchemaFile: true, // build the schema in memory; pass a path to also write it to disk
sortSchema: true,
// one set of DataLoaders per request, next to the Express request object
context: ({ req }: { req: unknown }) => ({ req, loaders: createLoaders(store) }),
transformSchema: (schema) => upperDirectiveTransformer(schema, 'upper'),
buildSchemaOptions: {
directives: [new GraphQLDirective({ name: 'upper', locations: [DirectiveLocation.FIELD_DEFINITION] })],
},
}),
}),
],
})
export class AppModule {}
Source: NestJS documentation: GraphQL resolvers, "Module" and GraphQL quick start, "Async configuration".
Query and mutation names and options
By default the field name is the method name. Decouple them with name: @Query(() ⇒ Book, { name: 'book' }) on a method called getBook() generates book(id: ID!): Book. For @ResolveField() the first argument is the field name (@ResolveField('posts', () ⇒ [Post])). The options object of @Query() and @Mutation() accepts name, description, deprecationReason and nullable (boolean, 'items' or 'itemsAndList').
@Args() and @ArgsType()
@Args('id') extracts one argument by name, as @Param() does in a controller. Because number is ambiguous you must give the type: @Args('id', { type: () ⇒ Int }) id: number. The options object accepts type, defaultValue, description, deprecationReason and nullable. Several @Args() parameters on one method give several arguments, and @Args({ name: 'postId', type: () ⇒ Int }) is the object form of the same thing.
Inline arguments get long quickly, so group them in an arguments class with @ArgsType() and receive it with a bare @Args(). Classes can inherit, which is how shared pagination arguments are written:
@ArgsType()
export class PaginationArgs {
@Min(0)
@Field(() => Int)
offset = 0;
@Min(1)
@Max(50)
@Field(() => Int)
limit = 10;
}
@ArgsType()
export class BooksArgs extends PaginationArgs {
@Field(() => BookStatus, { nullable: true })
status?: BookStatus;
@Field({ defaultValue: '' })
titleContains: string;
}
The class generates the arguments with their defaults (visible in the SDL at the end of the page: books(limit: Int! = 10, offset: Int! = 0, status: BookStatus, titleContains: String! = ""): [Book!]!). Because the module registers a global ValidationPipe, the @Min() and @Max() decorators run on the args class; a query that violates them returns a GraphQL error and not data:
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ books(limit: 500) { id } }"}'
{"errors":[{"message":"Bad Request Exception","locations":[{"line":1,"column":3}],"path":["books"],"extensions":{"code":"BAD_REQUEST","originalError":{"message":["limit must not be greater than 50"],"error":"Bad Request","statusCode":400}}}],"data":null}
A successful call shows the defaults, the computed ageInYears field resolver and the @upper directive from Directives (the data is the in-memory catalogue):
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ books { id title status ageInYears publishedAt } }"}'
{"data":{"books":[{"id":"b1","title":"GRAPHS IN PRACTICE","status":"PUBLISHED","ageInYears":2,"publishedAt":"2024-03-01T00:00:00.000Z"},{"id":"b2","title":"TYPED APIS","status":"PUBLISHED","ageInYears":1,"publishedAt":"2025-06-15T00:00:00.000Z"},{"id":"b3","title":"SCHEMAS FIRST","status":"DRAFT","ageInYears":0,"publishedAt":"2026-01-10T00:00:00.000Z"},{"id":"b4","title":"OLD RESOLVERS","status":"OUT_OF_PRINT","ageInYears":14,"publishedAt":"2012-09-30T00:00:00.000Z"}]}}
The GraphQL argument decorators
Besides @Args() there are decorators for the other resolver parameters of GraphQL (parent, args, context, info):
| Nest decorator | GraphQL resolver argument |
|---|---|
|
|
|
|
|
|
|
|
root is the result of the parent field (or the server’s rootValue for a top-level field), context is shared by all resolvers of one operation, and info describes the execution state. The interface section uses @Info(), and the DataLoader section uses @Context().
Mutations, @InputType() and mapped types
A mutation that takes an object argument takes an input type, declared with @InputType(). Input classes can carry class-validator decorators, which the ValidationPipe enforces just as it does for REST DTOs (see Pipes & Validation):
@InputType()
export class CreateBookInput {
@Length(3, 120)
@Field()
title: string;
@Field(() => ID)
authorId: string;
@Min(1)
@IsOptional()
@Field(() => Int, { nullable: true })
pages?: number;
}
The mapped-type helpers (PartialType(), PickType(), OmitType(), IntersectionType()) derive one input from another so the create and update variants do not drift apart. They are imported from @nestjs/graphql (not @nestjs/mapped-types, and not @nestjs/swagger) and copy the @Field() and validation metadata:
import { InputType, OmitType, PartialType } from '@nestjs/graphql';
import { CreateBookInput } from './create-book.input.js';
@InputType()
export class UpdateBookInput extends PartialType(OmitType(CreateBookInput, ['authorId'] as const)) {}
The generated SDL is input UpdateBookInput { pages: Int title: String }: every field optional, authorId removed. Sending the removed field is rejected by GraphQL validation before any resolver runs:
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"mutation { updateBook(id: \"b5\", input: { authorId: \"a2\" }) { id } }"}'
{"errors":[{"message":"Field \"authorId\" is not defined by type \"UpdateBookInput\".","locations":[{"line":1,"column":42}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
The helpers compose (PartialType(OmitType(…)) above), and their behaviour is controlled like this:
-
PartialType(Parent, options): the second argument is a decorator factory (InputType) when the parent is of another kind (for example an@ObjectType()class turned into an input:PartialType(User, InputType)), or an options object withdecorator,omitDefaultValues(drop inherited default values) andskipNullProperties(defaulttrue; setfalseto skip validation only forundefined, and still validatenull). -
PickType(Parent, ['email'] as const)keeps only the named fields,OmitType(Parent, ['email'] as const)removes them, andIntersectionType(A, B)merges two classes. Always write the key array withas const.
For a create mutation with an invalid title the pipe answers like the earlier example ("title must be longer than or equal to 3 characters").
Mapped types are code-first only. In schema first you write the variants in SDL, and extend the generated classes for validation (see Schema first: typePaths and generated typings). The mapped types for OpenAPI DTOs are a separate set in @nestjs/swagger; see OpenAPI & Swagger.
Enums
An enum is a TypeScript enum registered with registerEnumType(). Registration supplies the GraphQL name, a description, and a valuesMap for per-value descriptions and deprecation:
import { registerEnumType } from '@nestjs/graphql';
export enum BookStatus {
DRAFT = 'DRAFT',
PUBLISHED = 'PUBLISHED',
OUT_OF_PRINT = 'OUT_OF_PRINT',
}
registerEnumType(BookStatus, {
name: 'BookStatus',
description: 'Where a book is in its life cycle.',
valuesMap: {
DRAFT: { description: 'Not visible to readers yet.' },
OUT_OF_PRINT: { deprecationReason: 'Use a status filter on the catalogue instead.' },
},
});
Use it with @Field(() ⇒ BookStatus) (as Book.status and BooksArgs.status do). An introspection query run through GraphQLSchemaHost (see Generating the SDL and reaching the schema) confirms the metadata:
{"data":{"__type":{"enumValues":[{"name":"DRAFT","description":"Not visible to readers yet.","deprecationReason":null},{"name":"OUT_OF_PRINT","description":null,"deprecationReason":"Use a status filter on the catalogue instead."},{"name":"PUBLISHED","description":null,"deprecationReason":null}]}}}
Filtering by an enum value with a variable works as expected:
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"query($s: BookStatus) { books(status: $s) { id } }","variables":{"s":"DRAFT"}}'
{"data":{"books":[{"id":"b3"}]}}
In schema first the enum is declared in SDL; the generated TypeScript enum uses the names as string values. If the backend uses a different internal value than the public name, pass a resolvers entry for the enum (shown in Schema first: resolvers).
Interfaces
A GraphQL interface is an abstract class annotated with @InterfaceType(); TypeScript interface types cannot define GraphQL interfaces. Object types implement it with the implements option, and the interface’s fields are added to them (which is why Book.id needs no @Field() above):
import { Field, ID, InterfaceType } from '@nestjs/graphql';
@InterfaceType()
export abstract class Node {
@Field(() => ID)
id: string;
}
The generated resolveType() picks the concrete type from the class of the returned value, so you must return class instances (not plain object literals) unless you supply your own resolveType. The Bookshelf store builds Book and Author instances for that reason. A custom resolveType works on plain objects, and an interface resolver shares a field resolver implementation across every type that implements the interface:
@InterfaceType({
resolveType(book: { colors?: string[] }) {
return book.colors ? ColoringBook : TextBook;
},
})
export abstract class Edition {
@Field(() => ID)
id: string;
@Field()
title: string;
}
@ObjectType({ implements: () => [Edition] })
export class TextBook implements Edition {
id: string;
title: string;
}
@ObjectType({ implements: () => [Edition] })
export class ColoringBook implements Edition {
id: string;
title: string;
@Field(() => [String])
colors: string[];
}
@Resolver(() => Edition)
export class EditionInterfaceResolver {
@ResolveField(() => [Edition])
related(
@Parent() edition: Edition,
@Info() info: { parentType: { name: string } },
@Args('search', { type: () => String }) term: string,
) {
return [{ id: 'x1', title: `${term} (related to ${edition.id}, seen as ${info.parentType.name})` }];
}
}
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
inheritResolversFromInterfaces: true, // required for the interface resolver
});
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ editions { __typename id title related(search: \"x\") { title } } }"}'
{"data":{"editions":[{"__typename":"TextBook","id":"e1","title":"Plain text","related":[{"title":"x (related to e1, seen as TextBook)"}]},{"__typename":"ColoringBook","id":"e2","title":"Colour me","related":[{"title":"x (related to e2, seen as ColoringBook)"}]}]}}
related is declared once, on the interface, and runs for both implementing types; @Info() shows which concrete type is being resolved.
Unions
A union is created with createUnionType() from the classes that make it up. Add as const to the types array: without it TypeScript emits a wrong declaration file and using the union from another project fails:
import { createUnionType } from '@nestjs/graphql';
import { Author } from './author.model.js';
import { Book } from './book.model.js';
export const SearchResult = createUnionType({
name: 'SearchResult',
types: () => [Book, Author] as const,
});
The search query above returns Array<typeof SearchResult>. A client selects fields per member with inline fragments:
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ search(term: \"a\") { __typename ... on Book { title } ... on Author { name } } }"}'
{"data":{"search":[{"__typename":"Book","title":"GRAPHS IN PRACTICE"},{"__typename":"Book","title":"TYPED APIS"},{"__typename":"Book","title":"SCHEMAS FIRST"},{"__typename":"Author","name":"Ada Park"},{"__typename":"Author","name":"Grace Lindqvist"},{"__typename":"Author","name":"Linus Okafor"}]}}
As with interfaces, the default resolveType() needs class instances. Returning a plain object for a union member makes the field fail at run time:
{"errors":[{"message":"Return type for \"Work\" cannot be resolved. If you did not pass a custom implementation (the \"resolveType\" function), you must return an instance of a class instead of a plain JavaScript object.","locations":[{"line":1,"column":3}],"path":["works",0],"extensions":{"code":"INTERNAL_SERVER_ERROR"}}],"data":null}
Supply a resolveType in the options when the values are plain objects:
export const Hit = createUnionType({
name: 'Hit',
types: () => [TextBook, ColoringBook] as const,
resolveType(value) {
return 'colors' in value ? ColoringBook : TextBook;
},
});
Scalars
Code first ships five scalars: ID, Int, Float (the three built-ins), GraphQLISODateTime (the default for Date, rendered as DateTime in the SDL) and GraphQLTimestamp (milliseconds since the epoch). Two options of buildSchemaOptions change the defaults: dateScalarMode: 'timestamp' maps Date to Timestamp, and numberScalarMode: 'integer' maps plain number to Int instead of Float.
Custom scalars come in three forms. The first overrides a built-in scalar with a class decorated with @Scalar() and registered as a provider; here Date is serialised as epoch milliseconds:
@Scalar('Date', () => Date)
export class DateScalar implements CustomScalar<number, Date> {
description = 'Date custom scalar type';
parseValue(value: unknown): Date {
return new Date(value as number); // value from the client (variables)
}
serialize(value: unknown): number {
return (value as Date).getTime(); // value sent to the client
}
parseLiteral(ast: ValueNode): Date | null {
return ast.kind === Kind.INT ? new Date(parseInt(ast.value, 10)) : null;
}
}
@Module({ providers: [EventsResolver, DateScalar] })
export class ScalarsOverrideModule {}
The second is a stand-alone GraphQLScalarType, used directly in the type function:
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function validate(uuid: unknown): string | never {
if (typeof uuid !== 'string' || !regex.test(uuid)) {
throw new Error('invalid uuid');
}
return uuid;
}
export const CustomUuidScalar = new GraphQLScalarType({
name: 'UUID',
description: 'A simple UUID parser',
serialize: (value) => validate(value),
parseValue: (value) => validate(value),
parseLiteral: (ast) => validate(ast.kind === Kind.STRING ? ast.value : undefined),
});
The third imports a ready-made scalar. graphql-scalars has dozens (JSON, ISBN, EmailAddress, URL, …). The official page demonstrates the graphql-type-json package and passes the scalar to forRoot({ resolvers: { JSON: GraphQLJSON } }); in code first, using the scalar object in the @Field() type function is enough:
@ObjectType()
export class Event {
@Field(() => CustomUuidScalar)
id: string;
@Field()
at: Date;
@Field(() => GraphQLJSON)
meta: Record<string, unknown>;
@Field()
ratio: number;
@Field(() => Int)
count: number;
}
Source: NestJS documentation: GraphQL scalars, "Code first", "Override a default scalar", "Import a custom scalar" and "Create a custom scalar".
The three configurations of the same Event type produce these SDL fields and responses:
| Configuration | SDL fields | Response for { event { id at meta ratio count } } |
|---|---|---|
defaults |
|
|
|
|
error |
|
|
|
The middle row is the pitfall of numberScalarMode: 'integer': it changes every undecorated number, so any field that holds a fraction fails at run time.
In schema first, declare scalar Date in the SDL and register either a @Scalar('Date') provider (the class form above, without the type function) or a resolvers entry. Generated typings type custom scalars as any unless you configure defaultScalarType and customScalarTypeMapping (see the options table in Schema first: typePaths and generated typings); that is what turns scalar DateTime into export type DateTime = Date; and scalar Payload into export type Payload = unknown;.
Directives
A directive is metadata in the schema that a transformer turns into behaviour. Nest has two parts: the transformer, which you write once, and the place where the directive is applied. The transformer maps over the schema with @graphql-tools/utils and wraps the resolver of every field that carries the directive; it works for both Apollo and Mercurius:
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import { defaultFieldResolver, type GraphQLSchema } from 'graphql';
export function upperDirectiveTransformer(schema: GraphQLSchema, directiveName: string): GraphQLSchema {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const upperDirective = getDirective(schema, fieldConfig, directiveName)?.[0];
if (upperDirective) {
const { resolve = defaultFieldResolver } = fieldConfig;
// replace the resolver with one that calls the original, then upper-cases a string result
fieldConfig.resolve = async function (source, args, context, info) {
const result = await resolve(source, args, context, info);
return typeof result === 'string' ? result.toUpperCase() : result;
};
return fieldConfig;
}
return undefined;
},
});
}
Register it with the transformSchema option. In code first also declare the directive in buildSchemaOptions.directives (both GraphQLDirective and DirectiveLocation come from graphql), as the AppModule above does. Apply it with @Directive(), which can decorate fields, field resolvers, input and object types, queries, mutations and subscriptions; the Book.title field earlier carries @Directive('@upper'):
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ book(id: \"b1\") { title } }"}'
{"data":{"book":{"title":"GRAPHS IN PRACTICE"}}}
Two details matter. Directives applied with @Directive() are not written to the generated schema.gql (only their effect is visible), and the stored title is still Graphs in practice. In schema first you write the directive in SDL (directive @upper on FIELD_DEFINITION and title: String! @upper, as in the schema above) and you register only the transformSchema function; the schema-first example returns "title":"GRAPHS IN PRACTICE" as well. For the GraphQL side of directives (@include, @skip, @deprecated, custom directives and their pitfalls) see Directives. The federation directives are on GraphQL: Federation.
Extensions and field middleware
@Extensions() attaches arbitrary metadata to a field, a class or a method in the schema configuration (code first only). By itself it does nothing; something at run time must read it. The usual reader is a field middleware, which wraps one field’s resolution. This one compares the caller’s role with the role stored in the field’s extensions:
import { ForbiddenException } from '@nestjs/common';
import type { FieldMiddleware, MiddlewareContext, NextFn } from '@nestjs/graphql';
export const checkRoleMiddleware: FieldMiddleware = async (ctx: MiddlewareContext, next: NextFn) => {
const info = ctx.info!;
const { extensions } = info.parentType.getFields()[info.fieldName];
const header = (ctx.context as { req?: { headers: Record<string, string> } }).req?.headers['x-role'];
if (header !== extensions.role) {
throw new ForbiddenException(`Missing role "${String(extensions.role)}" for field "${info.fieldName}".`);
}
return next();
};
It is attached with middleware: [checkRoleMiddleware] on the @Field() and paired with @Extensions({ role: 'editor' }), exactly as in Book.internalNotes above. The role comes from a request header here only to keep the example small; a real application reads the authenticated user that a guard or the context factory put on the request (see Authorization).
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ book(id: \"b1\") { internalNotes } }"}'
{"errors":[{"message":"Missing role \"editor\" for field \"internalNotes\".","locations":[{"line":1,"column":20}],"path":["book","internalNotes"],"extensions":{"code":"FORBIDDEN","originalError":{"message":"Missing role \"editor\" for field \"internalNotes\".","error":"Forbidden","statusCode":403}}}],"data":{"book":{"internalNotes":null}}}
The same call with the header x-role: editor returns {"data":{"book":{"internalNotes":null}}}: the nullable field is allowed through and has no value in the store. Note that the error is a field-level error: the rest of the selection still resolves, and data.book.internalNotes is null. Field middleware is explained further on GraphQL: Subscriptions, Security & Plugins.
Inheritance and generics
Standard class inheritance works for @ArgsType() (shown above), @ObjectType() and resolvers, and generic factory functions build reusable types. Mark a base class isAbstract: true to suppress its SDL type. This cursor-pagination factory produces an XxxEdge type and a connection-like type per entity, and a generic base resolver adds a findAllXxx query:
export function Paginated<T>(classRef: Type<T>): Type<IPaginatedType<T>> {
@ObjectType(`${classRef.name}Edge`)
abstract class EdgeType {
@Field(() => String)
cursor: string;
@Field(() => classRef)
node: T;
}
@ObjectType({ isAbstract: true })
abstract class PaginatedType implements IPaginatedType<T> {
@Field(() => [EdgeType], { nullable: true })
edges: EdgeType[];
@Field(() => [classRef], { nullable: true })
nodes: T[];
@Field(() => Int)
totalCount: number;
@Field()
hasNextPage: boolean;
}
return PaginatedType as Type<IPaginatedType<T>>;
}
@ObjectType()
export class PaginatedShelf extends Paginated(Shelf) {}
function BaseResolver<T extends Type<unknown>>(classRef: T): Type<FindAll<InstanceType<T>>> {
@Resolver({ isAbstract: true })
abstract class BaseResolverHost {
@Query(() => [classRef], { name: `findAll${classRef.name}` })
async findAll(): Promise<InstanceType<T>[]> {
return [];
}
}
return BaseResolverHost as Type<FindAll<InstanceType<T>>>;
}
@Resolver(() => Shelf)
export class ShelvesResolver extends BaseResolver(Shelf) { /* ... */ }
The SDL this produces, with the matching response for { findAllShelf { id } shelfPage { totalCount edges { cursor node { label } } } }:
type PaginatedShelf {
edges: [ShelfEdge!]
hasNextPage: Boolean!
nodes: [Shelf!]
totalCount: Int!
}
type Query {
findAllShelf: [Shelf!]!
shelfPage: PaginatedShelf!
warrior: Warrior!
}
type ShelfEdge {
cursor: String!
node: Shelf!
}
{"data":{"findAllShelf":[],"shelfPage":{"totalCount":1,"edges":[{"cursor":"c1","node":{"label":"new arrivals"}}]}}}
The official page notes that the explicit return type on a generic base resolver is required (it avoids TypeScript’s "private class definition" error), and recommends an interface rather than any; FindAll<T> above is that interface. For the GraphQL pagination theory (connections, cursors) see Pagination.
Schema first: resolvers
Schema-first resolvers use string names instead of type functions, because the SDL already holds the types. @Resolver('Book') names the parent type; @Query(), @Mutation() and @ResolveField() match schema fields by method name, or by the string you pass (@Query('book')). The class-level @Resolver('Author') is required whenever the class has @ResolveField() methods. (A method-level @Resolver() works in schema first only and is not recommended; it is not supported in code first.)
@Resolver('Book')
export class BooksResolver {
constructor(private readonly store: BookshelfStore) {}
@Query()
books(@Args('status') status?: InternalStatus, @Args('limit') limit = 10) {
return this.store.books.filter((b) => !status || b.status === status).slice(0, limit);
}
@Query('book')
getBook(@Args('id') id: string) {
return this.store.books.find((b) => b.id === id) ?? null;
}
@Query()
search(@Args('term') term: string) {
const needle = term.toLowerCase();
return [
...this.store.books.filter((b) => b.title.toLowerCase().includes(needle)).map((b) => ({ ...b, __kind: 'Book' })),
...this.store.authors.filter((a) => a.name.toLowerCase().includes(needle)).map((a) => ({ ...a, __kind: 'Author' })),
];
}
@Mutation()
createBook(@Args('input') input: CreateBookInput) {
return this.store.create(input);
}
@ResolveField('author')
author(@Parent() book: { authorId: string }, @Context() ctx: Ctx) {
return ctx.authorLoader.load(book.authorId);
}
}
// unions and interfaces need __resolveType in schema first: it returns the *name* of the concrete type
@Resolver('SearchResult')
export class SearchResultResolver {
@ResolveField()
__resolveType(value: { __kind: string }) {
return value.__kind;
}
}
Source: NestJS documentation: GraphQL resolvers, "Schema first resolver" and GraphQL unions and enums, "Schema first".
The module registers the SDL, the Date scalar class, the directive transformer and a per-request loader. The resolvers entry gives the OUT_OF_PRINT enum value an internal representation (out-of-print) that differs from the public name, so the resolvers filter on the internal value while clients keep using the public one:
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [BookshelfModule],
inject: [BookshelfStore],
useFactory: (store: BookshelfStore) => ({
typePaths: ['./src/graphql-schema-first/*.graphql'],
// internal values for an enum: the API says OUT_OF_PRINT, resolvers see "out-of-print"
resolvers: { BookStatus: { OUT_OF_PRINT: 'out-of-print' } },
transformSchema: (schema) => upperDirectiveTransformer(schema, 'upper'),
context: () => ({ authorLoader: store.authorLoader() }),
}),
}),
Source: NestJS documentation: GraphQL unions and enums, "Schema first" (enums, "internal values") and Directives, "Schema first".
The schema-first application answers these requests:
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ books(status: OUT_OF_PRINT) { id status } }"}'
{"data":{"books":[{"id":"b2","status":"OUT_OF_PRINT"}]}}
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"mutation { createBook(input: { title: \"SDL first\", authorId: \"a1\" }) { id title status } }"}'
{"data":{"createBook":{"id":"b3","title":"SDL FIRST","status":"DRAFT"}}}
Context, the request object and async configuration
The context option is a factory that builds the GraphQL context for each operation. Its argument differs by driver (see Express and Fastify: Apollo and Mercurius): Apollo on Express receives { req, res }, Apollo on Fastify receives { request, reply }, and Mercurius calls it with (request, reply). Read the values in a resolver with @Context(), or from a guard through GqlExecutionContext.create(context).getContext():
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
context: ({ req, res }) => ({ req, res }),
}),
// in a resolver
@Query(() => String)
userAgent(@Context('req') req: Request): string {
return req.headers['user-agent'] ?? '';
}
forRootAsync() takes the usual dynamic-module forms (see Dynamic Modules): useFactory (optionally async, with inject), useClass (a class implementing GqlOptionsFactory with createGqlOptions()) and useExisting. The driver stays outside the factory. Use it when options come from configuration (see Configuration). The switches below are read from environment variables, so nothing is hard-coded:
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
autoSchemaFile: true,
graphiql: config.get<string>('GRAPHQL_IDE') === 'on',
introspection: config.get<string>('GRAPHQL_INTROSPECTION', 'on') === 'on',
}),
}),
With GRAPHQL_IDE=on and introspection on, GET /graphql with Accept: text/html returns the GraphiQL page and { schema { queryType { name } } } answers Query; with both off the page request returns HTTP 400 and the introspection query returns GraphQL introspection is not allowed by Apollo Server, but the query contained schema or __type.
GraphiQL and Apollo Sandbox
GraphiQL is the default IDE in @nestjs/graphql 14 (the one that ships with NestJS 12). It is served on the GraphQL URL itself (/graphql for Apollo, /graphiql for Mercurius). The older GraphQL Playground was removed in v14, and the playground option survives only as a deprecated boolean alias of graphiql; if both are set, graphiql wins.
With the Apollo driver, GraphiQL is on when NODE_ENV is not production and off otherwise. With a bare forRoot() and Accept: text/html:
| Configuration | NODE_ENV |
GET /graphql (browser) |
|---|---|---|
defaults |
|
200, GraphiQL page |
defaults |
|
400, JSON error (no IDE) |
|
|
200, GraphiQL page (explicit options force it on) |
|
|
400, JSON error (no IDE) |
|
|
400, JSON error (no IDE) |
|
|
200, Apollo Sandbox page |
Pass an object to configure GraphiQL (the migration guide’s replacement for graphiql: true with settings):
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
graphiql: {
url: '/graphql',
headers: {
authorization: 'Bearer <token>', // placeholder: never put a real token in source
},
shouldPersistHeaders: true,
isHeadersEditorEnabled: true,
inputValueDeprecation: false,
},
}),
| Option | Meaning |
|---|---|
|
endpoint the IDE sends operations to; defaults to the driver’s |
|
headers added to every request (for example a preconfigured |
|
persist the headers editor in browser storage (default |
|
show the headers editor (default |
|
include deprecated input fields and arguments in the documentation and in introspection (default |
Source: NestJS documentation: GraphQL quick start, "GraphQL IDE" and Migration guide, "GraphQL IDE configuration".
To use Apollo Sandbox instead of GraphiQL for local development, turn GraphiQL off and install Apollo’s landing-page plugin:
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
graphiql: false,
plugins: [ApolloServerPluginLandingPageLocalDefault()],
}),
Two behaviours surprise people. First, Apollo Server 5 has CSRF prevention on: a GET request that does not carry a non-simple content type or an apollo-require-preflight header is refused, so GET /graphql?query=… from curl returns This operation has been blocked as a potential Cross-Site Request Forgery (CSRF) with HTTP 400, while POST with content-type: application/json works (which is what every client library sends). Second, in non-production environments Apollo adds a stacktrace array to extensions in error responses; in production it does not, so do not rely on it. Securing introspection and limiting depth and cost is covered on Security & Demand Control and Introspection.
Generating the SDL and reaching the schema
There are three ways to get at the schema a Nest application builds.
The generated file. autoSchemaFile: 'path' writes it at start-up (see Code first: the module configuration); commit it if front-end teams or code generators read it.
Without running the application. To print the SDL in a build step, with no HTTP server, database or resolver dependencies, use GraphQLSchemaBuilderModule and GraphQLSchemaFactory (code first only). Pass the resolver classes (and optionally scalar classes, and BuildSchemaOptions such as skipCheck and orphanedTypes):
import { NestFactory } from '@nestjs/core';
import { GraphQLSchemaBuilderModule, GraphQLSchemaFactory } from '@nestjs/graphql';
import { printSchema } from 'graphql';
import { AuthorsResolver } from './authors.resolver.js';
import { BooksResolver } from './books.resolver.js';
/** Print the SDL without starting the server, a database or any resolver dependency. */
export async function generateSchema(): Promise<string> {
const app = await NestFactory.create(GraphQLSchemaBuilderModule, { logger: false });
await app.init();
const gqlSchemaFactory = app.get(GraphQLSchemaFactory);
const schema = await gqlSchemaFactory.create([BooksResolver, AuthorsResolver]);
await app.close();
return printSchema(schema);
}
skipCheck: true skips schema validation, and orphanedTypes lists classes to emit even when nothing references them; a declared but unreferenced class is otherwise left out of the schema.
From a running application. GraphQLSchemaHost exposes the built schema (code first or schema first). Read the schema getter only after initialisation, that is after app.init() or app.listen(); before the onModuleInit hook has run it is not built. This is how you run operations in an end-to-end test without an HTTP listener, and how the SDL below was printed:
const { schema } = app.get(GraphQLSchemaHost);
const result = await graphql({ schema, source: '{ __type(name: "BookStatus") { name } }' });
console.log(printSchema(schema));
The complete SDL generated for the Bookshelf module (with sortSchema: true):
directive @upper on FIELD_DEFINITION
"""Someone who wrote at least one book."""
type Author implements Node {
books: [Book!]!
id: ID!
name: String!
}
type Book implements Node {
"""How many years ago the book came out."""
ageInYears: Int!
author: Author!
id: ID!
"""Editorial notes, editors only."""
internalNotes: String
isbn: ISBN
pages: Int
publishedAt: DateTime!
status: BookStatus!
"""Title as printed on the cover."""
title: String!
}
"""Where a book is in its life cycle."""
enum BookStatus {
"""Not visible to readers yet."""
DRAFT
OUT_OF_PRINT @deprecated(reason: "Use a status filter on the catalogue instead.")
PUBLISHED
}
input CreateBookInput {
authorId: ID!
pages: Int
title: String!
}
type Mutation {
createBook(input: CreateBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book!
}
interface Node {
id: ID!
}
type Query {
author(id: ID!): Author
book(id: ID!): Book
"""A page of books, newest catalogue order."""
books(limit: Int! = 10, offset: Int! = 0, status: BookStatus, titleContains: String! = ""): [Book!]!
search(term: String!): [SearchResult!]!
}
union SearchResult = Author | Book
input UpdateBookInput {
pages: Int
title: String
}
The DateTime and ISBN scalar declarations are omitted above for brevity; the printed schema also contains scalar DateTime and scalar ISBN with their descriptions.
Source: NestJS documentation: GraphQL resolvers, "Object types" (the SDL is the one the example code of this page generates).
N+1 and DataLoader
A nested resolver runs once per parent object, so { books { author { name } } } calls the author field resolver once per book. With a repository behind it that is the N+1 problem, explained with the batch-and-cache remedy in Performance & N+1. In Nest the pattern has two Nest-specific rules: create the loaders per request (a loader caches, and a cache that outlives the request leaks data between users), and put them where every resolver can reach them, which is the GraphQL context.
The Bookshelf does that with a context factory (visible in AppModule above) and a tiny helper:
import DataLoader from 'dataloader';
import { BookshelfStore } from './store.js';
import { Author } from './models/author.model.js';
export interface GqlContext {
loaders: { authorById: DataLoader<string, Author | undefined> };
req?: { headers: Record<string, string | string[] | undefined> };
}
/** One set of loaders per GraphQL request: batching and the cache must never outlive a request. */
export function createLoaders(store: BookshelfStore): GqlContext['loaders'] {
return {
authorById: new DataLoader<string, Author | undefined>(async (ids) => store.findAuthorsByIds(ids)),
};
}
The author field resolver shown earlier calls ctx.loaders.authorById.load(book.authorId). Querying all four books returns the right authors in order with a single batched call to the store (store.authorQueries was 1, where the per-book version would have been 4):
curl -s localhost:3000/graphql -H 'content-type: application/json' \
-d '{"query":"{ books { title author { name } } }"}'
{"data":{"books":[{"title":"GRAPHS IN PRACTICE","author":{"name":"Ada Park"}},{"title":"TYPED APIS","author":{"name":"Ada Park"}},{"title":"SCHEMAS FIRST","author":{"name":"Grace Lindqvist"}},{"title":"OLD RESOLVERS","author":{"name":"Linus Okafor"}}]}}
Source: NestJS documentation: GraphQL resolvers, "Code first resolver" (field resolvers) and the DataLoader library.
The official NestJS documentation does not prescribe a DataLoader integration; the context-per-request approach above is the simplest one. The alternative is a request-scoped injectable that owns the loaders, which works but makes every resolver that depends on it request-scoped (see Injection Scopes for what that costs). The context-based loader shown here worked with both Apollo and Mercurius (see Express and Fastify: Apollo and Mercurius).
The CLI plugin (code first)
Writing @Field() on every property gets verbose. The @nestjs/graphql CLI plugin is an opt-in TypeScript compiler transformer that adds the decorators from the class’s AST: it annotates every property of input, object-type and args classes with @Field() (unless @HideField() is used), sets nullable from the question mark, sets the type (arrays included), and, with introspectComments, turns JSDoc comments into descriptions. It only analyses files whose names end in .input.ts, .args.ts, .entity.ts or .model.ts (change that with typeFileNameSuffix).
Enable it in nest-cli.json:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{ "name": "@nestjs/graphql", "options": { "introspectComments": true, "esmCompatible": true } }
]
}
}
| Option | Default | Description |
|---|---|---|
|
|
suffixes of the files that contain GraphQL types |
|
|
generate descriptions from comments |
|
inferred from each file’s module format |
emit ESM-compatible code (no |
|
|
log diagnostics, for example when a type is not exported |
With the plugin on, this model (note: no @Field() except the ID, which the plugin cannot infer from number):
/** A book in the catalogue. */
@ObjectType()
export class Book {
@Field(() => ID)
id: number;
/** Title as printed on the cover. */
title: string;
subtitle?: string;
tags: string[];
publishedAt: Date;
}
builds (nest build) into a program whose schema is:
"""A book in the catalogue."""
type Book {
id: ID!
"""Title as printed on the cover."""
title: String!
subtitle: String
tags: [String!]!
publishedAt: DateTime!
}
The same class in a file called book.dto.ts is not analysed: only the explicit id field survives (type Book { id: ID! }). Rename the file or add the suffix to typeFileNameSuffix.
Three situations need extra care:
-
SWC builder. With a plain (non-monorepo) project, the plugin needs type checking:
nest start -b swc --type-check. In a monorepo, generate the metadata with thePluginMetadataGeneratorand load it in the module with themetadataoption (import metadata from './metadata.js'; GraphQLModule.forRoot({ …, metadata })), as described in the SWC recipe. -
Webpack without the Nest CLI. Use the transformer with
ts-loader:getCustomTransformers: (program) ⇒ ({ before: [require('@nestjs/graphql/plugin').before({}, program)] }). -
Tests. A test runner that compiles TypeScript itself (for example
ts-jest) does not apply the plugin, and the schema fails to build withObject type <name> must define one or more fields.Add the AST transformer to the runner’s configuration (a transformer factory withtransformer.before({}, cs.program)ints-jest) or use explicit@Field()decorators in anything the tests compile. The Vitest ESM template compiles with esbuild, which has no AST-transformer hook, so there the explicit decorators are the simplest route.
Source: NestJS documentation: GraphQL CLI plugin, "SWC builder" and "Integration with ts-jest (e2e tests)".
Sharing models with a front end
Code-first models are full of GraphQL decorators that are useless (and heavy) in a browser bundle, yet reusing the same TypeScript types in the front end is one of the benefits of a TypeScript back end. @nestjs/graphql ships a model shim that replaces the decorators with inert code and exposes it through the browser export condition, so bundlers that resolve that condition for browser builds pick it up automatically. If yours does not, alias the package to the shim, for example with webpack:
resolve: {
alias: {
"@nestjs/graphql": path.resolve(__dirname, "../node_modules/@nestjs/graphql/dist/extra/graphql-model-shim")
}
}
Put the shared models in a separate package or workspace library (see Project Structure & Workspaces) so the front end imports only the model files. Another route is to generate front-end types from the SDL instead of sharing classes; for the client side see Clients Overview.
nest g resource for GraphQL
The CRUD generator scaffolds a module, service, resolver, entity, inputs and .spec files in one command. It asks for the transport layer; choose GraphQL (code first) or GraphQL (schema first):
nest g resource users
? What transport layer do you use? GraphQL (code first)
? Would you like to generate CRUD entry points? Yes
CREATE src/users/users.module.ts (231 bytes)
CREATE src/users/users.resolver.spec.ts (531 bytes)
CREATE src/users/users.resolver.ts (1121 bytes)
CREATE src/users/users.service.spec.ts (456 bytes)
CREATE src/users/users.service.ts (631 bytes)
CREATE src/users/dto/create-user.input.ts (196 bytes)
CREATE src/users/dto/update-user.input.ts (246 bytes)
CREATE src/users/entities/user.entity.ts (187 bytes)
UPDATE src/app.module.ts (161 bytes)
Source: NestJS documentation: CRUD generator.
The generated code-first resolver (CLI 12, ESM template) uses .js import extensions and the placeholders shown here, to be replaced with your data access:
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql';
import { UsersService } from './users.service.js';
import { User } from './entities/user.entity.js';
import { CreateUserInput } from './dto/create-user.input.js';
import { UpdateUserInput } from './dto/update-user.input.js';
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Mutation(() => User)
createUser(@Args('createUserInput') createUserInput: CreateUserInput) {
return this.usersService.create(createUserInput);
}
@Query(() => [User], { name: 'users' })
findAll() {
return this.usersService.findAll();
}
@Query(() => User, { name: 'user' })
findOne(@Args('id', { type: () => Int }) id: number) {
return this.usersService.findOne(id);
}
@Mutation(() => User)
updateUser(@Args('updateUserInput') updateUserInput: UpdateUserInput) {
return this.usersService.update(updateUserInput.id, updateUserInput);
}
@Mutation(() => User)
removeUser(@Args('id', { type: () => Int }) id: number) {
return this.usersService.remove(id);
}
}
Source: NestJS documentation: CRUD generator.
The generated update-user.input.ts is PartialType(CreateUserInput) with an id field (see Mutations, @InputType() and mapped types). The schema-first variant instead creates a users.graphql file with type User, input CreateUserInput, input UpdateUserInput, queries and mutations, a resolver that uses string names (@Query('users'), @Mutation('createUser')), empty DTO classes, and it adds @nestjs/mapped-types to package.json for its PartialType. Neither variant creates a service that talks to a database: that is yours to write. For the rest of the CLI see Getting Started & the CLI.
Several GraphQL endpoints in one application
GraphQLModule.forRoot() can be imported more than once, each with its own path. By default a module scans the whole application for resolvers; limit it with include. In code first, include only decides which modules are scanned for resolvers: object types, inputs, enums and unions would still appear in every schema. To scope a type to one module, use registerIn, available on @ObjectType(), @InputType(), @InterfaceType() and @ArgsType(), and in the options of registerEnumType() and createUnionType(). Prefer the factory form (() ⇒ AdminModule) when the type and the module reference each other, to avoid circular-import errors.
@ObjectType({ registerIn: () => AdminModule })
export class AuditEntry {
@Field()
action: string;
}
@Module({
imports: [
PublicModule,
AdminModule,
GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, path: '/graphql', include: [PublicModule], autoSchemaFile: true }),
GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, path: '/admin/graphql', include: [AdminModule], autoSchemaFile: true }),
],
})
export class MultiEndpointModule {}
{ info { name } } answers on /graphql; { audit { action } } on /graphql fails with Cannot query field "audit" on type "Query"., and succeeds on /admin/graphql. If you use @apollo/server with @as-integrations/fastify and serve several GraphQL endpoints from one application, enable disableHealthCheck in the module options.
Express and Fastify: Apollo and Mercurius
Apollo Server works on both HTTP platforms; Mercurius is Fastify-only. The Nest side (decorators, resolvers, schema) is identical. What differs is the integration package, the shape of the context and the details of the server. The two Fastify modules below run the same Bookshelf resolvers:
/** Fastify + Apollo Server: needs @as-integrations/fastify; the request is { request, reply }. */
@Module({
imports: [
BookshelfModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [BookshelfModule],
inject: [BookshelfStore],
useFactory: (store: BookshelfStore) => ({
autoSchemaFile: true,
context: ({ request }: { request: FastifyRequest }) => ({ req: request, loaders: createLoaders(store) }),
}),
}),
],
})
export class FastifyApolloModule {}
/** Fastify + Mercurius: context is a function of the Fastify request and reply. */
@Module({
imports: [
BookshelfModule,
GraphQLModule.forRootAsync<MercuriusDriverConfig>({
driver: MercuriusDriver,
imports: [BookshelfModule],
inject: [BookshelfStore],
useFactory: (store: BookshelfStore) => ({
autoSchemaFile: true,
graphiql: true,
context: (req: FastifyRequest, _reply: FastifyReply) => ({ req, loaders: createLoaders(store) }),
transformSchema: (schema) => upperDirectiveTransformer(schema, 'upper'),
buildSchemaOptions: {
directives: [new GraphQLDirective({ name: 'upper', locations: [DirectiveLocation.FIELD_DEFINITION] })],
},
}),
}),
],
})
export class MercuriusModule {}
On FastifyAdapter, both return the same data for { books { title author { name } } }, batch the author look-ups into one call, and serve an IDE. They differ in the details:
| Apollo (Express or Fastify) | Mercurius (Fastify) | |
|---|---|---|
Packages |
|
|
Context factory argument |
Express: |
|
IDE URL |
|
|
Error |
|
a validation error carries only |
CSRF prevention on |
yes (Apollo Server 5 default) |
not applicable |
Option reference |
Apollo Server constructor options |
Mercurius plugin options |
Federation, subscriptions, plugins |
Apollo plugins, |
Mercurius hooks, its own subscriptions and federation packages (see the next page and Federation) |
Fastify itself (and why you would choose it) is on Fastify & HTTP Platforms. Serving GraphQL over HTTP in general, including GET, batching and content negotiation, is on Serving over HTTP.
Pitfalls
-
Missing
driver.forRoot()throws immediately; passApolloDriverorMercuriusDriver. -
No
@Field()and no plugin. The property is silently absent from the schema, and a class with no fields at all fails withObject type <name> must define one or more fields. -
numberisFloat. Use@Field(() ⇒ Int)and@Args('id', { type: () ⇒ Int }).numberScalarMode: 'integer'fixes that globally but makes fractional values fail at run time. -
Nullable by
?is not enough without the plugin.name?: stringwithout{ nullable: true }still producesString!. -
Plain objects for unions and interfaces. Without a custom
resolveType, return class instances; otherwise the field errors withReturn type for "…" cannot be resolved. -
Circular model imports in ESM. Nest 12 compiles to ES modules, and with
emitDecoratorMetadataa property typed with a class from a cyclic import is evaluated while that module is not initialised. ABookwithauthor: Authorand anAuthorwithbooks: Book[]in separate files crashes at load time withReferenceError: Cannot access 'Author' before initialization. Wrapping the property type in a type alias that erases at run time fixes it:import type { Relation } from './relation.js'withexport type Relation<T> = Tandauthor: Relation<Author>. The@Field(() ⇒ Author)type function is lazy and is not the problem. This page’s Bookshelf avoids the cycle by resolvingBook.authorwith a field resolver. -
Two copies of
graphql.@nestjs/graphqlimportsgraphqlas an ES module, Node resolves it to the CommonJS build, and some tooling picks the.mjsbuild for your own files. Vitest’s default resolution does exactly that and producesCannot use GraphQLDirective "@upper" from another module or realm.Aliasgraphqltographql/index.jsin the test config (resolve.alias), or keepgraphqlimports out of the files under test; in a normal Node run this does not occur. -
A resolver not listed in
providers. Its fields and queries do not exist; the error only appears when a client asks. -
Field resolvers skip guards and interceptors by default. See Where GraphQL sits in the Nest request lifecycle; protect sensitive fields with
fieldResolverEnhancersor field middleware, not only with a guard on the class. -
Request-scoped dependencies. A resolver that injects a request-scoped provider becomes request-scoped and is instantiated per operation; prefer the context object for per-request state (see N+1 and DataLoader).
-
Directives applied with
@Directive()are invisible in the generated file. Check behaviour by running a query, not by readingschema.gql. -
Editing the generated typings. Changes are overwritten; extend the generated classes in another file.
-
IDE and introspection in production. GraphiQL is off in production with the Apollo default, but an explicit
graphiqlobject turns it on there; decide deliberately.
What changed recently
-
GraphiQL replaces Playground (
@nestjs/graphql14, NestJS 12).graphiql: trueor an options object enables it;graphql-playgroundis gone andplaygroundremains only as a deprecated alias.nest upgradeapplies this mechanical change for you. -
subscriptions-transport-wsis removed. Onlygraphql-wsis supported:subscriptions: { 'graphql-ws': true }(see GraphQL: Subscriptions, Security & Plugins). -
Apollo Server 5 with
@nestjs/apollo14. The Express integration is@as-integrations/express5(matching Express 5), and Apollo’s CSRF prevention applies toGETrequests. -
The CLI plugin has an
esmCompatibleoption that emits ESM-compatible code for the ESM template (it was needed to build the plugin example on this page), andts-morphis an optional peer dependency of@nestjs/graphqlused by the schema-first typings generator. -
registerIn(documented on the current quick-start page) scopes object types, inputs, interfaces, args, enums and unions to a module in multi-endpoint applications.
Where the book is outdated
The reference book (see the section bibliography) was written for NestJS 10, and its GraphQL chapters show the code-first setup with GraphQLModule.forRoot<ApolloDriverConfig>, autoSchemaFile, @Resolver, @Query, @Mutation, @Args, @ObjectType and @Field and the schema-first setup with typePaths. The decorators are unchanged. What has moved on:
-
The book installs the end-of-life
apollo-server-expressandapollo-server-corepackages and leaves out the mandatorydriveroption. Today you install@nestjs/apolloand@apollo/server(Apollo Server 5) with an integration package, anddriver: ApolloDriveris required. -
It uses Apollo Server 2 and 3 APIs such as
ApolloErrorandapollo-server-plugin-base; the equivalents areGraphQLErrorand@apollo/serverplugins, covered on the next GraphQL page. -
Its tooling refers to GraphQL Playground; GraphiQL (or Apollo Sandbox) is the IDE now, and the
graphql-wsprotocol replacessubscriptions-transport-ws. -
It tested with Express only. Fastify with Mercurius, or Apollo’s Fastify integration, is a supported route today.
-
Its examples use CommonJS imports; the NestJS 12 template is ESM and the examples here use
.jsimport extensions. -
Federation is conceptual in the book; the working setup is on GraphQL: Federation.