GraphQL directives let you hook into the GraphQL execution process to modify query behavior, add metadata, or perform validation.
Let’s see how they work with an example. Imagine you have a GraphQL API for a music library, and you want to add a directive to automatically uppercase string fields.
Here’s a basic GraphQL schema:
type Song {
title: String!
artist: String!
album: String
}
type Query {
getSong(id: ID!): Song
}
And here’s how you might define a custom directive, say @uppercase, in your schema definition:
directive @uppercase on FIELD_DEFINITION
type Song {
title: String! @uppercase
artist: String! @uppercase
album: String @uppercase
}
type Query {
getSong(id: ID!): Song
}
The directive @uppercase on FIELD_DEFINITION part tells GraphQL that this directive can only be applied to field definitions within your schema.
Now, to make this directive do something, you need to implement its logic in your GraphQL server. The exact implementation depends on your GraphQL server library. For Apollo Server (Node.js), you’d typically use SchemaDirectiveVisitor.
Here’s a simplified Apollo Server implementation:
const { ApolloServer, gql } = require('apollo-server');
const { SchemaDirectiveVisitor } = require('apollo-server-core');
const { defaultFieldResolver, GraphQLSchema } = require('graphql');
// Your existing schema
const typeDefs = gql`
directive @uppercase on FIELD_DEFINITION
type Song {
title: String! @uppercase
artist: String! @uppercase
album: String @uppercase
}
type Query {
getSong(id: ID!): Song
}
`;
// Mock data
const songs = {
'1': { id: '1', title: 'Bohemian Rhapsody', artist: 'Queen', album: 'A Night at the Opera' },
};
// Custom directive implementation
class UppercaseDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (...args) {
const result = await resolve.apply(this, args);
if (typeof result === 'string') {
return result.toUpperCase();
}
return result;
};
}
}
const server = new ApolloServer({
typeDefs,
resolvers: {
Query: {
getSong: (parent, { id }) => songs[id],
},
},
schemaDirectives: {
uppercase: UppercaseDirective,
},
});
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
In this UppercaseDirective class:
visitFieldDefinitionis a method that gets called bySchemaDirectiveVisitorfor every field that has the@uppercasedirective applied.field.resolve = async function (...args) { ... }intercepts the original resolver for that field.const result = await resolve.apply(this, args);calls the original resolver to get the data.if (typeof result === 'string') { return result.toUpperCase(); }checks if the returned value is a string and, if so, converts it to uppercase.
Now, when you query for a song, the title, artist, and album fields will automatically be returned in uppercase.
query {
getSong(id: "1") {
title
artist
album
}
}
Response:
{
"data": {
"getSong": {
"title": "BOHEMIAN RHAPSODY",
"artist": "QUEEN",
"album": "A NIGHT AT THE OPERA"
}
}
}
Directives are not just for transforming data. They can also be used for authorization, logging, caching, and more. For example, you could create an @auth directive to check user permissions before executing a field.
The power comes from the fact that directives can be placed on various schema elements: SCHEMA, SCALAR, OBJECT, FIELD_DEFINITION, ARGUMENT_DEFINITION, INTERFACE, UNION, ENUM, ENUM_VALUE, and INPUT_OBJECT. Each of these on locations triggers a different visit method in your SchemaDirectiveVisitor subclass (visitSchema, visitScalar, visitObject, etc.), allowing you to hook into different parts of the schema definition and execution.
What most people don’t realize is that directives can also be applied to arguments. If you had a searchSongs query that accepted an artistName argument and you wanted to apply a @constraint directive to ensure it’s not too long, you’d define it like this:
directive @constraint(minLength: Int) on ARGUMENT_DEFINITION
type Query {
searchSongs(artistName: String! @constraint(minLength: 2)): [Song!]
}
Then, your directive logic would live in visitArgumentDefinition within your custom directive visitor. This allows for fine-grained validation and behavior modification at the argument level, preventing invalid data from even reaching your core resolvers.
The next step is often to explore directives that modify the query execution itself, like implementing pagination or filtering directly via directives.