The same functionality to modify the output of a field in GraphQL can often be achieved via two different methods:
field(arg: value)
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.
Resolving a field in GraphQL involves two different operations:
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.
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.
Once we retrieve the data for the field, we may want to manipulate its value. For instance, we could:
YYYY-mm-dd
format to dd/mm/YYYY
***
null
or emptyAny 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.
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.
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.
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.
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.
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.
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.
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.
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. Start monitoring for free.Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.