Skip to content

GraphQL Directives — Schema and Query Annotations Explained

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Graphql Directives. We cover key concepts, practical examples, and best practices to help you master this topic.

GraphQL directives are annotations that add metadata and conditional behavior to schema elements and queries, enabling features like deprecation, conditional inclusion, and custom transformations.

What You'll Learn

You will learn how to use built-in directives (@deprecated, @skip, @include), define custom schema and query directives, implement directive logic, and apply directives for concerns like authentication and formatting.

Why Directives Matter

Directives separate cross-cutting concerns from business logic. Instead of adding deprecation logic to every resolver or checking authentication in 20 places, directives let you annotate the schema declaratively. DodaTech's Durga Antivirus Pro uses a custom @auth directive on fields that require specific roles, a @format directive for consistent date/number formatting, and @deprecated to phase out legacy fields gracefully.

flowchart TB
    A["Schema Directives\n(transform schema)"] --> B["@deprecated(reason)"]
    A --> C["@auth(requires: Role)"]
    A --> D["@format(style: String)"]
    E["Query Directives\n(transform execution)"] --> F["@skip(if: Boolean)"]
    E --> G["@include(if: Boolean)"]
    style A fill:#dbeafe,stroke:#2563eb
    style E fill:#fef3c7,stroke:#d97706
â„šī¸ Info

Prerequisites: GraphQL schema design and resolver implementation.

Built-in Directives

GraphQL provides three built-in directives:

# @deprecated — marks a field as deprecated
type Device {
  id: ID!
  name: String!
  osVersion: String @deprecated(reason: "Use 'os' field instead")
}

# @skip — conditionally skips a field
query GetDevices($hideScans: Boolean!) {
  devices {
    id
    name
    scans @skip(if: $hideScans) {
      id
      status
    }
  }
}

# @include — conditionally includes a field
query GetDeviceDetail($includeHistory: Boolean!) {
  device(id: "dev-001") {
    id
    name
    history @include(if: $includeHistory) {
      event
      timestamp
    }
  }
}

Directive Locations

Directives can be applied to different schema locations:

# SCHEMA locations
directive @auth(requires: Role!) on FIELD_DEFINITION | OBJECT
directive @format(style: String!) on FIELD_DEFINITION
directive @upper on FIELD_DEFINITION

# QUERY locations
# @skip and @include on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

# Executable directives
directive @live on QUERY
directive @deprecated(reason: String) on FIELD_DEFINITION | ENUM_VALUE

Custom Schema Directive: @auth

enum Role {
  ADMIN
  ANALYST
  VIEWER
}

directive @auth(requires: Role!) on FIELD_DEFINITION | OBJECT

type Threat {
  id: ID!
  name: String!
  severity: Severity!
  internalNotes: String @auth(requires: ADMIN)
}

type Query {
  threats: [Threat!]!
  sensitiveThreats: [Threat!]! @auth(requires: ANALYST)
}
const { mapSchema, getDirectives, MapperKind } = require('@graphql-tools/utils');

function authDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directives = getDirectives(schema, fieldConfig);
      const authDirective = directives['auth'];
      
      if (authDirective) {
        const { requires } = authDirective;
        const originalResolver = fieldConfig.resolve || defaultResolver;
        
        fieldConfig.resolve = function (source, args, context, info) {
          // Check user authentication and role
          if (!context.user) {
            throw new AuthenticationError('Authentication required');
          }
          
          const roleHierarchy = { ADMIN: 3, ANALYST: 2, VIEWER: 1 };
          const userRoleLevel = roleHierarchy[context.user.role] || 0;
          const requiredLevel = roleHierarchy[requires] || 0;
          
          if (userRoleLevel < requiredLevel) {
            throw new ForbiddenError(`Requires ${requires} role`);
          }
          
          return originalResolver(source, args, context, info);
        };
      }
      return fieldConfig;
    },
  });
}

Custom Query Directive: @format

directive @format(style: String!) on FIELD_DEFINITION

type Threat {
  id: ID!
  name: String!
  detectedAt: DateTime! @format(style: "relative")
  fileSize: Int! @format(style: "humanize")
}
const { mapSchema, getDirectives, MapperKind } = require('@graphql-tools/utils');

function formatDirectiveTransformer(schema) {
  return mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directives = getDirectives(schema, fieldConfig);
      const formatDirective = directives['format'];
      
      if (formatDirective) {
        const { style } = formatDirective;
        const originalResolver = fieldConfig.resolve || defaultResolver;
        
        fieldConfig.resolve = async (source, args, context, info) => {
          const value = await originalResolver(source, args, context, info);
          
          switch (style) {
            case 'relative':
              return value ? timeAgo(new Date(value)) : null;
            case 'humanize':
              return value ? formatFileSize(value) : null;
            case 'upper':
              return typeof value === 'string' ? value.toUpperCase() : value;
            default:
              return value;
          }
        };
      }
      return fieldConfig;
    },
  });
}

Directive Declaration

All custom directives must be declared in the schema:

# Declare the directive
directive @auth(requires: Role!) on FIELD_DEFINITION | OBJECT
directive @format(style: String!) on FIELD_DEFINITION
directive @upper on FIELD_DEFINITION

# Use the directive
type Query {
  threats: [Threat!]! @auth(requires: ANALYST)
}

Without the declaration, GraphQL will reject the schema.

Common Mistakes

1. Forgetting to Declare Directives

Every custom directive must be declared in the schema with directive @name(args) on LOCATION. Undeclared directives cause schema validation errors.

2. Applying Directives to Wrong Locations

A directive declared for FIELD_DEFINITION cannot be applied to INPUT_FIELD_DEFINITION or QUERY. Check the directive declaration for valid locations.

3. Not Transforming the Schema

Declaring a directive does not implement its behavior. You must use mapSchema or equivalent to transform the schema and add logic to annotated fields.

4. Overusing Directives for Business Logic

Directives are for cross-cutting concerns (auth, logging, formatting). Business logic belongs in resolvers. Don't create a @calculateDiscount directive.

5. Ignoring Directive Order

Multiple directives on one field apply in declaration order, which can cause unexpected interactions. Document directive compatibility.

Practice Questions

  1. What are the three built-in GraphQL directives?
  2. How do you define a custom directive in SDL?
  3. What is the purpose of @deprecated?
  4. How do custom schema directives implement actual behavior?
  5. What locations can directives be applied to?

Answers:

  1. @deprecated (marks field as deprecated), @skip (conditionally skips field), @include (conditionally includes field).
  2. With directive @name(args) on LOCATION | LOCATION. The declaration specifies the directive name, arguments, and valid application locations.
  3. @deprecated(reason: "...") marks a schema field as deprecated. Tools display the reason and clients should migrate away from deprecated fields.
  4. Custom directives need a schema transformation (using mapSchema or the Apollo Server plugin system) to read directive metadata and wrap resolvers with additional logic.
  5. Schema locations: FIELD_DEFINITION, OBJECT, INPUT_FIELD_DEFINITION, ENUM_VALUE, SCALAR, INTERFACE, UNION, ARGUMENT_DEFINITION. Query locations: FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT, QUERY, MUTATION, SUBSCRIPTION.

Challenge: Implement a custom @log directive for DodaTech's GraphQL schema that logs every field access with execution time. Include an argument to set log level (INFO, DEBUG, WARN) and implement it using schema transformation. Test it on fields of the Threat type.

FAQ

Can directives modify the response?

Yes — custom directives can wrap resolvers to transform values. A @upper directive could uppercase string fields. A @format directive could format dates.

Are directives available in all GraphQL implementations?

Built-in directives (@skip, @include, @deprecated) are universal. Custom directive support depends on the server library. Apollo Server and GraphQL Tools support them.

Can I use @skip and @include on the same field?

Technically yes, but it creates confusion. The field is included if the @include condition is true, and skipped if the @skip condition is false. Use only one per field.

Do directives work with federation?

Custom directives in federated schemas require extra care. The gateway needs to understand the directive. Use @shareable or document custom directives for all subgraphs.

Can I pass complex objects to directive arguments?

Directive arguments can only be scalars and enums. For complex configuration, use a separate configuration object in the server setup, referenced by a scalar argument.

Mini Project

Create a set of custom directives for DodaTech's GraphQL API: @auth (role-based access), @cache (TTL configuration), @validate (input validation rules), and @audit (log access to sensitive fields). Implement all four with schema transformation and test them on the Threat and User types.

What's Next

Topic Description
Subscriptions Deep Dive Real-time data with WebSockets
Nested Resolvers Resolver chains and data fetching
Arguments Guide Query and field arguments
âŦ… Arguments Guide
➡ Subscriptions Deep Dive

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro