DiscUnion
Description
Discriminated unions for Elixir.
Allows for building data structure with a closed set of representations/cases as an alternative for a tuple+atom combo. Provides macros and functions for creating and matching on datastructres which throw compile-time and run-time exceptions if an unknow case was used or not all cases were covered in a match. It’s inspired by ML/OCaml/F# way of building discriminated unions. Unfortunately, Elixir does not support such a strong typing and this library will not solve this. This library allows to easly catch common mistakes at compile-time instead of run-time (those can be sometimes hard to detect).
How to use
In example folder, there is a tennis kata example, a simple coding excercise, that shows exactly how to use this
library.
To define a discriminated union, defunion macro is used:
defmodule Shape do
use DiscUnion
defunion Point
| Circle in float()
| Rectangle in any * any
end
Type specs in Circle or Rectangle definitions are only for description and have no influance on code nor are they
used for any type checking - there is no typchecking other then checking if correct cases were used!
When constructing a case (an union tag), you have three options:
from/1macro (compile-time checking),from!/orfrom!/2functions (only run-time checking).-
a dynamicaly built macro named after union tag (in a camalized form, i.e.
Score‘sAdvantagecase, in tennis kata, would be available asScore.advantage/2macro and also with compile-time checking),
If you would do use DiscUnion, dyn_constructors: false, dynamic constructos would not be built.
If Score.from {Pointz, 1, 2} be placed somwhere in run_test_match/0 function, in tennis kata, compiler would throw
this error:
== Compilation error on file example/tennis_kata.exs ==
** (UndefinedUnionCaseError) undefined union case: {Pointz, 1, 2}
(disc_union) expanding macro: Score.from/1
(disc_union) example/tennis_kata.exs:38: Tennis.run_test_match/0
If you would use from!/1, this error would be thrown at run-time, or, in the case of from!/2, not at all! Function
from!/2 returns it’s second argument when unknow clause is passed to the function.
For each discriminated union, a special case macro is created. This macro checks if all cases were covered in it’s
clauses (at compile-time) and expects it’s predicate to be evaluated to this discriminated union’s struct (checked at
run-time).
If Game in _, in Tennis.score_point/2 functions, would be commented, compiler would throw this error:
== Compilation error on file example/tennis_kata.exs ==
** (MissingUnionCaseError) not all defined union cases are used, should be all of: Points in "PlayerPoints" * "PlayerPoints", Advantage in "Player", Deuce, Game in "Player"
(disc_union) expanding macro: Score.case/2
(disc_union) example/tennis_kata.exs:64: Tennis.score_point/2
You can also use a catch-all statment (_), like in a regular case macro (Kernel.SpecialForms.case/2), but here, it
needs to be explicitly enabled by passing allow_underscore: true option to the macro:
Score.case score, allow_underscore: true do
Points in PlayerPoints.forty, PlayerPoints.forty -> Score.duce
_ -> score
endOtherwise you would see a smillar error like above.
How it works
Underneath, it’s just a module containg a struct with tuples and some dynamicly built macros. This property can be used
for matching in function deffinitions, altough it will not look as clearly as a case macro built for a discriminated
union.
The Shape union creates a %Shape{} struct with current active case held in case field and all possible
cases can be get by Shape.__union_cases__/0 function:
%Shape{case: Point} = Shape.point
%Shape{case: {Circle, :foo}} = Shape.circle(:foo)Cases that have arguments are just tuples; n-argument union case is a n+1-tuple with a case tag as it’s first element. This should work seamlessly with existing convections:
defmodule Result do
use DiscUnion
defunion :ok in any | :error in String.t
end
defmodule Test do
require Result
def run(file) do
res = Result.from! File.open(file)
Result.case res do
r={:ok, io_dev} -> {:yey, r, io_dev}
:error in reason when reason==:eacces -> :too_much_protections
:error in :enoent -> :why_no_file
:error in _reason -> :ney
end
end
end
Since cases are just a tuples, they can be used also used as a clause for case macro. Matching and gaurds also works!
Side note
It is possible to place discriminated union’s constructor macros in function definition:
defmodule ShapeArea do
require Shape
def calc_area(Shape.point), do: 0
def calc_area(Shape.circle(r)), do: :math.pi*r*r
end
And even use natural Elixir’s multi-fun capability, to build logic, like in score_point/2 function in tennis kata
example. Although, placing constructors inside of function definition is not a bad thing, and being able to do so is a
clear WIN! Using this technique to build business logic, instead of using discriminated union’s case macro, is not
encouraged because nothing checks if all union cases were covered.
Installation
If available in Hex, the package can be installed as:
Add disc_union to your list of dependencies in
mix.exs:def deps do
[{:disc_union, "~> 0.1.0"}]end
Ensure disc_union is started before your application:
def application do
[applications: [:disc_union]]end