Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Field arguments vs. directives in GraphQL

9 min read 2632

Field arguments vs. directives in GraphQL

The same functionality to modify the output of a field in GraphQL can often be achieved via two different methods:

  1. Field arguments: field(arg: value)
  2. Query-type directives: field @directive

Query-type directives are those applied to the query on the client-side, as contrasted to schema-type directives, which are applied via SDL when building the schema on the server-side. For instance, converting the response of a title field to uppercase could be achieved by passing a field arg format with an enum value UPPERCASE, like this:

{
  posts {
    title(format: UPPERCASE)
  }
}

or by applying a directive @upperCase to the field, like this:

{
  posts {
    title @upperCase
  }
}

In both cases, the response from the GraphQL server will be the same:

{
  "data": {
    "posts": [
      {
        "title": "HELLO WORLD!"
      },
      {
        "title": "FIELD ARGUMENTS VS DIRECTIVES IN GRAPHQL"
      }
    ]
  }
}

When should we use field arguments and when should we use query-side directives? Is there any difference between the two methods, or any situation when one option is better than the other? In this article, we will find out.

What is the difference between field arguments and directives in GraphQL?

Resolving a field in GraphQL involves two different operations:

  1. Fetching the requested data from the queried entity
  2. Applying functionality (such as formatting) on the requested data

We can label these two operations as “data resolution” and “applying functionality”, or, for short, as “data” and “functionality” respectively.

The main difference between field arguments and query-type directives is that field arguments can be used for both “data” and “functionality”, but directives can only be used for “functionality”.

Let’s see a bit more in detail what this means.

Resolving data via field arguments

Field arguments are processed when resolving the field; hence, they can be used to retrieve the actual data, such as deciding what property from the object is accessed.

For instance, this resolver for Apollo GraphQL shows how the argument size is used to fetch one or another property from an object corresponding to type Mission:

Mission: {
  // The default size is 'LARGE' if not provided
  missionPatch: (mission, { size } = { size: 'LARGE' }) => {
    return size === 'SMALL'
      ? mission.missionPatchSmall
      : mission.missionPatchLarge;
  },
},

Field args can also be used to help decide what row or column from the database table must be queried.

In this query, the field argument id is used to query some specific entity of type Post, which the resolver will translate to some specific row from WordPress’s wp_posts database table:

{
  post(id: 1) {
    title
  }
}

The same table stores the post’s date in two different columns, post_modified and post_modified_gmt (for backward-compatibility reasons). In this query%0A%20%20%7D%0A%7D), passing field argument gmt with true or false translates into fetching the value from one or the other column:

{
  post(id: 1) {
    title
    date(gmt: true)
  }
}

These examples demonstrate that field args can modify the source of the data when resolving the field.

Query-type directives cannot be used to modify the source of the data, however, because their logic is provided via directive resolvers, which are invoked after the field resolver. Hence, by the time the directive is applied, the field’s value must have been retrieved.

For instance, a field that retrieves a single entity, such as post, user or product, must always receive an ID, or the server wouldn’t know which entity to retrieve. So this query will never work:

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

In this situation, the id of the post entity can only be provided via a field argument. Since it is missing, the server will therefore return an error:

{
  "errors": [
    {
      "message": "Argument 'id' cannot be empty",
      "extensions": {
        "type": "QueryRoot",
        "field": "post @selectEntity(id:1)"
      }
    }
  ]
}

In conclusion, only field arguments can help retrieve the data that resolves the field.

Applying functionality via field arguments or directives

Once we retrieve the data for the field, we may want to manipulate its value. For instance, we could:

  • Format a string, converting it to uppercase or lowercase
  • Format a date represented with a string, from the default YYYY-mm-dd format to dd/mm/YYYY
  • Mask a string, replacing emails and telephone numbers with ***
  • Provide a default value if it is null or empty
  • Round floats to two digits

Any of these operations is a manipulation of the already-retrieved data. As a result, they can be coded both in the field resolver, right after fetching the data and before returning it, or in the directive resolver, which will get the field’s value as its input. As such, any of these operations can be implemented via either field arguments or query-type directives.



For instance, the field resolver for Post.title can provide a default value via field arg fallback:

// Resolvers
module.exports = {
  Post: {
    title: (post, { fallback } = { fallback: '' }) => {
      return post.title || fallback;
    }
  }
};

And then we can customize the value for the fallback arg in the query:

{
  posts {
    title(fallback: "(No title)")
  }
}

We can also create a @default directive, with a directive resolver like this:

/**
 * Replace all the empty results with the default value
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  foreach ($objectIDFields as $id => $fields) {
    $object = $objectsByID[$id];
    $defaultValue = $directiveArgs['value'];
    foreach ($fields as $field) {
      if (empty($responseByObjectIDAndField\[$id\][$field])) {
        $responseByObjectIDAndField\[$id\][$field] = $defaultValue;
      }
    }
  }
}

Are these two strategies equally suitable? Let’s explore this question based on different areas of interest.

Use field arguments for compatibility with the GraphQL spec, clients and tools

The extent to which directives are allowed to operate is not clearly defined in the GraphQL spec, which reads:

Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.

In some cases, you need to provide options to alter GraphQL’s execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

This definition consents to the use of query-type directives such as @include and @skip — which conditionally include and skip a field, respectively — and @stream and @defer — which provide a different runtime execution for retrieving data from the server.

However, this definition is not unambiguous concerning query-type directives that modify the value of a field, such as @upperCase, which transforms the output value "Hello world!" into "HELLO WORLD!".

Due to this ambiguity, different GraphQL servers, clients, and tools may take directives into account to different extents, creating conflicts among them.

For instance, when working with Relay, we must notice that it does not take directives into account for caching field values. As a consequence, if using the GraphQL API for WordPress as our server, we may need to avoid using some of its directives, such as @upperCase, @lowerCase, and @titleCase. Otherwise, Relay could have its cache produce a wrong value when the same field is queried with and without the directive.

For instance, if first querying:

{
  post(id: 1) {
    title
  }
}

Relay will query and cache value "Hello world!" for a post with the ID 1. If then we run this query:

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

the response should be "HELLO WORLD!"; however, Relay will return "Hello world!", which is the value stored in its cache for the post with the ID 1, ignoring the directive applied to the field.

Whether or not directives are allowed to modify the field’s output value is in a gray area, since it is neither explicitly allowed nor disallowed in the GraphQL spec; yet, there are indicators for both opposite situations.

On one side, the GraphQL spec seems to grant directives a free hand to improve and customize GraphQL:

As future versions of GraphQL adopt new configurable execution capabilities, they may be exposed via directives. GraphQL services and tools may also provide any additional custom directive beyond those described here.

On the other hand, the spec does not take directives into account for the FieldsInSetCanMerge validation or the CollectFields algorithm. As spec contributor Benjie Gillam points out, the following GraphQL query is valid, yet it is uncertain what response the user will obtain:

{
  user(id: 1) {
    name
    name @upperCase
    name @lowercase
  }
}

Depending on the behavior from the GraphQL server, the response for field name may be "Leo", "LEO", or "leo" — and it’s a problem that we don’t know what it will return in advance.


More great articles from LogRocket:


The same issue does not happen when using field arguments. When the following query is executed:

{
  user(id: 1) {
    name
    name(format: UPPERCASE)
    name(format: LOWERCASE)
  }
}

the spec dictates the GraphQL server to return an error, so the value for name will be null. We would then be forced to introduce aliases to execute the query:

It is possibly due to these reasons that we are discouraged from using query-type directives within the GraphQL ecosystem. GraphQL Tools, for instance, advises against them (even though it still allows them):

Directive syntax can also appear in GraphQL queries sent from the client. […] In general, however, schema authors should consider using field arguments wherever possible instead of query directives.

To summarize, we must use field arguments if we want to be on the safe side. Using query-type directives should be avoided, unless we can guarantee that there are no conflicts within the employed stack (GraphQL server, clients, and tools), or when the API is for internal use only and we know what we’re doing. In this case, there are good reasons to use query-type directives, as we shall see next.

Directives are better for modularity and code reusability

Many of the operations offered by query-type directives are agnostic of the entity and field where they are applied. For instance, @upperCase will work on any string, whether it’s applied to a post’s title, a user’s name, a location’s address, or anything else. As a consequence, the code for this directive is implemented only once and in a single place: the directive resolver. Similar to aspect-oriented programming, which increases modularity by allowing the separation of cross-cutting concerns, directives are applied to the field without affecting the field’s logic. In contrast, implementing the same functionality via a field argument involves executing the same code across the different field resolvers:

const formatString = (string, format) => {
  if (format === "UPPERCASE") {
    return string.toUpperCase();
  }
  if (format === "LOWERCASE") {
    return string.toLowerCase();
  }
  return string;
};

// Resolvers
module.exports = {
  Post: {
    title: (post, { format }) => {
      return formatString(post.title, format);
    },
    excerpt: (post, { format }) => {
      return formatString(post.excerpt, format);
    },
    content: (post, { format }) => {
      return formatString(post.content, format);
    },
  },
  User: {
    name: (user, { format }) => {
      return formatString(user.name, format);
    },
    description: (user, { format }) => {
      return formatString(user.description, format);
    },
  },
};

Middleware can improve the architecture of this solution:

async function middleware({ root, { format }, context, info }, next) {

  const string = await next();

  if (format === "UPPERCASE") {
    return string.toUpperCase();
  }
  if (format === "LOWERCASE") {
    return string.toLowerCase();
  }
  return string;
}

But directives are more modular and reusable than middleware because directives and fields can be completely decoupled from each other, so that they come in contact with each other only at runtime, not at schema build-time.

So, it’s safe to say that if the goal is to reduce the amount of code in the resolvers, then query-type directives are more suitable than field arguments.

Directives may be better for working with opinionated CMSs

When providing a new functionality via a field argument, we will need to modify the schema and recompile it. But that may not be possible when working with some stacks.

For instance, WordPress allows its users to inject functionality via plugins, with the expectation that they will not have to touch a line of code.

In this scenario, directives provided via plugins can be plug-and-play, since directives are applied on runtime. In contrast, adding arguments to the relevant fields from the schema most likely requires modifying code, which may prevent the plugin from working immediately upon activation.

In conclusion, query-type directives may be more suitable for making GraphQL adhere to the philosophy of the opinionated CMS on which it runs.

Directives are better for schema design

Adding field arguments will add extra information to the schema, possibly bloating it and making it inconsistent.

For instance, the field argument format will need to be added to all String fields, and, if we are not careful, it may not be homogeneous across fields — it may use different names, different values, different default values, or even split the argument into several inputs:

type Post {
  # Input value is "uppercase" or "lowercase"
  title(format: String): String
  content(format: String): String
  excerpt(format: String): String
}

type Category {
  # Input name is "case" instead of "format"
  # Input value is an enum StringCase with values UPPERCASE and LOWERCASE
  name(case: StringCase): String
}

type Tag {
  # Using a default value
  name(format: String = "lowercase"): String
}

type User {
  # Using multiple Boolean inputs
  description(useUppercase: Boolean, useLowercase: Boolean): String
}

Directives allow us to keep the schema as lean as possible:

directive @upperCase on FIELD
directive @lowerCase on FIELD

type Post {
  title: String
  content: String
  excerpt: String
}

type Category {
  name: String
}

type Tag {
  name: String
}

type User {
  description: String
}

In conclusion, using query-type directives can produce more elegant GraphQL schemas than using field arguments.

Directives can be more efficient than field arguments

On execution time, a field argument will be accessed when resolving the field, which happens on a field-by-field and object-by-object basis. For instance, when resolving the fields title and content on a list of posts, the resolver will be invoked once per post and field:

// Resolvers
module.exports = {
  Post: {
    title: (post, args) => {
      return post.title;
    },
    content: (post, args) => {
      return post.content;
    },
  }
};

Imagine that we want to translate these strings using the Google Translate API, for which we add argument translateTo:

const executeGoogleTranslate = (string, lang) => {
  // Execute against https://translation.googleapis.com
  return ...
};

module.exports = {
  Post: {
    title: (post, { translateTo }) => {
      return executeGoogleTranslate(post.title, translateTo);
    },
    content: (post, { translateTo }) => {
      return executeGoogleTranslate(post.content, translateTo);
    },
  }
};

We may end up requesting a great number of connections to the external API because the logic is naturally executed per combination of field and object. This may slow down the response to resolve the query.

In addition, executing the calls independently from each other will not allow Google Translate to associate their data, so the quality of the translation will be inferior than it would be if all data were submitted together in a single API call.

For instance, a post title "Power" can be better translated if the post content, which makes it evident this word refers to “electrical power”, is submitted together with it.

GraphQL servers can opt to provide a better experience concerning directives, where a directive may be invoked only once, passing all fields and objects to be applied to as input. That has been the design strategy concerning directives for the server GraphQL API for WordPress, which has this signature for its DirectiveResolver:

/**
 * All fields and objects are passed together to the executed directive
 */
function resolveDirective(
  array $directiveArgs,
  array $objectIDFields,
  array $objectsByID,
  array &$responseByObjectIDAndField
): void {
  // ...
}

By receiving all data all together, the @translate directive can execute a single call to Google Translate passing along all title and content fields for all objects, as in this query:

{
  posts(limit: 6) {
    title @translate(from:"en", to:"fr")
    excerpt @translate(from:"en", to:"fr")
  }
}

In conclusion, even though not all GraphQL servers take advantage of this architectural design, directives can provide a more performant way to modify the value of the fields, such as when interacting with external APIs.

Conclusion

If the same functionality can be accomplished via field arguments or query-type directives, why should we be choosy about using one method over the other?

Query-type directives are valuable, providing code reusability and allowing us to declutter the schema. Unfortunately, they are currently in a gray area in the GraphQL spec — not fully supported, yet not banned, either. With such ambiguity, GraphQL servers, clients, and tools may deal with directives in different ways, possibly producing incompatibilities among them.

As a consequence, it’s advised to use field arguments to be on the safe side, or query-type directives — only if you know what you’re doing.

Monitor failed and slow GraphQL requests in production

While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
Leonardo Losoviz Freelance developer and writer, with an ongoing quest to integrate innovative paradigms into existing PHP frameworks, and unify all of them into a single mental model.

Leave a Reply