Is there a best practice for representing a variant field that could either be an object with subfields or one or more enum-like singleton values? Like, if there's only one singleton value, a nullable union could work (if somewhat awkwardly, if that value doesn't feel "zeroish"), but what about more than one singleton?
My domain model has a lot of enum-ish structures like this, where some of the variants don't carry any data. I'm hoping the answer isn't to make dummy fields so that every variant is an object type, to satisfy the requirements of union. But maybe I'm missing something.
This is basically a question of how to model algebraic data types in GraphQL. In particular, how to model a coproduct, in which some possibilities are singletons and others are products. For those not familiar with this terminology:
product - a data structure that groups together data that must appear together (see also tuple, record, data class, etc.).
coproduct - a data structure that represents mutual exclusive variations (see also union, either, sum type, etc.)
singleton - a type that has exactly one member. In other words, it has no free parameters and contains no data, other than its own presence.
algebraic data type - a set of product and coproduct types that refer to each other (possibly recursively) and also to singletons. This allows arbitrarily complex data structures to be modeled.
My conclusion so far is that there is no totally clean and generic answer for this. Here are some approaches, all of which have tradeoffs:
Use null (limited)
If your data structure only has one non-optional singleton, you can use nullability to represent the singleton. See [Herku's answer][1] for lists. I've included this for completeness, but it doesn't generalize to data structures with multiple singletons. And in cases where the singleton variant doesn't necessarily represent absence or emptiness, it can be awkward.
Custom scalars
If you're willing to forego the need to inspect the internal data in the variants that do have properties, you can make the entire variant an opaque custom scalar in your schema:
scalar MyVariant # Could be whatever!
The downside is that if you want to add richer GraphQL behavior on your product variants, you're out of luck. Scalars are leaf nodes in your query tree.
Singleton object in union
You can represent the type as a union of regular object types and make also types that represent your singleton options. For the singletons, you have to add some kind of dummy field, because object types must have at least one field. You could make the field a type name, but that's already available as __typename on every type. We chose to just make it a nullable whose value is always null:
union MyVariant = RealObject | Singleton1 | Singleton2
type RealObject {
field1: String
field2: Int
}
type Singleton1 {
singletonDummyField: String # ALWAYS `null`
}
type Singleton2 {
singletonDummyField: String # ALWAYS `null`
}
type Query {
data: MyVariant
}
# query looks like:
{
data {
myVariantType: __typename
... on RealObject {
field1
}
}
}
So, the query for __typename satisfies the need to provide at least one field in a query for an object type. You would never query for singletonDummyField, and you can mostly forget that it exists.
It's easy to make a helper in your GraphQL server that will materialize singleton types for you -- their only variations are their names and metadata. The downside is that client queries gain boilerplate.
Singleton object implenting interface
If the idea of a dummy field is distasteful, and you would prefer to create your own type field you can make an interface that explicitly has a type field and use an enum to represent the types. So:
enum MyVariantType {
REAL_OBJECT
SINGLETON1
SINGLETON2
}
interface MyVariant {
myVariantType: MyVariantType
}
type RealObject implements MyVariant {
myVariantType: MyVariantType
field1: String
field2: Int
}
type Singleton1 implements MyVariant {
myVariantType: MyVariantType
}
type Singleton2 implements MyVariant {
myVariantType: MyVariantType
}
type Query {
data: MyVariant
}
# query looks like:
{
data {
myVariantType
... on RealObject {
field1
}
}
}
So here, you query for the "real" non-meta myVariantType field, and the singleton types have "real" fields, even though they are redundant with respect to the __typename field. You're free to use the __typename approach still, of course, but what's the point. The downside here is that there's a lot more server-side boilerplate to implement the pattern. But this probably could be factored into a helper too, just with a bit more complexity.
Composition
You could encode the singletons as an enum contained within an object type that exists solely to contain them.
union MyVariant = RealObject | Singleton
type RealObject {
field1: String
field2: Int
}
type Singleton {
variation: SingletonVariation!
}
enum SingletonVariation {
SINGLETON1
SINGLETON2
}
type Query {
data: MyVariant
}
# query looks like:
{
data {
... on RealObject {
field1
}
... on Singleton {
variation
}
}
}
This has the advantage of not resorting to introspection or redundant fields. The disadvantage here is that it groups the singleton variations together separate from the product variations in a way that may not be meaningful. In other words, the structure of the schema is pragmatic for being implemented in GraphQL, not necessarily to represent the data.
Conclusion
Pick your poison. As far as I know, there's no great answer for how to do this in a way that feels completely free of boilerplate or abstraction leakage.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With