acjay
acjay

Reputation: 36521

Representing enum+object variant type in GraphQL

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.

Upvotes: 2

Views: 3082

Answers (3)

acjay
acjay

Reputation: 36521

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.

Upvotes: 10

Herku
Herku

Reputation: 7666

I think you already provided a very good answer. I just want to add to that another approach that can be a solution in some cases (mostly when you only have a small number of types in the sum). For that you might want to simply not use the GraphQL type system at all to mirror the types. Not using interfaces makes queries way less verbose and leaves the mapping of the result to the client implementation. Instead you can simply use nullable fields.

Example: Linked list of integers

sealed trait List
case class Cons(value: Int, next: List) extends List
case object Nil extends List

You can create a single type for this using nullable fields:

type List {
  value: Int
  next: List
}

This can be easily mapped back to your sum type in Scala while a JavaScript client simply uses the type as is with nulls in place:

def unwrap(t: Option[GraphQLListType]) = t match {
  case Some(GraphQLListType(value, next))) => Cons(value, unwrap(next))
  case None => Nil
}

This makes your queries much less verbose and your type definition contains only one type instead of three.

{
  list {
    value
    next
  }
  # vs.
  list {
    __typename
    ... on Cons {
      value
      next
    }
  }
}

You can also easily add more fields to the type.

Unfortunately GraphQL is not very good at querying recursive structures and you might have to represent your structure in a different way (e.g. flatten your tree structure).

Let me know what you think!

Upvotes: 0

neattom
neattom

Reputation: 364

You can create your scalar type, probably as string type, and validate enum value inside.

Upvotes: -1

Related Questions