GraphQL directives can modify your schema at runtime, allowing for dynamic behavior and custom logic.

Let’s see how a simple directive can alter how a field is resolved. Imagine a User type with a name field. We want to add a directive that automatically transforms the name to uppercase.

type User {
  id: ID!
  name: String! @uppercase
  email: String!
}

directive @uppercase on FIELD_DEFINITION

When a query like this is executed:

query {
  user(id: "123") {
    name
  }
}

Without a custom directive, the name would be returned as is (e.g., "Alice"). With the @uppercase directive, the resolver for user(id: "123") would fetch "Alice", and then the directive’s logic would intercept this result and transform it to "ALICE" before it’s sent back to the client.

The real power of directives comes from their ability to inject logic directly into the GraphQL execution process. They are essentially metadata that the GraphQL server engine understands and acts upon. This means you can add features like authentication checks, rate limiting, data transformations, or even conditional field inclusion without modifying your core resolver code.

Here’s how you might implement this @uppercase directive in a Node.js environment using Apollo Server:

const { ApolloServer, gql } = require('apollo-server');
const { mapSchema, getDirective, MapperKind } = require('@graphql-tools/utils');

// The GraphQL schema
const typeDefs = gql`
  directive @uppercase on FIELD_DEFINITION

  type User {
    id: ID!
    name: String! @uppercase
    email: String!
  }

  type Query {
    user(id: ID!): User
  }
`;

// Mock data
const users = {
  "123": { id: "123", name: "Alice", email: "alice@example.com" },
  "456": { id: "456", name: "Bob", email: "bob@example.com" }
};

// Resolver for the Query.user field
const resolvers = {
  Query: {
    user: (parent, { id }) => users[id]
  },
  // We'll add directive resolvers here later
};

// Function to apply the directive transformation
function uppercaseDirectiveTransformer(schema) {
  return mapSchema(schema, {
    // Executes once for each object field definition in the schema
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      // Check whether this field has the specified directive
      const uppercaseDirective = getDirective(schema, fieldConfig, 'uppercase')?.[0];

      if (uppercaseDirective) {
        // Get the original resolver function
        const { resolve } = fieldConfig;

        // Replace the original resolver with a new function that calls
        // the original resolver and then transforms the result
        fieldConfig.resolve = async function (source, args, context, info) {
          // Call the original resolver
          const result = resolve ? await resolve(source, args, context, info) : source[fieldConfig.key];

          // Transform the result if it's a string
          if (typeof result === 'string') {
            return result.toUpperCase();
          }
          return result;
        }
        return fieldConfig;
      }
    }
  });
}

// Create the initial schema
const schema = gql`
  directive @uppercase on FIELD_DEFINITION

  type User {
    id: ID!
    name: String! @uppercase
    email: String!
  }

  type Query {
    user(id: ID!): User
  }
`;

// Apply the directive transformer
const transformedSchema = uppercaseDirectiveTransformer(schema);

// Create the Apollo Server instance
const server = new ApolloServer({
  typeDefs, // Use the original typeDefs for schema definition
  resolvers,
  // The actual schema used for execution will be the transformed one.
  // Apollo Server handles this internally if you pass the directive definitions.
  // For more explicit control, you might pass `schema: transformedSchema` if your
  // GraphQL server library supports it directly.
  // For Apollo Server, defining the directive in typeDefs and providing the transformer
  // function is the typical approach.
});

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

In this example, mapSchema from @graphql-tools/utils is key. It allows us to traverse the schema and apply transformations. When it encounters a field with the @uppercase directive, we wrap the original resolver. The new resolver first executes the original logic (fetching "Alice") and then applies the .toUpperCase() transformation to the result before returning it. This separation of concerns is powerful: the core data fetching logic remains clean, while the presentation or transformation logic is handled by the directive.

The most surprising thing about custom directives is how they elegantly decouple cross-cutting concerns from your business logic. You can define a directive for authorization, for example, and apply it to multiple fields across your schema. The directive’s implementation then handles the authorization check, and your field resolvers are none the wiser, focusing solely on fetching the requested data. This means you can add or change authorization rules without touching the resolvers themselves, which is a massive win for maintainability.

The MapperKind.OBJECT_FIELD is a specific visitor pattern that tells mapSchema to pay attention to definitions of fields within object types. When the visitor finds a field that has our @uppercase directive applied to it, it then proceeds to modify that field’s configuration.

The next concept you’ll want to explore is how directives can accept arguments, allowing for even more flexible and dynamic schema modifications, such as a @transform(case: "lower") directive.

Want structured learning?

Take the full Graphql-tools course →