AshSumType

AshSumType is a Spark DSL for defining custom Ash types that behave like algebraic sum types.

It lets you declare tagged variants with optional carried fields, work with them in Elixir as atoms or tuples, and persist them through Ash as maps.

[!NOTE] Fields are not nilable by default

Defining A Sum Type

defmodule MyApp.Result do
  use AshSumType

  variant :ok do
    field :value, :integer
  end

  variant :error do
    field :message, :string
  end
end

For nullary variants, you can also use a shorthand:

defmodule MyApp.Player do
  use AshSumType, variants: [:x, :o]
end

This defines an Ash type with two variants:

What It Provides

Installation

Add the dependency to mix.exs:

def deps do
  [
    {:ash_sum_type, "~> 1.0.0"}
  ]
end

Constructing Values

You can construct values either from a variant name plus named fields, or from an existing atom/tuple representation.

iex> MyApp.Result.new(:ok, value: 1)
{:ok, {:ok, 1}}

# Or directly with a tuple
iex> MyApp.Result.new({:error, "not found"})
{:ok, {:error, "not found"}}

iex> MyApp.Result.new!(:ok, value: 1)
{:ok, 1}

# Or directly with a tuple
iex> MyApp.Result.new!({:ok, 1})
{:ok, 1}

Nullary variants are represented as bare atoms:

defmodule MyApp.Player do
  use AshSumType

  variant :x
  variant :o
end

iex> MyApp.Player.new(:x)
{:ok, :x}

Persistence Format

In memory, values are represented as:

When dumped to Ash/native storage, they become maps:

iex> MyApp.Result.dump_to_native({:ok, 1}, [])
{:ok, %{__variant__: :ok, value: 1}}

iex> MyApp.Player.dump_to_native(:x, [])
{:ok, %{__variant__: :x}}

The __variant__ key is reserved for the variant tag.

Using It In Ash Resources

defmodule MyApp.GameWinner do
  use AshSumType

  variant :player do
    field :player, MyApp.Player, allow_nil?: false
  end

  variant :draw
end

defmodule MyApp.Game do
  use Ash.Resource,
    domain: MyApp.Games,
    data_layer: Ash.DataLayer.Ets

  attributes do
    uuid_primary_key :id
    attribute :winner, MyApp.GameWinner, allow_nil?: false, public?: true
  end

  actions do
    defaults [:read]

    create :create do
      primary? true
      accept [:winner]
    end
  end
end

defmodule MyApp.Games do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource MyApp.Game do
      define :create_game, action: :create, args: [:winner]
    end
  end
end

Example usage:

MyApp.Games.create_game!({:player, :x})

Validation Behavior

Carried fields are validated with the Ash type you declare for each field. For example, this will fail because :value must be an integer:

iex> MyApp.Result.new(:ok, value: "nope")
{:error, error}

Fields also reject nil by default. If you want to allow nil, opt in explicitly:

defmodule MyApp.MaybeLabel do
  use AshSumType

  variant :some do
    field :value, :string, allow_nil?: true
  end

  variant :none
end

Unknown variants, unknown carried fields, invalid field values, and wrong tuple arity are rejected.

Nesting

A field can itself be another AshSumType type:

defmodule MyApp.GameWinner do
  use AshSumType

  variant :player do
    field :player, MyApp.Player
  end

  variant :draw
end

This allows values like:

{:player, :x}

API Summary