Union type

In Rust there are enums, which allow you to define a limited number of variants for a “type”. Elm just calls them “custom types” but they are the same concept of defining a limited number of variants. Does Elixir have an equivalent structure?

It does not. You can document such things via typespecs, but elixir doesn’t have great means of enforcing it, which imo makes them actually useful in the first place.

1 Like

It all depends on how you want to use it. For most of where I’ve used a “union” just typespec and matching on the various options did the trick for me.

I’ve never tried this in Elixir, but you could potentially create a wrapper struct and have its “constructor” only take acceptable values.

1 Like

Erlang (and by extension Elixir) uses “tagged tuples” for many things Rust would do with enum.

For instance, this enum (taken from this article):

enum Animal {
    Cat { weight: f32, legs: usize },
    Dog { weight: f32, legs: usize },
    Monkey { weight: f32, arms: usize, legs: usize },
    Fish { weight: f32, fins: usize },
    Dolphin { weight: f32, fins: usize },
    Snake { weight: f32, fangs: usize }
}

might be represented with the type:

@type animal :: {:cat, number(), integer()} | {:dog, number(), integer()} | {:monkey, number(), integer(), integer()} | ...

Then the type can be used in case statements like:

case some_animal do
  {:cat, weight, _} -> # do something with weight

  _ ->
    # do something if some_animal isn't the kind we care about
end

This will get you some nice checks with Dialyzer:

  • if a case doesn’t have a default and Dialyzer can prove it receives a shape it doesn’t handle, it will complain

  • if a case handles a shape that Dialyzer can prove never occurs (because of a typo, etc), it will complain

There’s also Erlang’s “record” feature, which isn’t used as much in Elixir but provides some wrappers around these conventions.

2 Likes

Thanks! That looks like exactly what I was looking for.

Just to add another perspective, you may use structs as well instead, for example:

defmodule Animal.Cat do
  defstruct [:weight, :legs]
end

defmodule Animal.Dog do
  defstruct [:weight, :legs]
end

Then in a case you should:

case animal do
  %Cat{} -> ...
  %Dog{} -> ...
end

But, maybe what may make sense is to have a protocol called Animals that contains common, functions, such as:

defprotocol Animals do
  def fluffy?(animal)
  def size(animal)
end

defimpl Animals, for: Cat do
  def fluffy?(%Cat{weight: weight}) when weight > 5, do: true
  def fluffy?(_), do: false

  def size(%Cat{weight: weight}) when weight > 5, do: "big"
  def size(%Cat{weight: weight}) when weight > 2 and weight <= 3, do: "medium"
  def size(_), do: "small"
  
end
1 Like

Would you say protocols in Elixir are analogous (or even equivalent) to traits in Rust? I think this is a great tip, but potentially a different usecase than union types. Or at least, requires a shift in thinking/design to achieve the same effect as one does with union types.

I’m not familiar with Rust but they are somewhat equivalent to Traits in Scala.

Since elixir is a dynamic language, a way to extend functionality for an already defined function is via Protocol. For example, all functions in the Enum and Stream modules accept Enumerables (list, map, map set, etc…). So if you want to build your own data structure (let’s say you want to wrap Erlang’s :array module) and make use of Stream and Enum, you need to implement the Enumerable protocol, because they use the Enumerable defined functions behind the scenes.

Protocols are also like, well, protocols in Clojure and typeclasses in Haskell.
But in the context of Rust and Elm(and Haskell or OCaml ADTs), “enums” are especially useful because they’re languages that can give you compile time guarantees that elixir cannot(and dialyzer is limited).
So typespecs is the closest you can get to them.

I think @dorgan has hit on the transition I need to make in my head. These union type variants are great for compile time checks but not critical for run time behavior. Lots of methods can be brought to achieve similar behaviors at runtime, with protocols seeming the best option in many ways.

Correct. Elixir protocols are similar to Rust traits, although they are slightly less powerful because we cannot constrain the types for which the protocol is implemented (e.g. we can implement an Elixir protocol for “List” but not constrain it to "List a when protocol P is implemented for a" or “Only lists of integers”. Also, all tuples are considered instances of the type Tuple, regardless of arity, which unfortunately includes any and all record-types (which are tuples whose first element is a particular atom) ).

They are thus a form of ‘open-ended’ polymorphism where at any later time a user might add an implementation for an already-existing protocol, or add a new protocol which works on already-existing datatypes.

This is in stark contrast to “tagged unions”/“variants”/“sum types”. Here, there is only one such definition which restricts us but at the same time is what allows e.g. compile-time checks that make sure no case is missed.
The best bets you have to handle those in Erlang/Elixir is to either:

  • use techniques like what @al2o3cr described where you rely on static type checking by Dialyzer (which is unfortunately known for its sometimes archaic error messages and it very much is opt-in and has some cases of false positives).
  • Alternatively there are a couple of run-time type-checking libraries out there (like TypeCheck or Norm) which however obviously add extra runtime cost.
2 Likes

This is true.
A bit of a tangent here but there is also the ProtocolEx library by @OvermindDL1, with it you can easily define aprotocol for result tuples or any kind of values you want as long as you can pattern match against it. I’m not sure why it didn’t catch up.

2 Likes