Custom Directives
Extending GraphQL with Custom Directives
Directives provide a way to add custom logic to your GraphQL schema without modifying types or fields. They enable reusable features like authorization, caching, formatting, and validation.
What are Schema Directives?
Schema directives are annotations prefixed with @ that modify the behavior of fields, types, or arguments. GraphQL includes built-in directives like @deprecated, @skip, and @include.
type User {
id: ID!
name: String!
email: String! @deprecated(reason: "Use contactEmail instead")
contactEmail: String!
posts: [Post!]!
}
# Client can conditionally skip fields
query GetUser($includeEmail: Boolean!) {
user(id: "1") {
id
name
email @include(if: $includeEmail)
posts @skip(if: false)
}
}
Declaring Custom Directives
Custom directives are defined in your schema using the directive keyword. You specify where they can be applied using directive locations.
directive @auth(
requires: Role = USER
) on OBJECT | FIELD_DEFINITION
directive @rateLimit(
limit: Int = 10,
window: Int = 60
) on FIELD_DEFINITION
directive @cache(
maxAge: Int = 3600
) on FIELD_DEFINITION
directive @upper on FIELD_DEFINITION
directive @length(
min: Int,
max: Int
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
enum Role {
USER
ADMIN
MODERATOR
}
Implementing @auth Directive
The @auth directive restricts field access based on user roles.
import { SchemaDirectiveVisitor } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
class AuthDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { requires } = this.args;
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (source, args, context, info) {
const user = context.user;
if (!user) {
throw new Error('Authentication required');
}
if (requires && user.role !== requires) {
throw new Error(`Role ${requires} required`);
}
return resolve.call(this, source, args, context, info);
};
}
}
// Apply to schema
const schema = makeExecutableSchema({
typeDefs,
resolvers,
schemaDirectives: {
auth: AuthDirective
}
});
Using @auth in Schema
type Query {
# Public - no auth required
posts: [Post!]!
# Requires authentication
me: User! @auth
# Requires ADMIN role
users: [User!]! @auth(requires: ADMIN)
allOrders: [Order!]! @auth(requires: ADMIN)
}
type Mutation {
# Any authenticated user
createPost(title: String!, content: String!): Post! @auth
# Only admins
deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
# Only moderators or admins
approvePost(id: ID!): Post! @auth(requires: MODERATOR)
}
type User @auth {
id: ID!
email: String!
# Even if User type is queried, sensitive field needs extra protection
ssn: String! @auth(requires: ADMIN)
}
Implementing @rateLimit Directive
Rate limiting prevents abuse by limiting the number of requests per time window.
import { SchemaDirectiveVisitor } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
class RateLimitDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { limit, window } = this.args;
const { resolve = defaultFieldResolver } = field;
const requests = new Map();
field.resolve = async function (source, args, context, info) {
const userId = context.user?.id || context.ip;
const key = `${userId}:${info.parentType}.${info.fieldName}`;
const now = Date.now();
// Get request history
let history = requests.get(key) || [];
// Remove old requests outside window
history = history.filter(time => now - time < window * 1000);
// Check limit
if (history.length >= limit) {
throw new Error(
`Rate limit exceeded. Max ${limit} requests per ${window}s`
);
}
// Add current request
history.push(now);
requests.set(key, history);
return resolve.call(this, source, args, context, info);
};
}
}
type Mutation {
# Max 5 posts per minute
createPost(title: String!, content: String!): Post!
@auth
@rateLimit(limit: 5, window: 60)
# Max 3 password resets per hour
resetPassword(email: String!): Boolean!
@rateLimit(limit: 3, window: 3600)
}
Implementing @cache Directive
Caching directives optimize performance by storing expensive computations.
class CacheDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { maxAge } = this.args;
const { resolve = defaultFieldResolver } = field;
const cache = new Map();
field.resolve = async function (source, args, context, info) {
const key = JSON.stringify({
field: info.fieldName,
args,
sourceId: source?.id
});
// Check cache
const cached = cache.get(key);
if (cached && Date.now() - cached.time < maxAge * 1000) {
return cached.value;
}
// Execute resolver
const result = await resolve.call(this, source, args, context, info);
// Store in cache
cache.set(key, { value: result, time: Date.now() });
return result;
};
}
}
type Query {
# Cache for 1 hour
popularPosts: [Post!]! @cache(maxAge: 3600)
# Cache for 5 minutes
stats: SiteStats! @cache(maxAge: 300)
}
type User {
id: ID!
name: String!
# Cache user posts for 10 minutes
posts: [Post!]! @cache(maxAge: 600)
}
Transformer Directives
Transformer directives modify field values before returning them to the client.
class UpperDirective 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;
};
}
}
# Usage
type User {
name: String! @upper # "john doe" becomes "JOHN DOE"
email: String!
}
Validation Directives
Validation directives enforce constraints on input arguments.
class LengthDirective extends SchemaDirectiveVisitor {
visitArgumentDefinition(argument) {
const { min, max } = this.args;
// Wrap field resolver
const field = argument.astNode.parent;
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (source, args, context, info) {
const value = args[argument.name];
if (min && value.length < min) {
throw new Error(
`${argument.name} must be at least ${min} characters`
);
}
if (max && value.length > max) {
throw new Error(
`${argument.name} must be at most ${max} characters`
);
}
return resolve.call(this, source, args, context, info);
};
}
}
# Usage
type Mutation {
createUser(
username: String! @length(min: 3, max: 20)
password: String! @length(min: 8, max: 100)
): User!
}
@auth @rateLimit @cache provides authentication, rate limiting, and caching in one field.
- Create a
@logdirective that logs every field access with timestamp and user info - Implement a
@sanitizedirective that removes HTML tags from string inputs - Build a
@costdirective that tracks query complexity and rejects expensive queries - Create a
@maskdirective that partially hides sensitive data (e.g., credit card numbers) - Combine
@auth,@rateLimit, and@cacheon a single field and test behavior