A new PR on the GraphQL spec will bring support for directives on directives. Its motivation is to be able to use directives @deprecate and @specifiedBy also on directives:

directive @lowerCase
  on FIELD
  @deprecated(reason: "Use @lc instead")

directive @connector
  on FIELD_DEFINITION
  @specifiedBy(url: "https://...")

Being able to apply directives @deprecated and @specifiedBy on other directives is clearly justified, since directives can evolve together with the schema, and we may need to provide documentation about their behavior by pointing to some URL.

Are there other situations where directives modifying directives could make sense? This question has been raised in the proposal: "What are potential use-cases for directives that transform the actual behavior of an existing directive?"

In this article I'd like to contribute an answer to this question.

Issue: Applying directives on arrays

Let's imagine we have a directive @titleCase which can be applied on the field in the query, or in the field definition via the Schema Definition Language (SDL):

directive @titleCase on FIELD | FIELD_DEFINITION

This directive will transform the field from "hello world!" to "Hello World!", so it makes sense to apply it on fields of type String only. When running this query:

{
  post(id: 1) {
    title @titleCase
  }
}

...or using this schema definition:

type Post {
  title: String @titleCase
}

...will produce:

{
  "data": {
    "post": {
      "title": "Hello World!"
    }
  }
}

Now, let's say that the field type is [String], as in this case:

type Post {
  categories: [String]
}

What should happen when applying directive @titleCase on field categories when running this query?

{
  post(id: 1) {
    categories @titleCase
  }
}

Or, similarly, if applied on the SDL?

type Post {
  categories: [String] @titleCase
}

Ideally, the response will be a transformation of every String value inside the array:

{
  "data": {
    "post": {
      "categories": ["Software", "Web Development", "Mobile App"]
    }
  }
}

To make that happen, the implementation of directive @titleCase will need to check if the input is an array, and proceed accordingly. I use PHP for the examples below, but the same situation will happen with any language:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }

  // Convert the String value to title case
  return ucwords($value);
}

That's not very difficult. But then, what would happen if the field is an array of array of String, i.e. [[String]]? Even though a bit more difficult, the directive can deal with it also:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to title case
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(ucwords(...), $array),
      $value
    );
  }

  // Convert each item in an array to title case
  if ($schemaDef['isArray']) {
    return array_map(ucwords(...), $value);
  }

  // Convert the String value to title case
  return ucwords($value);
}

And then, what if it is a [[[String]]] or [[[[String]]]]? It starts getting difficult to implement.

Worse still, this additional logic boilerplate would need be implemented for any directive that could be applied on arrays. For instance, to implement a directive @upperCase, this extra logic will be required too:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // Convert each item in an array of arrays to uppercase
  if ($schemaDef['isArrayOfArrays']) {
    return array_map(
      fn (array $array) => array_map(strtoupper(...), $array),
      $value
    );
  }

  // Convert each item in an array to uppercase
  if ($schemaDef['isArray']) {
    return array_map(strtoupper(...), $value);
  }

  // Convert the String value to uppercase
  return strtoupper($value);
}

It doesn't look very pretty, right?

Solution: Modifying the input to a directive via another directive

This is where applying a directive to modify the behavior of another directive can prove useful.

Instead of dealing with every possible exponent of arrays for the field (i.e. String, [String], [[String]], [[[String]]], etc), @titleCase can just deal with the base case String:

function applyDirective(mixed $value, array $schemaDef): mixed
{
  // The input will always be `String`
  // Convert the String value to title case
  return ucwords($value);
}

And then, another directive @forEach can modify its behavior, by:

  1. Converting the single input of type [String] with an array of inputs of type String
  2. Iterating the items in this array and, for each, invoke and apply the downstream directive (@titleCase), which will then receive an input of type String
  3. Converting back the array of String values into a single [String] value

We can then execute this query:

{
  post(id: 1) {
    categories @forEach @titleCase
  }
}

Or apply it on the SDL:

type Post {
  categories: [String] @forEach @titleCase
}

This gif shows @forEach in action:

Adding @forEach to modify another directive

The beauty of this solution is that it decouples the depth of the array from the implementation of the directive. If the input is of type [[String]], all we need to do is add an additional @forEach, which will modify the @forEach that modifies the intended directive:

{
  customerAllNames @forEach @forEach @titleCase
}

...or:

type Query {
  customerAllNames: [[String]] @forEach @forEach @titleCase
}

...producing:

{
  "data": {
    "customerAllNames": [
      [
        "John", 
        "Edward", 
        "Stevenson"
      ],
      [
        "Samantha", 
        "Perkins"
      ],
      [
        "Michael", 
        "Edward", 
        "Higgs"
      ]
    ]
  }
}

So, as we can appreciate, a directive modifying a directive can also happen on a pipeline of directives, where one of them affects a downstream directive, and they are themselves modified by an upstream directive.

Since white spaces do not add semantic value, we can format the query and SDL to better convey the nesting:

{
  customerAllNames 
    @forEach 
        @forEach 
            @titleCase
}

...and:

type Query {
  customerAllNames: [[String]] 
    @forEach 
        @forEach 
            @titleCase
}

Defining a pipeline of nested directives

How does @forEach know that it must modify the behavior of @titleCase? In the previous example, it was because it was placed right before it. But what should happen when we have yet another directive right after them?

For instance, in this query:

{
  post(id: 1) {
    categories 
        @forEach 
            @titleCase 
            @translate(to: "es")
  }
}

...@forEach should also modify the behavior of directive @translate, since this directive must also be applied to a String, producing this response:

{
  "data": {
    "post": {
      "categories": [
        "Software", 
        "Desarrollo web", 
        "Aplicación movil"
        ]
    }
  }
}

However, a directive placed afterwards could also need be applied to the array, and not to the individual String value. For instance, directive @implode below merges all the entries in an array (and places the result back in the array, since directives should not change the type of the input), so it should not be affected by @forEach:

{
  post(id: 1) {
    categories 
        @forEach 
            @titleCase 
        @implode(separator: ", ")
  }
}

...producing this response:

{
  "data": {
    "post": {
      "categories": ["Software, Web Development, Mobile App"]
    }
  }
}

In order to distinguish between the two situations, we introduce argument affect to @forEach, which defines the relative position of the directives that must be affected, as an array of Int.

In the query below, @forEach knows it needs to be applied on @titleCase and @translate, since they are placed on relative positions 1 and 2 from itself:

{
  post(id: 1) {
    categories 
        @forEach(affect: [1, 2]) 
            @titleCase 
            @translate(to: "es")
  }
}

In this other query, @forEach is applied only on @titleCase (relative position 1) but not on @implode:

{
  post(id: 1) {
    categories 
        @forEach(affect: [1]) 
            @titleCase 
        @implode(separator: ", ")
  }
}

The default value for affect can be set to [1], so if not specified, the directive will always be applied to the directive right after it. The query above is then equivalent to this one:

{
  post(id: 1) {
    categories 
        @forEach 
            @titleCase 
        @implode(separator: ", ")
  }
}

Since the GraphQL syntax does not support defining composable directives, argument affect can fill the gap, allowing to clearly specify which other directive(s) must be affected:

{
  post(id: 1) {
    categories
      @forEach(affect: [1, 2])
        @titleCase
        @translate(to: "es")
      @implode(separator: ", ")
  }
}

Conclusion

Granting directives the ability to modify other directives, as proposed in PR #907, is a powerful feature. This article has illustrated a potential use case, where the directive @forEach could expand the usefulness of other directives in the schema while allowing them to reduce the complexity of their logic, and in a completely decoupled manner.


Editor’s note: Our thanks to Leo for another exploration of what's happening with the GraphQL spec and directives. You may have noticed that we're fans of directives :) In StepZen, GraphQL directives enable developers to easily customize their schemas- whether based on REST, database, or even GraphQL backends: (@rest, @dbquery, @graphql) - stitch schemas together using @materializer, and sequence queries using @sequence.

Check out the docs for more. We’d love to hear what you’re building and answer any questions over on the Discord Community!