At the time of writing this, union input types are not yet supported by either Absinthe or the GraphQL spec itself. This RFC in the spec describes possible solutions and their challenges.

The leading solution seems to be a tagged union. In this post, I’ll describe my attempt of implementing that as an Absinthe middleware.

What’s a union type

A union type is one of GraphQL’s Abstract Data Types that’s made up of multiple possible types.

Using the example domain from the RFC document, let’s say that we model an animal shelter and we have two different animal species we support: cats and dogs.

An union type allows us to model each animal as one of those two different types:

object :shelter do
  field :location, non_null(:string)
  field :animals, list_of(non_null(:animal))
end

union :animal do
  types [:cat, :dog]

  resolve_type fn
    %{name: _, lives_left: _} -> :cat
    %{name: _} -> :dog
  end
end

object :dog do
  field :name, non_null(:string)
end

object :cat do
  field :name, non_null(:string)
  field :lives_left, non_null(:string)
end

Another Abstract Data Type (ADT) supported by GraphQL and Absinthe is the interface, which might have made more sense for querying this data, but let’s ignore that for the sake of consistency.

Unions for input objects?

You can imagine that the same would be helpful in input objects and mutations. If you want to log new animals at the shelter, how do you distinguish between cats and dogs?

A popular solution is to use a tagged unions for that:

mutation do
  field :log_animal_drop_off, :shelter do
    arg :location, non_null(:string)
    arg :animals, list_of(non_null(:animal_input))

    resolve &AnimalDropoff.resolve/2
  end
end

input_object :animal_input do
  field :cat, :cat_input
  field :dog, :dog_input
end

input_object :cat_input do
  field :name, non_null(:string)
  field :lives_left, non_null(:integer)
end

input_object :dog_input do
  field :name, non_null(:string)
end

This solution assumes that clients will pass only one of the fields in the animal_input object. Unfortunately, there’s no automatic validation for that, except for the validations you might already have in the resolver or business logic.

The middleware

Before we get native support for union input types in GraphQL and Absinthe, you can use the following middleware to add automatic validations for tagged unions:

defmodule MyGraph.Middleware.OneOfInputValidation do
  @moduledoc """
  Middleware that allows you to specify one-of/union input_objects.

  In a one-of/union input_object, only one of the keys can be set at any given
  time.

  This middleware must be called before `resolve` to prevent mutation from
  running if the validation fails.

  The `keys` argument specifies the path to the one-off input object, following
  a similar pattern to the one found in `Kernel.get_in/2`, but is extented to
  support lists.
  """

  @behaviour Absinthe.Middleware

  @impl true
  def call(resolution = %{state: :resolved, errors: []}, _keys) do
    IO.warn("#{__MODULE__} must be called before a field is resolved.")

    resolution
  end

  @impl true
  def call(resolution = %{state: :resolved}, _keys) do
    resolution
  end

  @impl true
  def call(resolution, key) when is_atom(key), do: call(resolution, [key])

  @impl true
  def call(%{state: :unresolved} = resolution, keys) when is_list(keys) do
    field_values =
      resolution.arguments
      |> get_values(keys)
      |> to_flat_list()

    if Enum.any?(field_values, &more_than_one_value?/1) do
      field_name =
        keys
        |> List.last()
        |> to_string()
        |> Absinthe.Utils.camelize(lower: true)

      error = "You can only set one of the values in: '#{field_name}'."
      resolution |> Absinthe.Resolution.put_result({:error, error})
    else
      resolution
    end
  end

  # Inspired by
  # https://github.com/elixir-lang/elixir/blob/v1.11.4/lib/elixir/lib/kernel.ex#L2473
  # but adjusted to handle lists
  defp get_values(nil, _), do: []

  defp get_values(data, []), do: data

  defp get_values(data, keys) when is_list(data),
    do: Enum.map(data, &get_values(&1, keys))

  defp get_values(map, [key | rest]) when is_map(map),
    do: get_values(map[key], rest)

  defp to_flat_list(list) when is_list(list),
    do: List.flatten(list)

  defp to_flat_list(element), do: [element]

  defp more_than_one_value?(map) when is_map(map),
    do: map |> Map.keys() |> length() > 1

  defp more_than_one_value?(_), do: false
end

And use it like this in your schema:

mutation do
  field :log_animal_drop_off, :shelter do
    arg :location, non_null(:string)
    arg :animals, list_of(non_null(:animal_input))

    middleware MyGraph.Middleware.OneOfInputValidation, [:animals]

    resolve &AnimalDropoff.resolve/2
  end
end

Or, if union field is nested, you can specify the path to the field, similar to get_in/2:

field :log_animal_drop_off, :shelter do
  arg :input, non_null(:animal_drop_off_input)

  middleware MyGraph.Middleware.OneOfInputValidation, [:input, :animals]

  resolve &AnimalDropoff.resolve/2
end

Could you help me find a better solution?

I’m neither a GraphQL nor Absinthe expert, so this is far from perfect. It’s a first step, but I’m sure someone can (or already have) find a better solution.

If you are that someone and you have some ideas on how to improve it (or see what’s wrong), reach out and let me know!