Newtype-like tagged tuples in Elixir
A thought (and code) experiment in type wrappers
2020-04-26
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 struct
s?
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:
-
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. -
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!)