This document is a work-in-progress exploration of how the "oneOf" solution to input polymorphism might work within a GraphQL schema.
For the examples below, we'll be using the following shared types using existing GraphQL syntax:
type Person {
id: ID!
name: String!
}
type Cat {
id: ID!
name: String!
owner: Person
numberOfLives: Int
}
input CatInput {
name: String!
numberOfLives: Int
}
type Dog {
id: ID!
name: String!
owner: Person
breed: String
}
input DogInput {
name: String!
breed: String
}
"""
Some people keep cats and dogs as pets; some people keep colonies of creatures.
"""
type Colony {
id: ID!
colonyType: ColonyType
owner: Person
}
enum ColonyType {
WORM
ANT
BEE
}
In solution A, the input is a single-key map from a string to the associated
concrete input type (input object type, scalar, enum, etc), for example
{ "cat": { "name": "Felix", "numberOfLives": 9 } }
.
The following syntactic variants all have identical meanings; they're just different ways of expressing this concept through SDL.
(Note the scalars integer
and rational
have been added to demonstrate
flexibility and lack of ambiguity; it's not expected that many people keep
numbers as pets.)
input PetInput @oneOf {
cat: CatInput
dog: DogInput
colony: ColonyType
integer: Int
rational: Float
}
inputUnion PetInput {
cat: CatInput
dog: DogInput
colony: ColonyType
integer: Int
rational: Float
}
inputUnion PetInput =
| { cat: CatInput }
| { dog: DogInput }
| { colony: ColonyType }
| { integer: Int }
| { rational: Float }
inputUnion PetInput =
| { cat: CatInput! }
| { dog: DogInput! }
| { colony: ColonyType! }
| { integer: Int! }
| { rational: Float! }
This solution is a more restricted form of Solution A, where the map key must be
the exact type name of the input, for example
{ "CatInput": { "name": "Felix", "numberOfLives": 9 } }
.
This enables all the syntaxes above (although they'd look a lot more redundant), plus this further more beautiful syntax:
inputUnion PetInput =
| CatInput
| DogInput
| ColonyType
| Int
| Float
(This variant is slightly less flexible, in that you cannot have the same type referenced in two different ways (e.g. using String twice), but this restriction may be a benefit in terms of clarity.)
Imagine that we had the following mutation:
extend type Mutation {
addPets(pets: [PetInput!]!): [Pet!]
}
In all of the Solution A cases, we could issue the mutation via this same operation document:
mutation($pets: [PetInput!]!) {
addPets(pets: $pets) {
id
}
}
and for all of them, the variables would be the same:
{
"pets": [
{ "cat": { "name": "Felix", "numberOfLives": 9 } },
{ "dog": { "name": "Buster" } },
{ "colony": "WORM" },
{ "integer": "42" },
{ "rational": "3.141592653589793" }
]
}
Same mutation and operation document as Solution A, but the JSON would be more explicit:
{
"pets": [
{ "CatInput": { "name": "Felix", "numberOfLives": 9 } },
{ "DogInput": { "name": "Buster" } },
{ "ColonyType": "WORM" },
{ "Int": "42" },
{ "Float": "3.141592653589793" }
]
}
You could nest oneOf unions; e.g.
inputUnion MediaInput =
| BookInput
| DVDInput
inputUnion SourceInput =
| LibraryInput
| OnlineVideoRentalInput
input BookInput {
title: String!
numberOfPages: Int
availableFrom: [SourceInput!]!
}
input DVDInput {
title: String!
durationInMinutes: Float
availableFrom: [SourceInput!]!
}
input LibraryInput {
name: String!
address: Address
}
input OnlineVideoRentalInput {
name: String!
website: String!
}
Input of a MediaInput
might look like:
{
"DVDInput": {
"title": "The Matrix",
"durationInMinutes": 150.3,
"availableFrom": [
{
"LibraryInput": {
"name": "Mytown Library"
}
},
{
"OnlineVideoRentalInput": {
"name": "1-line Vidz",
"website": "http://example.com/1-line"
}
}
]
}
}
The oneOf
principle could also be applied to outputs; here are some equivalent
syntaxes:
type Pet @oneOf {
cat: Cat
dog: Dog
colony: ColonyType
integer: Int
rational: Float
}
(Note: I've used the union
keyword here, overloading union
with two forms,
but we could just as easily use a different keyword such as oneOf
.)
union Pet {
cat: Cat
dog: Dog
colony: ColonyType
integer: Int
rational: Float
}
union Pet =
| { cat: Cat }
| { dog: Dog }
| { colony: ColonyType }
| { integer: Int }
| { rational: Float }
union Pet =
| { cat: Cat! }
| { dog: Dog! }
| { colony: ColonyType! }
| { integer: Int! }
| { rational: Float! }
For example, if you had the GraphQL schema:
extend type Query {
pets: [Pet!]
}
You could issue this query:
{
__typename
pets {
__typename
cat {
__typename
name
numberOfLives
}
dog {
__typename
name
}
colony
integer
rational
}
}
and you might get a response such as:
{
"data": {
"__typename": "Query",
"pets": [
{
"__typename": "Pet",
"cat": {
"__typename": "Cat",
"name": "Felix",
"numberOfLives": 9
}
},
{
"__typename": "Pet",
"dog": {
"__typename": "Dog",
"name": "Buster"
}
},
{ "__typename": "Pet", "colony": "WORM" },
{ "__typename": "Pet", "integer": "42" },
{ "__typename": "Pet", "rational": "3.141592653589793" }
]
}
}
💡 It might seem weird at first that __typename
is returning Pet
which we
don't think of as a concrete type. However, it actually is a concrete type with
known fields and the special semantic that exactly one non-introspection field
is non-null. Effectively Pet
is very similar to a regular object type.
This has one drawback in that if the relevant type was not queried, we do not know what it was, whereas with regular unions we could at least get its type name. This is more relevant to debugging than it is to application users.
In summary, the main differences between querying oneOf
unions vs regular
unions are:
oneOf
unions would not require fragmentsoneOf
results follow the "nested" / "wrapper object" pattern- a
__typename
directly in the selection set for theoneOf
would return theoneOf
's type name rather than the concrete type (see 💡 above).
As Solution A, but with the field name restriction. Here we'd need to use a new
keyword to avoid syntax conflict with the union
type.
oneOf Pet =
| Cat
| Dog
| ColonyType
| Int
| Float
{
"data": {
"__typename": "Query",
"pets": [
{
"__typename": "Pet",
"Cat": {
"__typename": "Cat",
"name": "Felix",
"numberOfLives": 9
}
},
{
"__typename": "Pet",
"Dog": {
"__typename": "Dog",
"name": "Buster"
}
},
{ "__typename": "Pet", "Colony": "WORM" },
{ "__typename": "Pet", "Int": "42" },
{ "__typename": "Pet", "Float": "3.141592653589793" }
]
}
}
oneOf could also be a way of reducing the number of "fetcher" fields required in your GraphQL schema; for example, an existing GraphQL schema might have person-finder fields such as:
extend type Query {
personById(id: ID!): Person
personByUsername(username: String!): Person
personByEmail(email: String!): Person
parentOfChild(childId: ID!): Person
departmentHead(organizationId: ID!, department: Department!): Person
}
This could be simplified to a single person fetcher field:
input OrganizationAndDepartmentInput {
organizationId: ID!
department: Department!
}
extend type Query {
person(
id: ID
username: String
email: String
childId: ID
organizationAndDepartment: OrganizationAndDepartmentInput
): Person
}
However, currently this loses type safety on the arguments and punts the problem of validation to the resolver.
Using a "oneOf" approach could restore this type safety, and could be applied retro-actively to schemas of the above shape and move the validation errors from the execution phase to the validation phase. The following syntaxes are equivalent:
extend type Query {
person(
id: ID
username: String
email: String
childId: ID
organizationAndDepartment: OrganizationAndDepartmentInput
): Person @oneArgument
}
extend type Query {
person(
@oneOf
id: ID
username: String
email: String
childId: ID
organizationAndDepartment: OrganizationAndDepartmentInput
): Person
}
extend type Query {
person(
id: ID @oneOf
username: String @oneOf
email: String @oneOf
childId: ID @oneOf
organizationAndDepartment: OrganizationAndDepartmentInput @oneOf
): Person
}
extend type Query {
person(
| { id: ID }
| { username: String }
| { email: String }
| { childId: ID }
| { organizationAndDepartment: OrganizationAndDepartmentInput }
): Person
}
inputUnion PersonFinderInput =
| { id: ID }
| { username: String }
| { email: String }
| { childId: ID }
| { organizationAndDepartment: OrganizationAndDepartmentInput }
extend type Query {
person(PersonFinderInput): Person
}
I personally love this syntax and it would syntactically solve a search functionality I want to implement:
This would allow for queries like:
The following input would find all cat owners named "bob" who also have a labrador retriever OR any owner with a dog named "fluffy"