The "oneof" input object is a new feature proposed for the GraphQL spec. Even though it's still on the RFC 1 stage (so that there are two more stages to go), the Working Group appears to have agreed on the way forward about the proposal, so it's only a matter of time until it is merged into the spec.

This new feature will undoubtedly have a deep impact in GraphQL. In this article, I'll describe how it works, and several use cases on when to use it.

What is the oneof input object

The oneof input object is a particular type of input object, where exactly one of the input fields must be provided as input, or otherwise the server returns a validation error. This behavior will introduce polymorphism for inputs in GraphQL, allowing us to design neater schemas.

For instance, retrieving a user in our application could be done by different properties, such as the user ID or email. Right now, to do this, we may need to create a separate field for each property:

type Query {
    userByID(id: ID!): User
    userByEmail(email: String!): User
}

With the new feature, we can have a single field user that accepts all properties via a UserByInput oneof input object, knowing that only one of the properties (either the ID or the email) can be provided:

type Query {
    user(by: UserByInput!): User
}

input UserByInput @oneOf {
    id: ID
    email: String
}

In the query, we provide the input value for exactly one of the properties:

{
    tom: user(by: { id: 1 }) {
        name
    }

    jerry: user(by: { email: "jerry@warnerbros.com" }) {
        name
    }
}

If we provide two (or more) values to the input:

{
    user(by: { id: 1, email: "jerry@warnerbros.com" }) {
        name
    }
}

... then the server will return an error:

{
    "errors": [
        {
            "message": "The oneof input object 'UserByInput' must be provided exactly one value, but 2 have been provided",
            "extensions": {
                "type": "Query",
                "field": "user(by:{id:1,email:\"jerry@warnerbros.com\"})",
                "argument": "by"
            }
        }
    ],
    "data": {
        "user": null
    }
}

Oneof syntax

Contributors have had plenty of deliberation concerning if a new syntax should be introduced or not. The main considered options are:

  • using a directive @oneOf, which is already supported by GraphQL, or
  • introducing brand-new syntax, such as via a new keyword tagged or oneof

The winner solution, as of today, seems to be the @oneOf directive. Regardless of the syntax employed, though, the underlying logic of how this feature works does not change: allowing only "one of" the possible input values to be provided at a time.

When to use oneof

Let's see a few examples when we can use this new feature.

Selecting a single entity by different properties

This is the general case for the query demonstrated above, concerning input UserByInput in field user.

Whenever we need to fetch a single entity (a single User, Post, Client, Book, etc) that can be uniquely identified by more than one property (such as by ID or email, ID or slug, ID or ISBN, etc), then we can define all different properties into a oneof input object, and converge all different fields to retrieve that entity into a single field.

Accepting different sets of data in mutations

When doing a mutation, we may accept different sets of data as inputs. Instead of exposing different mutation fields for each different set of data, by using a oneof input object, a single mutation field can tackle all possibilities.

For instance, a mutation addComment (which allows visitors to add a comment to the site) can support both logged-in and non-logged-in visitors. If they are logged-in, only the user ID is required to add the comment; otherwise, they must provide their name and email, and optionally an URL:

type Mutation {
    addComment(by: AddCommentByInput!): CommentPayload
}

input AddCommentByInput @oneOf {
    loggedInUserID: ID
    visitorData: AddCommentByUserDataInput
}

input AddCommentByUserDataInput {
    name: String!
    email: String!
    url: URL
}

Please notice how the oneof input object can receive any type of input: while loggedInUserID is a built-in scalar, visitorData is a custom input object, and this input also has its own validations: while the name and email are mandatory, the url is not (these validations only kick in if the visitorData input is the one provided).

Another example is for logging the user in by different methods, such as JWT and website credentials:

type Mutation {
  loginUser(by: LoginUserInput!)
}

input LoginUserInput @oneOf {
  jwt: String
  credentials: LoginUserCredentialsInput
}

input LoginUserCredentialsInput {
  username: String!
  password: String!
}

Fixing how we interact with legacy software

The oneof input object allows to unlock new behavior depending on the provided inputs, as was shown above with visitorData, where its own validations are triggered only if this input is provided.

We can benefit from this behavior when the underlying business logic is obtuse, such as when it allows for invalid or contradictory inputs to be provided, and the application relies on the developer reading documentation to understand how it works. Thanks to the oneof input object, we can instead have the GraphQL layer on top of the business logic layer directly perform strict validations, and reject the invalid combinations of inputs.

WordPress provides an example of this behavior when filtering data by a meta keys or values, as explained in its documentation:

The following arguments can be passed in a key=>value paired array.

  • meta_query (array) – Contains one or more arrays with the following keys:

  • key (string) – Custom field key.

  • value (string|array) – Custom field value. It can be an array only when compare is 'IN', 'NOT IN', 'BETWEEN', or 'NOT BETWEEN'. You don’t have to specify a value when using the 'EXISTS' or 'NOT EXISTS' comparisons in WordPress 3.9 and up. (Note: Due to bug #23268, value was required for NOT EXISTS comparisons to work correctly prior to 3.9. You had to supply some string for the value parameter. An empty string or NULL will NOT work. However, any other string will do the trick and will NOT show up in your SQL when using NOT EXISTS. Need inspiration? How about 'bug #23268'.)

  • compare (string) – Operator to test. Possible values are ‘=’, ‘!=’, ‘>’, ‘>=’, ‘<‘, ‘<=’, ‘LIKE’, ‘NOT LIKE’, ‘IN’, ‘NOT IN’, ‘BETWEEN’, ‘NOT BETWEEN’, ‘EXISTS’ (only in WP >= 3.5), and ‘NOT EXISTS’ (also only in WP >= 3.5). Values ‘REGEXP’, ‘NOT REGEXP’ and ‘RLIKE’ were added in WordPress 3.7. Default value is ‘=’.

What's going on there? The documentation explains that value can be a string or an array, and depending on this value, then compare can accept one set of values or another (such as IN only for arrays, LIKE only for strings). In addition, value is mandatory, but only if compare does not receive EXISTS, in which case value is not needed at all.

Analyzing the different input sets we will discover that there are 4 possible combinations, depending on the comparison being applied on the key or the value, and the type of value:

  • key
  • numericValue
  • stringValue
  • arrayValue

We can create a oneof input object MetaQueryCompareByInput with these 4 inputs, and different Enums to define the possible operators that each input can use. Then, filtering by numericValue we can use operator GREATER_THAN, by arrayValue we can use operator IN, and by key we can use operator EXISTS (and there's no need to provide a value).

The GraphQL schema, which conceals the awkwardness of the business logic, is this one:

type Query {
    posts(filter: PostsFilterInput): [Post!]!
}

input PostsFilterInput {
    metaQuery: [PostMetaQueryInput!]
}

input PostMetaQueryInput {
    compareBy: MetaQueryCompareByInput!
    key: String!
}

type MetaQueryCompareByInput @oneOf {
    """
    Compare against the meta key
    """
    key: MetaQueryCompareByKeyInput

    """
    Compare against an array meta value
    """
    array: ValueMetaQueryCompareByArrayValueInput

    """
    Compare against a numeric meta value
    """
    numeric: ValueMetaQueryCompareByNumericValueInput

    """
    Compare against a string meta value
    """
    string: ValueMetaQueryCompareByStringValueInput
}

input MetaQueryCompareByKeyInput {
    operator: MetaQueryCompareByKeyOperatorEnum!
}

enum MetaQueryCompareByKeyOperatorEnum {
    EXISTS
    NOT_EXISTS
}

input ValueMetaQueryCompareByArrayValueInput {
    operator: MetaQueryCompareByArrayValueOperatorEnum!
    value: [AnyBuiltInScalar!]!
}

# AnyBuiltInScalar: Int, Float, String or Bool
scalar AnyBuiltInScalar

enum MetaQueryCompareByArrayValueOperatorEnum {
    BETWEEN
    IN
    NOT_BETWEEN
    NOT_IN
}

input ValueMetaQueryCompareByNumericValueInput {
    operator: MetaQueryCompareByNumericValueOperatorEnum!
    value: Numeric!
}

enum MetaQueryCompareByNumericValueOperatorEnum {
    EQUALS
    GREATER_THAN
    GREATER_THAN_OR_EQUAL
    LESS_THAN
    LESS_THAN_OR_EQUAL
    NOT_EQUALS
}

# Numeric: Float or Int
scalar Numeric

input ValueMetaQueryCompareByStringValueInput {
    operator: MetaQueryCompareByStringValueOperatorEnum!
    value: String!
}

enum MetaQueryCompareByStringValueOperatorEnum {
    EQUALS
    LIKE
    NOT_EQUALS
    NOT_LIKE
    NOT_REGEXP
    REGEXP
    RLIKE
}

This way, by choosing what input to use under compareBy, the correctness of the overall input data set will be validated by GraphQL. Now, when filtering posts where some meta key exists we cannot provide a value:

{
    posts(
        filter: {
            metaQuery: {
                key: "_thumbnail_id"
                compareBy: { key: { operator: EXISTS } }
            }
        }
    ) {
        id
        title
        metaValue(key: "_thumbnail_id")
    }
}

To filter posts "liked" by some user we use input arrayValue, and select the operator IN:

query FilterPostsLikedByUser($userID: ID!) {
    posts(
        filter: {
            metaQuery: {
                key: "liked_by_users"
                compareBy: { arrayValue: { value: $userID, operator: IN } }
            }
        }
    ) {
        id
        title
    }
}

Conclusion

The oneof input object is "one of" the most significant proposals for a new feature in GraphQL. Its impact will be felt in the quality of our GraphQL schemas, becoming more decluttered and understandable. Let's hope it is merged into the GraphQL spec soon!


Editor’s note: Our thanks to Leo for another great update on the GraphQL Spec. Check out our docs for more on GraphQL basics, and we’d love to hear what you’re building over on Discord!