x4lldux / disc_union

Discriminated unions for Elixir
MIT License
64 stars 1 forks source link

DiscUnion

Description

Discriminated unions for Elixir - for building algebraic data types.

Allows for building data structure with a closed set of representations/cases as an alternative for a set of tuple+atom combo. Elixir already had product type - tuples. With DiscUnion library, sum-types, types with a fixed set of values can be created (also called discriminated unions or disjoint unions).

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. However, it allows to easily 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 exercises, that shows exactly how to use this library.)

To define a discriminated union, defunion macro is used. Use | to separate union cases from each other. Union cases can have arguments and an asterisk * can be used to combine several arguments. Underneath, it's just a struct with union cases represented as atoms and tuples. Type specs in definitions are passed to @spec declaration, so dialyzer can be used. However, DiscUnion does not type-check anything by it self.

Usage

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 influence 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 couple of options:

Run-time constructors from! can be overridden. Any changes in functionality introduced to them will also impact c! constructors which are based on from!. This, for example, allows for defining variant cases with some run-time validations.

Preferred way to construct a variant case is via c macros or c! functions. from/1 and from!/1 construcotrs are mainly to be used when interacting with return values like in example with opening a file. If you'd like to enable named constructors do: use DiscUnion, named_constructors: true.

If Score.from {Pointz, 1, 2} or Score.c Pointz, 1, 2, from tennis kata example, be placed somewhere in run_test_match/0 function compiler would throw this error:

== Compilation error on file example/tennis_kata.exs ==
** (UndefinedUnionCaseError) undefined union case: Pointz in _, _
    (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 or c!, 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 statement (_), 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
end

Otherwise you would see a smillar error like above.

How it works

Underneath, it's just a module containg a struct with tuples and some dynamically built macros. This property can be used for matching in function definitions, although 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.c Point
%Shape{case: {Circle, :foo}} = Shape.c 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 conventions:

defmodule Result do
  use DiscUnion

  defunion :ok in any | :error in atom
end

defmodule Test do
  use 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 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
  use   Shape

  def calc_area(Shape.c(Point)), do: 0
  def calc_area(Shape.circle(r)), do: :math.pi*r*r  # assuming named construcors are enabled
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:

  1. Add disc_union to your list of dependencies in mix.exs:

    def deps do
      [{:disc_union, "~> 0.3.0"}]
    end
  2. Ensure disc_union is started before your application:

    def application do
      [applications: [:disc_union]]
    end