Polymorphic-capable composite symmetric input/output type (`struct`)
At a glanceβ
- Identifier: Struct
- Stage: RFC0: Strawman
- Champion: -
- PR: -
- Related:
- InputUnion (GraphQL Input Union)
- wg#1071 (Struct type)
Timelineβ
- RFC document updated on 2023-11-28 by Benjie
- RFC document updated on 2023-08-24 by Tushar Mathur
- RFC document updated on 2023-01-19 by Benjie
- RFC document updated on 2022-10-24 by Alexander Varwijk
- RFC document updated on 2022-10-20 by Mark Larah
- RFC document updated on 2022-07-22 by Benjie Gillam
- RFC document created on 2022-07-07 by Benjie Gillam
Polymorphic-capable composite symmetric input/output type (
struct
)a.k.a "Structured, Type-safe, Returnable and Union-Capable Type" S.T.R.U.C.T.
GraphQL currently has two types that are suitable for both input and output: scalar and enum. These are both "leaf" types that generally1 possess no structure.
GraphQL currently has one composite type that is suitable for input: the input object type. This type possesses structure, but is only available on input (and does not support polymorphism).
This proposal is for a composite type (a type with structure) which is available on both input and output, and which supports polymorphism.
Why?β
A polymorphic-capable composite type that's available on both input and output would address a number of use cases, so lets dig into them individually:
Scalar with structureβ
Sometimes the data that a leaf field resolver returns may have structure, represented via something like a custom scalar (e.g. 'JSON'). It would be nice to share the schema of this data with the client to enable the client to understand it better; this may be solved in part with
@specifiedBy
, but it would be beneficial to leverage the capabilities that GraphQL already has.Composite type capable of input polymorphismβ
The search for an "input union" type has been around for a long time; many of the use cases and proposed solutions to this problem are discussed in the InputUnion RFC. The author of this RFC was also the author of three other RFCs to address the input union problem, the leading one at this time being the
@oneOf
type. Please see the InputUnion RFC for more details of why this feature is desired in GraphQL.Symmetric polymorphismβ
One of the "high priority" desires for an InputUnion solution is "input polymorphism matches output polymorphism." A union of structs would be suitable for use on both input and output, since structs themselves are.
Non-infinite recursionβ
There have been a good few times that non-infinite recursion has been asked for in GraphQL. Here's just a couple of the conversations on this topic:
- https://github.com/graphql/graphql-spec/issues/237
- https://github.com/graphql/graphql-spec/issues/929
Even introspection would benefit from non-infinite recursion - it would allow to request the full type of a field no matter how many list wrappers the type contained. Currently the best we have is to repeat a structure in the query:
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
# repeated 5 more times...A use case that I've had for this non-infinite recursion is trying to generate a "table of contents" for a tree structure (a DAG in my case). I resorted to the
JSON
type, another solution is to turn the tree into a list, and turn the list back into a tree on the client. A better solution would be to stick to the tenets of GraphQL and return the data in the shape that the client needs, fully type-safe.Symmetric transput composite typeβ
Currently when a user wants to be able to input and output the exact same data in a future-proof way, they must use a scalar or enum. This is one of the things that has made solutions like the 'JSON' scalar quite popular, but it lacks strong type safety guarantees.
In the GraphQL spec we use
String
to represent things like thedefaultValue
of an argument or input field. Having to parse a string is not ideal; a type that is symmetric over input and output would allow us to representdefaultValue
in a much easier to consume way.When thinking about schema metadata, a type that can both be input (e.g. via directives or similar) and output identically would be quite desirable.
If your GraphQL API supports complex filtering or aggregate queries (for example if you're building a reporting dashboard or similar), you may want to be able to both issue queries using these advanced parameters, and to store them to/load saved versions of them from the backend. Having the data for this be symmetric would be beneficial. Doing this with object type and input type is currently unsafe as your API evolves as older clients will not request properties they did not know about, potentially resulting in data loss; if we adopt wildcard selection over structs (as discussed below) we may solve this issue.
Wildcard selectionβ
This has been discussed many times:
- https://github.com/graphql/graphql-spec/issues/127
- https://github.com/graphql/graphql-spec/issues/147
- https://github.com/graphql/graphql-spec/issues/942
GraphQL's reasoning for not performing wildcard selection with object types is very sensible, and I do not argue against that in any way (I fully support it).
However, imagine that you're dealing with a concrete piece of data that has structure, such as a time interval. In this case, you might have something like:
{months: 2, days: 8, hours: 23}
. Since this is pure data, it does not have arguments, it does not have relations to other types (the entire type is effectively a "leaf" of our graph), and it only makes sense when you have the whole value. If you were to adddecades: Int
to this type, it would be essential that old clients pulled this field down, and baulked if they did not know how to handle it - it would be unsafe for them to only process themonths
,days
andhours
if there was also adecades
field that they could not have known about. Currently your best bet to deal with this is a custom scalar for the entireInterval
type, but that loses details about this structure.It's possible to imagine deeper structures than
Interval
that have these same concerns, perhaps things like the save formats for WYSIWYG editors or similar, all of which want to act like ascalar
(i.e. no resolvers within them, no arguments, just pure data), and yet have composite structure represented by the schema.On top of this, there are situations where you might want to make explicit sub-selections. For example, consider a
GeoJSON
value - you may wish to only query thetype
andgeometry
so that you can plot it on a map, and not concern yourself with the (potentially multi-megabyte) additional properties.Allowing for wildcard selection along with explicit selection for these structured scalar-like pure data types is desirable.
Another example of where this is useful is with traditional RESTful APIs which use the
PUT
(rather thanPATCH
) mechanic - i.e. they replace the entire object rather than "patching" it. To allow us to do this in a future compatible way (allowing for the object to gain more properties over time) we must be careful not to accidentally drop properties that we do not understand, so we should pull down the entire object, modify the parts that we want to (leaving parts we don't understand alone) and then send the entire (modified) object back to the server.What could it look like?β
There are two types that are close to solving this problem already:
- Input objects are already a structured input that handle many of our concerns, but they're only valid on input
- Scalars are already available on both input and output, and users can define their own
I see three approaches that we could take that would all be broadly similar in terms of achieving our goals, but have slightly different trade-offs:
- give
scalar
optional fields, turning it intro astruct
- enable
input
to be used in output too, turning it into astruct
- introduce a new
struct
typeI've gone full circle on this, and am currently thinking that option 1 is our best bet.
Option 2 is tempting because in the other scenarios
struct
andinput
will overlap significantly and it will be less than clear when to use one rather than the other. However, it suffers from some backwards-compatibility concerns - namely that an input object being used on output is likely to confuse existing tooling such as GraphiQL, and it's entirely unknown to them whether an input object should requireβ a selection set or not.Option 1 feels like it could be done in a more backwards-compatible manner since the majority of existing tools and clients would not look at the
fields
entry on a scalar (so would not be confused by it) and already handle custom scalars such as theJSON
scalar returning structured output, and can continue to omit a selection set on this enhanced scalar, thus continuing to operate correctly.NOTE: this syntax, these keywords, etc. are not set in stone. Discussion is very welcome!
struct
βNo matter which of the three approaches we take (scalar, input, new type), the resulting type (
struct
) will be composed of fields, each field has a type, and the type of astruct
field can be astruct
, scalar, enum, struct union, or a wrapping type over any of these. (i.e. it is only composed of types that are valid in both input and output.)Importantly, when querying a
struct
you can never reach an object type, union or interface from within astruct
- the entirestruct
acts like a leaf (a leaf with structure).Similarly, for input, a
struct
may never contain an input object (unless that input object is, itself, a struct).Similar to input objects, a
struct
may not contain an unbreakable cycle.A struct might look something like this:
struct Biography {
title: String!
socials: BiographySocials
paragraphs: [Paragraph!]!
}(Note how this is basically identical to an
input
definition.)Pure structured dataβ
Like scalars, structs represent pure data that is the same on input as on output. Unlike scalars, however, structs have structure (and thus fields) - but since they're just pure data their fields do not have resolvers, and do not accept arguments. They are NOT a replacement for Objects!
__typename
β
__typename
is available to query on allstruct
s. This ensures that clients that automatically and silently add the__typename
meta-field to every selection set will not break, and is could also be useful for resolving the type of a struct union.On input of a
struct
,__typename
is optional but recommended.Champion's note: if we were to make
__typename
required on allstruct
inputs then changing an input type fromstruct
to struct union (see below) would be non-breaking; this is a nice advantage, but is currently outweighed by the desire to make input pleasant for users, and to makestruct
compatible with existing input objects.Struct unionβ
A struct union is an abstract type representing that the value may be one of many possible
struct
s. Rather than requiring a new type, we can define a struct union as a union that only contains structs:union Paragraph =
TextParagraph
| PullquoteParagraph
| BlockquoteParagraph
| TweetParagraph
| GalleryParagraph(A
union
must only consist of object types, or only of structs, never a mix of the two.)Champion's note: We could use a different keyword, such as
structUnion
, to enforce this, but I'm not convinced this is necessary.)Champion's note: I'm very open to dropping struct union and using a oneOf approach instead, or adding a oneOf approach in addition. I've gone with struct union for now for greater similarity with the existing GraphQL types.
A struct union is valid in both input and output.
On input of a struct union,
__typename
is required in order to determine the type of thestruct
supplied.Example schemaβ
Here's an example schema where a user can customise their biography by composing together paragraphs of different types. You could imagine that these paragraphs were managed by an editor such as ProseMirror.
type Query {
user(id: ID!): User
}
type Mutation {
setUserBio(userId: ID!, bio: Biography!): User
}
type User {
id: ID!
username: String!
bio: Biography!
}
struct Biography {
title: String!
socials: BiographySocials
paragraphs: [Paragraph!]!
}
struct BiographySocials {
github: String
twitter: String
linkedIn: String
facebook: String
}
union Paragraph =
| TextParagraph
| PullquoteParagraph
| BlockquoteParagraph
| TweetParagraph
| GalleryParagraph
struct TextParagraph {
text: String!
}
struct PullquoteParagraph {
pullquote: String!
source: String
}
struct BlockquoteParagraph {
paragraphs: [Paragraph!]!
source: String
}
struct TweetParagraph {
message: String!
url: String!
}
struct GalleryParagraph {
images: [Image!]!
}
struct Image {
url: String!
caption: String
}Selection setsβ
Controversially, structs could be the first type in GraphQL that can act as both a leaf and a non-leaf type - i.e. a selection set over them is optional. If you do not provide a selection set then the entire value is returned. If you do provide a selection set then those fields will be selected and returned.
Struct selection sets are similar to, but not the same as, regular selection sets. In particular:
- struct fields are not
FIELD
s, i.e. they cannot haveFIELD
directives attached (suggest we call themSTRUCT_FIELD
)- aliases are not allowed, otherwise the simple field merging (see below) does not work (also there's no need for aliasing because struct fields are pure data and do not accept arguments, so they cannot produce multiple values for the same field)
Both inline and named fragments are supported on
struct
s and struct unions.Field merging is very straightforward; for the example schema above, the following query:
{
user(id: "1") {
bio {
title
}
bio {
socials {
}
}
}
}would be equivalent to:
{
user(id: "1") {
bio {
title
socials {
}
}
}
}(Note that the object with both
title
andsocials{twitter}
satisfies both selection sets.)And similarly, the following:
{
user(id: "1") {
...A
...B
...C
}
}
fragment A on User {
bio {
title
}
}
fragment B on User {
bio {
socials {
}
}
}
fragment C on User {
# Select the entire field
bio
}would be equivalent to:
{
user(id: "1") {
bio
}
}(Note that because we're selecting the entire
bio
field inC
, all possible subselections are satisfied, and thus A and B are happy.)Why doesn't GraphQL support wildcards over object types?β
In general GraphQL, wildcards have a lot of questions:
- Which fields do we select?
- What do we do for fields with arguments?
- What do we do about deprecated fields?
- Do we automatically add subselections to non-leaf fields?
- How deep do we go?
- What happens if there's an infinite loop?
- Etc.
They also impede the "versionless schema" desire of GraphQL - if a client uses wildcards then when new fields are added the client will start receiving these too, sending them data that they may not understand or desire and causing versioning problems.
However, if we constrain the problem space down to that of pure structured data (treating it like an atom - like a
scalar
is currently) then many of these concerns lessen.Should
struct
replace input objects?βMaybe... They do seem to solve many of the same problems. However one major difference is intent; because
struct
is intended to be used for input/output it's intended that you pull it down, modify it as need be, and then send it back again. As such, adding a non-nullable field to a struct used in this way may not be a breaking change - existing clients should automatically receive the additional fields, and send them back unmodified.The semantics of
struct
and input objects are very similar, so it could be that we repurpose input objects for struct usage. This will need careful thought, discussion and investigation. Not least because the terminput
would make it confusing ;)Use casesβ
Server Driven UIβ
Server Driven UI (SDUI) is a not uncommon pattern for application data fetching, in which objects directly map to UI components.
- https://medium.com/airbnb-engineering/a-deep-dive-into-airbnbs-server-driven-ui-system-842244c5f5
- https://engineeringblog.yelp.com/2021/11/building-a-server-driven-foundation-for-mobile-app-development.html
Encoding SDUI responses as
struct
s would address query complexity issues that SDUI-over-GraphQL suffers from today, when adopted on a large scale. Specifically, selection sets have to (recursively) request all properties of all union memebers, which:
- blows up request payload sizes
- how far do you limit the recursion?
- client developers have to either keep the query in sync with what the server offers by hand, or introduce custom tooling and treat the query as a compile target
For example, imagine a schema with a very low level of abstraction over UI primatives:
{
sduiView(view: "my_cool_marketing_page") {
... on SDUIBox {
... on SDUIButton {
label
radius
action
}
... on SDUIText {
font
weight
body
}
... on SDUIBox {
... on SDUIButton {
label
radius
action
}
... on SDUIText {
font
weight
body
}
... on SDUIBox {
...
}
}
}
...
}
...
}This clearly poses challanges for developers maintaining this query, who might reasonably prefer to write a query like this:
{
sduiView(view: "my_cool_marketing_page")
}Enabling cross-platform user contentβ
In an application that allows creating user content (like a blog, or a social media platform) quite a few fields can be input using structured data such as a title, publishing date, hero image. However, the most important part, the body of the content, is often structured but arbitrary content.
Various services solve this in different ways but usually suffer from one of various drawbacks. Allowing either the raw input (e.g. Markdown) or providing specific rendered formats. However, this means that either every client becomes tied to the input format and the service can no longer change this (e.g. upgrade to a newer Markdown format) or the client must jump through hoops to access data within the rendered blob (e.g. to load an optimised image or video embedded in the content).
The
struct
approach outlined in this RFC would solve these issues by allowing the GraphQL server to provide this user data in a structured format. The server can now change the input formats it accepts as the output can be normalised JSON content. Similarly clients can either request the whole document and render it as they see fit or query for specific parts of the user content.This allows different clients to render different formats (e.g. HTML for the web or native components for a mobile app) without being tied to the server's input format (e.g. Markdown or HTML) and without having to parse a server rendered representation (e.g. Plain-Text, HTML or Markdown).
The schema for this example would be similar to the schema provided in the Struct Union excample. The use-case does highlight one drawback with respect to the constraints outlined in the use-case.
Since
struct
s can not themselves containobject
s providing the structured content in this manner would mean that the client could not request optimised media (one of the reasons why a pre-rendered output was undesirable in the first place), or the image type itself must be a struct which seems to disallow arguments fields which is a common pattern to request specific transformations of an image.e.g. the following is a simplified example for a way to query images often used by Shopify or specific image-resizing services that support GraphQL.
type Image implements Node {
id: ID!
"""
The location of the image as a URL.
If no transform options are specified, then the original image will be preserved including any pre-applied transforms.
All transformation options are considered "best-effort". Any transformation that the original image type doesn't support will be ignored.
If you need multiple variations of the same image, then you can use [GraphQL aliases](https://graphql.org/learn/queries/#aliases).
"""
url(transform: ImageTransformInput): Url!
}
"""
The available options for transforming an image.
All transformation options are considered "best-effort". Any transformation that the original image type doesn't support will be ignored.
"""
input ImageTransformInput {
"""
Image height in pixels between 1 and 5760.
"""
width: Int
"""
Image width in pixels between 1 and 5760.
"""
height: Int
}Simplified client queriesβ
Footnotesβ
-
Custom scalars may possess internal structure, a common example of this is the 'JSON' scalar, however this structure is not defined by nor can it be queried via GraphQL (
specifiedBy
notwithstanding). β©