Previous    |   Up   |    Next

Newtype-like tagged tuples in Elixir

A thought (and code) experiment in type wrappers

Some time ago I stumbled upon a hacky way of generating something akin to Haskell’s newtype declarations in Elixir. Here it is: defopaque.

A Motivating Example: Units of Measure

I’m going to assume you agree with the maxim that making illegal states unrepresentable is a desirable feature of software systems.

Let’s say a part of your system deals with weights. It would be nice to ensure that programmers don’t use plain numbers when dealing with units of measure. The consequences of doing so have been bad in the past. Let’s define a Weight module with a kg constructor, wrapping any number. This will represent our unit of weight.

defmodule Weight do
  use Defopaque
  defopen(:kg, number())
end

Our intention here is to create a lightweight wrapper type whose role is primarily to document the meaning of the variable, and also to prevent accidental use of weight-related functions with ‘plain’ numbers.

The macro defopen gives us:

1) A kg() type, exported from Weight.

2) A kg(n) macro, which will generate a tuple containing the number n as its second element. The first element of the tuple will be an autogenerated atom (guaranteed to be stable for every {wrapper-atom, wrapped-subtype} pair). We can use this macro to generate new kg values and to pattern-match on existing values.

Let’s see some examples of the kg unit in use:

defmodule MyApp do
  import Weight

  @spec tell_weight(Weight.kg()) :: String.t()
  def tell_weight(w) do
    case w do
      kg(12) -> "twelve kilograms"
      kg(other) -> "#{other}kg"
      _ -> "invalid unit"
    end
  end
end
require Weight
iex()> MyApp.tell_weight(Weight.kg(12))
"twelve kilograms"
iex()> MyApp.tell_weight(Weight.kg(11))
"11kg"
iex()> MyApp.tell_weight(12)
"invalid unit"

You can also use pattern-match syntax to match on values inside the constructor:

defmodule Matches do
  import Weight

  def count(want_value) do
    weights = [kg(1), kg(3.9), kg(5.3), kg(10.1)]
    Enum.count(weights,
      fn kg(^want_value) -> true
         kg(_) -> false
      end)
  end
end
iex(19)> Matches.count(3.90)
1
iex(20)> Matches.count(3.91)
0

You can even pattern match in function heads:

defmodule Conversion do
  import Weight
  def kg_to_lb(kg(n)), do: n * 2.204623
end
require Weight
iex(6)> Conversion.kg_to_lb(kg(2))
4.409246
iex(7)> Conversion.kg_to_lb(kg(0.5))
1.1023115

But note! Now our function can only be called once:

Conversion.kg_to_lb(kg(0.5)) |> Conversion.kg_to_lb()
** (FunctionClauseError) no function clause matching in Conversion.kg_to_lb/1

    The following arguments were given to Conversion.kg_to_lb/1:

        # 1
        1.1023115

    iex:5: Conversion.kg_to_lb/1

This is precisely the behavior we wanted at the very beginning. Only a kg unit can be converted, not a plain number.

If we want to expand our system to deal with pounds as a first-class citizen, we are free to do so. It’s very cheap to generate a new wrapper, and we can do it in the same module:

defmodule Weight do
  use Defopaque
  defopen(:kg, number())
  defopen(:lb, number())
end

defmodule Conversion do
  import Weight
  def kg_to_lb(kg(n)), do: lb(n * 2.204623)
  def lb_to_kg(lb(n)), do: kg(n * 0.4535924)
end

This will work as expected, only allowing for conversions in the right direction:

import Weight
iex()> kg(res) = Conversion.kg_to_lb(kg(1)) |> Conversion.lb_to_kg()
{:"f7ce213d444ac5216656-kg", 1.0000002376652002}
iex()> res
1.0000002376652002

iex()> kg(res) = Conversion.kg_to_lb(kg(1)) |> Conversion.kg_to_lb()
** (FunctionClauseError) no function clause matching in Conversion.kg_to_lb/1

    The following arguments were given to Conversion.kg_to_lb/1:

        # 1
        {:"be46b1adbd0d445032d6-lb", 2.204623}

    iex:25: Conversion.kg_to_lb/1

As you can see, we got a peek at how our wrapper tagging is implemented. It’s not exactly pretty, but it prevents other modules from creating wrapped types without importing the module where these types are defined.

Why not Tagged Tuples?

Tagged tuples are the traditional way of handling this kind of problem, and there’s nothing wrong with them. However, they don’t prevent ‘unauthorized’ use of our types. For example, anyone can create the tuple {:kg, 2}.

So there’s no reason why some module that should know nothing about weights couldn’t match on our tagged tuple:

def an_allegedly_weight_agnostic_function({:kg, weight}) do (...)

With this method, code that does not reference our Weight module cannot in good faith create our tagging atom, since it’s more-or-less gibberish.

Also, unique and opaque tags mean that dialyzer can be much more strict when checking our code.

Why not structs?

Structs are Elixir’s killer feature and they are great for modeling composite data types. This project came out of an attempt to golf opaque structs, defined as internally nested modules with a single field. That’s what I recommend doing in real production projects!

Why the name defopaque?

It’s because the original intent behind this hack was to provide a quick-n-dirty way to define @opaque newtypes in a codebase. Later on I figured it would also be nice to provide non-opaque, destructurable newtypes. Hence two macros:

  1. defopaque – Creates a wrapper and defines the resulting wrapped type as @opaque. The generated constructor and pattern-match macro can only be used in the module where the opaque type is defined.

  2. defopen – Creates a wrapper and defines the resulting wrapped type as @type. The generated constructor can be used outside the module where it was defined.

(Edited on 2020-04-27: module name, truncated sentence!)

Previous    |   Up   |    Next