Tagged Unions for Elixir. Just that.
Add ex_union
to your list of dependencies in mix.exs
:
def deps do
[
{:ex_union, "~> 0.1.0"}
]
end
Differences between the versions are explained in the Changelog.
Documentation gets generated with ExDoc and can be viewed at HexDocs.
ExUnion
is meant to be a lightweight, elixir-y implementation of tagged unions (also called variant, discriminated union, sum type, etc.).
While conventionally Elixir tends to promote using tuples to model tagged unions - the {:ok, ...} | {:error, ...}
pattern being a good example of that - this approach arguably lacks expressiveness, especially when modeling non-trivial unions.
An alternative is to employ structs to model the individual cases of a tagged union, which works nicely but has the disadvantage of requiring significant boilerplate code.
ExUnion
attempts to bridge this gap by generating the necessary boilerplate (and a bit more) through a concise albeit opinionated DSL.
To get an idea on how you can use ExUnion
let's look at an example:
defmodule Maybe do
import ExUnion
defunion some(value) | none
end
The defunion
macro takes a type-spec similar definition and generates a bunch of code from it.
Let's see how we can use Maybe
now, shall we?
iex> Maybe.none()
%Maybe.None{}
iex> Maybe.some("string!")
%Maybe.Some{value: "string!"}
# Requiring is necessary since `is_maybe` is a guard (defguard)
iex> require Maybe
iex> Maybe.is_maybe("What's the meaning of life, the universe, and everything?")
false
iex> Maybe.is_maybe(42)
false
iex> Maybe.is_maybe(Maybe.none())
true
As you can see ExUnion
generates a number of things from the definition:
- a struct for each case of the union (including type specs)
- a shortcut function for each case to create said struct (including
@spec
s) - a shortcut type spec for each case and the general union (
t
,union
,union_<case>
) - a guard that returns
true
if the given value is part of the union
Check out the additional examples below to get a better impression of what ExUnion
offers.
defmodule Shape do
import ExUnion
defunion circle(radius)
| square(side)
| rectangle(width, height)
end
iex> Shape.circle(3)
%Shape.Circle{radius: 3}
iex> Shape.square(side: 4)
%Shape.Square{side: 4}
iex> Shape.rectangle(4, 2)
%Shape.Rectangle{width: 4, height: 2}
iex> Shape.rectangle(height: 2, width: 4)
%Shape.Rectangle{width: 4, height: 2}
defmodule Color do
import ExUnion
defunion hex(string :: String.t)
| rgb(red :: 0..255, green :: 0..255, blue :: 0..255)
| rgba(red :: 0..255, green :: 0..255, blue :: 0..255, alpha :: float)
| hsl(hue :: 0..360, saturation :: float, lightness :: float)
| hsla(hue :: 0..360, saturation :: float, lightness :: float, alpha :: float)
end
defmodule IntegerTree do
import ExUnion
# You can also use `t` instead of `union` if you prefer
defunion leaf | node(integer :: integer, left :: union, right :: union)
end
If necessary you can ever refer to individual cases of the union.
Let's revisit the Color
example for above and how we can use recursive types to reuse the rbg
and hsl
definitions:
defmodule Color do
import ExUnion
defunion hex(string :: String.t)
| rgb(red :: 0..255, green :: 0..255, blue :: 0..255)
| rgba(rgb :: union_rgb, alpha :: float)
| hsl(hue :: 0..360, saturation :: float, lightness :: float)
| hsla(hsl :: union_hsl, alpha :: float)
end
To give you more of an idea on the kind of code ExUnion
generates for you, let's look at what you'd have to write out to get something equivalent.
For this we'll use the Maybe
example from earlier again.
defmodule Maybe do
@type t :: union
@type union :: Maybe.None.t() | Maybe.Some.t()
@type union_some :: Maybe.Some.t()
@type union_none :: Maybe.None.t()
defmodule Some do
@type t :: %__MODULE__{value: any}
defstruct [:value]
@spec new(fields :: %{value: any}) :: t
def new(fields) when is_map(fields) and :erlang.is_map_key(:value, fields) do
struct!(__MODULE__, fields)
end
@spec new(fields :: [value: any]) :: t
def new([{field, _} | _] = fields) when field in [:value] do
struct!(__MODULE__, fields)
end
@spec new(value :: any) :: t
def new(value) do
%__MODULE__{value: value}
end
end
defmodule None do
@type t :: %__MODULE__{}
defstruct []
@spec new() :: t
def new() do
%__MODULE__{}
end
end
defdelegate some(value), to: Maybe.Some, as: :new
defdelegate none(), to: Maybe.None, as: :new
defguard is_maybe(value)
when is_map(value) and :erlang.is_map_key(:__struct__, value) and
:erlang.map_get(:__struct__, value) in [Some, None]
end
Out of a single line of defunion some(value) | none
ExUnion
generated over 30 lines of code.
And while the specifics of the generated code are opinionated in places, they do have a lot lower information density than the defunion
line.
ExUnion
can be compared to a number of other libraries.
Algae
offers for "algebraic data types for Elixir".
Some people might prefer that, and that's perfectly fine!
I think Algae
(and it's big brother Witchcraft
) are amazing projects and should be used more - but I also think that they come with a lot of inborn complexity.
Not everybody is familiar with "algebraic data types" and arguably not everybody needs to be! But on the other hand there's a lot of goodness in the tools they bring to the table.
Algea
also offers its own flavor of tagged unions (or rather sum types) but also with more than that.
ExUnion
by design only implements tagged unions and nothing more - as they are a tool most developers probably are familiar with - in an attempt to be as approachable and self-explanatory as possible.
At some point you and/or your team might decide to take the next step and use Algae
or even Witchcraft
and ExUnion
will be happy to have been part of your journey.
Or maybe ExUnion
is all you need and that would be fine too.
Ok
and Wormhole
both aim to provide additional tools to work with Elixir's most well-known tagged union: {:ok, value}
and {:error, reason}
.
But they do only that.
If you want more tools to deal with {:ok, value}
and {:error, reason}
tuples, then they are great libraries.
But if you want additional tools to model similar tagged unions, then these libraries don't help you.
ExUnion
doesn't pretend to help you with {:ok, value}
/ {:error, reason}
.
This isn't the motivation behind the project.
It does however give you more power to escape the limits of using tagged tuples to model unions.
- Figure out a way to derive protocol implementations for union structs (e.g. for
Jason
)
Contributions are always welcome but please read our contribution guidelines before doing so.