GraphQL Directives â Schema and Query Annotations Explained
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
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
- What are the three built-in GraphQL directives?
- How do you define a custom directive in SDL?
- What is the purpose of @deprecated?
- How do custom schema directives implement actual behavior?
- What locations can directives be applied to?
Answers:
@deprecated(marks field as deprecated),@skip(conditionally skips field),@include(conditionally includes field).- With
directive @name(args) on LOCATION | LOCATION. The declaration specifies the directive name, arguments, and valid application locations. @deprecated(reason: "...")marks a schema field as deprecated. Tools display the reason and clients should migrate away from deprecated fields.- Custom directives need a schema transformation (using
mapSchemaor the Apollo Server plugin system) to read directive metadata and wrap resolvers with additional logic. - 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
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 |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro