Elixir: The Alchemy of Code Generation
This article was originally written in Russian and has been translated to English with the help of DeepSeek. You can find the original version here: Habr. Elixir – a language created to reintroduce Erlang to the modern world. A syntax free of beloved but archaic punctuation marks; a development culture that emphasizes tooling quality and developer comfort; a comprehensive suite for building web services; a standard library unburdened by decades of legacy; and real macros. At first glance, Elixir doesn’t introduce radically new concepts at its core. Indeed, developers familiar with both Elixir and Erlang can often imagine how code in one language would translate to the other. But not always — Elixir includes constructs that have no direct Erlang equivalents. How do they work? Obviously, Elixir expands them into some additional Erlang code during the compilation phase. Some transformations are intuitive to predict; others (spoiler alert) might reveal surprising compiler tricks. This article explores the transformations Elixir code undergoes before reaching the Erlang compiler. We’ll dissect conditional constructs like if and cond, unravel the mysteries of the dot operator, trace the adventures of with and for comprehensions, demystify protocols, and marvel at the optimizations Elixir achieves. Since the final result of the Elixir compiler's work is Erlang Abstract Code — an Erlang syntax tree — it is easy to reconstruct Erlang code from it. The following function will assist us in this process: @spec to_abstract(module()) :: String.t() def to_abstract(module) do module |> :code.get_object_code() # Get the module BEAM code |> then(fn {_module, beam, _path} -> beam end) |> :beam_lib.chunks([:abstract_code]) # Extract Abstract Code from debug section |> then(fn result -> {:ok, {_, [abstract_code: {:raw_abstract_v1, abstract_code}]}} = result abstract_code end) |> :erl_syntax.form_list() |> :erl_prettypr.format() # Translate Abstract Code back to Erlang |> List.to_string() end Feel free to use it yourself if the article doesn’t cover topics you’re curious about. The full code is available on GitHub. A heads-up: There will be plenty of Erlang code ahead, so familiarity with the language will help. But don’t worry if you’ve never encountered Erlang before — no overly complex syntax will appear. A basic grasp of Elixir is all you’ll need to follow along. Conditional expressions Let’s start with the basics. Unlike Erlang, which strictly operates with boolean values, Elixir introduces the concept of truthiness, where nil and false are considered falsy, and all other values are truthy. Accordingly, all expressions that rely on this logic must expand into BEAM-compatible code during compilation: Kernel.if/2 def if_thing(thing) do if thing, do: :thing, else: :other_thing end if_thing(_thing@1) -> case _thing@1 of _@1 when _@1 =:= false orelse _@1 =:= nil -> other_thing; _ -> thing end. Kernel.SpecialForms.cond/1 def cond_example do cond do :erlang.phash2(1) -> 1 :erlang.phash2(2) -> 2 :otherwise -> :ok end end cond_example() -> case erlang:phash2(1) of _@3 when _@3 /= nil andalso _@3 /= false -> 1; _ -> case erlang:phash2(2) of _@2 when _@2 /= nil andalso _@2 /= false -> 2; _ -> case otherwise of _@1 when _@1 /= nil andalso _@1 /= false -> ok; _ -> erlang:error(cond_clause) end end end. Kernel.!/1 def negate(thing), do: !thing negate(_thing@1) -> case _thing@1 of _@1 when _@1 =:= false orelse _@1 =:= nil -> true; _ -> false end. Indeed, every conditional expression relying on truthy/falsy evaluation expands into a case statement where the negative clause checks for membership in the falsy category. But this isn’t always the case. For example: def if_bool(thing) do if is_nil(thing), do: :thing, else: :other_thing if thing != nil, do: :thing, else: :other_thing end if_bool(_thing@1) -> case _thing@1 == nil of false -> other_thing; true -> thing end, case _thing@1 /= nil of false -> other_thing; true -> thing end. This reveals the first optimization implemented by the compiler: if Elixir is certain that the evaluation will only involve boolean values, it generates a case statement case Value of true -> success; false -> failure end without considering truthiness semantics as in the general case case Value of Value when Value =:= false orelse Value =:= nil -> failure; _ -> success end The optimization condition is defined here: case lists:member({optimize_boolean, true}, Meta) andalso elixir_utils:returns_boolean(EExpr) of true -> rewrite_case_clauses(Opts); false -> Opts end, For this optimization to apply, the following conditions must be met: a) The expression must be m

This article was originally written in Russian and has been translated to English with the help of DeepSeek. You can find the original version here: Habr.
Elixir – a language created to reintroduce Erlang to the modern world. A syntax free of beloved but archaic punctuation marks; a development culture that emphasizes tooling quality and developer comfort; a comprehensive suite for building web services; a standard library unburdened by decades of legacy; and real macros.
At first glance, Elixir doesn’t introduce radically new concepts at its core. Indeed, developers familiar with both Elixir and Erlang can often imagine how code in one language would translate to the other. But not always — Elixir includes constructs that have no direct Erlang equivalents. How do they work? Obviously, Elixir expands them into some additional Erlang code during the compilation phase. Some transformations are intuitive to predict; others (spoiler alert) might reveal surprising compiler tricks.
This article explores the transformations Elixir code undergoes before reaching the Erlang compiler. We’ll dissect conditional constructs like if
and cond
, unravel the mysteries of the dot operator, trace the adventures of with
and for
comprehensions, demystify protocols, and marvel at the optimizations Elixir achieves.
Since the final result of the Elixir compiler's work is Erlang Abstract Code — an Erlang syntax tree — it is easy to reconstruct Erlang code from it. The following function will assist us in this process:
@spec to_abstract(module()) :: String.t()
def to_abstract(module) do
module
|> :code.get_object_code() # Get the module BEAM code
|> then(fn {_module, beam, _path} -> beam end)
|> :beam_lib.chunks([:abstract_code]) # Extract Abstract Code from debug section
|> then(fn result ->
{:ok, {_, [abstract_code: {:raw_abstract_v1, abstract_code}]}} = result
abstract_code
end)
|> :erl_syntax.form_list()
|> :erl_prettypr.format() # Translate Abstract Code back to Erlang
|> List.to_string()
end
Feel free to use it yourself if the article doesn’t cover topics you’re curious about. The full code is available on GitHub.
A heads-up: There will be plenty of Erlang code ahead, so familiarity with the language will help. But don’t worry if you’ve never encountered Erlang before — no overly complex syntax will appear. A basic grasp of Elixir is all you’ll need to follow along.
Conditional expressions
Let’s start with the basics. Unlike Erlang, which strictly operates with boolean values, Elixir introduces the concept of truthiness, where nil
and false
are considered falsy
, and all other values are truthy
.
Accordingly, all expressions that rely on this logic must expand into BEAM-compatible code during compilation:
Kernel.if/2
def if_thing(thing) do
if thing, do: :thing, else: :other_thing
end
if_thing(_thing@1) ->
case _thing@1 of
_@1 when _@1 =:= false orelse _@1 =:= nil -> other_thing;
_ -> thing
end.
Kernel.SpecialForms.cond/1
def cond_example do
cond do
:erlang.phash2(1) -> 1
:erlang.phash2(2) -> 2
:otherwise -> :ok
end
end
cond_example() ->
case erlang:phash2(1) of
_@3 when _@3 /= nil andalso _@3 /= false -> 1;
_ ->
case erlang:phash2(2) of
_@2 when _@2 /= nil andalso _@2 /= false -> 2;
_ ->
case otherwise of
_@1 when _@1 /= nil andalso _@1 /= false -> ok;
_ -> erlang:error(cond_clause)
end
end
end.
Kernel.!/1
def negate(thing), do: !thing
negate(_thing@1) ->
case _thing@1 of
_@1 when _@1 =:= false orelse _@1 =:= nil -> true;
_ -> false
end.
Indeed, every conditional expression relying on truthy/falsy evaluation expands into a case
statement where the negative clause checks for membership in the falsy
category.
But this isn’t always the case. For example:
def if_bool(thing) do
if is_nil(thing), do: :thing, else: :other_thing
if thing != nil, do: :thing, else: :other_thing
end
if_bool(_thing@1) ->
case _thing@1 == nil of
false -> other_thing;
true -> thing
end,
case _thing@1 /= nil of
false -> other_thing;
true -> thing
end.
This reveals the first optimization implemented by the compiler: if Elixir is certain that the evaluation will only involve boolean values, it generates a case
statement
case Value of
true -> success;
false -> failure
end
without considering truthiness semantics as in the general case
case Value of
Value when Value =:= false orelse Value =:= nil -> failure;
_ -> success
end
The optimization condition is defined here:
case lists:member({optimize_boolean, true}, Meta) andalso elixir_utils:returns_boolean(EExpr) of
true -> rewrite_case_clauses(Opts);
false -> Opts
end,
For this optimization to apply, the following conditions must be met:
a) The expression must be marked with the optimize_boolean
flag. This flag is automatically set by the compiler for if
, !
, !!
, and
, and or
expressions. !!
represents another optimization: collapsing double negation into a truthiness check. And while and
/or
operate strictly on booleans, the optimize_boolean
flag avoids generating a third case
clause that would raise a BadBooleanError
.
b) Elixir must be able to determine that the operation will return a boolean. This primarily occurs when using operators or is_
guards from the :erlang
module, but more complex cases are also analyzed. For example if the compiler observes that every clause in a case
or cond
returns boolean values, it infers that the entire expression must return booleans.
The full logic can be seen here. Below is an excerpt of it:
returns_boolean(Bool) when is_boolean(Bool) -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_]}) when Op == 'not' -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_, _]}) when
Op == 'and'; Op == 'or'; Op == 'xor';
Op == '=='; Op == '/='; Op == '=<'; Op == '>=';
Op == '<'; Op == '>'; Op == '=:='; Op == '=/=' -> true;
returns_boolean({{'.', _, [erlang, Op]}, _, [_, Right]}) when
Op == 'andalso'; Op == 'orelse' ->
returns_boolean(Right);
returns_boolean({{'.', _, [erlang, Fun]}, _, [_]}) when
Fun == is_atom; Fun == is_binary; Fun == is_bitstring; Fun == is_boolean;
Fun == is_float; Fun == is_function; Fun == is_integer; Fun == is_list;
Fun == is_number; Fun == is_pid; Fun == is_port; Fun == is_reference;
Fun == is_tuple; Fun == is_map; Fun == is_process_alive -> true;
...
Key-based access
A modern convenience absent in Erlang is dedicated syntax for key-based access in data structures.
Elixir provides square bracket access []
, which simply compiles to a call to Access.get/3:
def brackets(data) do
data[:field]
end
brackets(_data@1) ->
'Elixir.Access':get(_data@1, field).
Then there’s also dot notation access, which works exclusively for maps with atom keys, and here’s where things get more interesting:
def dot(map) when is_map(map) do
map.field
end
dot(_map@1) when erlang:is_map(_map@1) ->
case _map@1 of
#{field := _@2} -> _@2;
_@2 ->
case elixir_erl_pass:no_parens_remote(_@2, field) of
{ok, _@1} -> _@1;
_ -> erlang:error({badkey, field, _@2})
end
end;
Instead of compiling directly into, say, erlang:map_get/2, this notation expands into two nested case
statements.
The first clause retrieves the value from the map, while the nested case
is a consequence of earlier design decisions.
The issue stems from Elixir allowing parentheses to be omitted in function calls:
iex(1)> DateTime.utc_now
~U[2025-02-17 12:35:39.575764Z]
This also applies when the module is determined at runtime:
iex(2)> mod = DateTime
DateTime
iex(3)> mod.utc_now
warning: using map.field notation (without parentheses) to invoke function DateTime.utc_now() is deprecated, you must add parentheses instead: remote.function()
(elixir 1.18.2) src/elixir.erl:386: :elixir.eval_external_handler/3
(stdlib 6.2) erl_eval.erl:919: :erl_eval.do_apply/7
(stdlib 6.2) erl_eval.erl:479: :erl_eval.expr/6
(elixir 1.18.2) src/elixir.erl:364: :elixir.eval_forms/4
(elixir 1.18.2) lib/module/parallel_checker.ex:120: Module.ParallelChecker.verify/1
~U[2025-02-17 12:36:23.248233Z]
Notice that the notation mod.utc_now
is ambiguous — it could be a function call or a key access. As a result, Elixir must generate code that checks at runtime whether the value being accessed is a function or a map.
Starting with this commit, a warning is now emitted, but the code still works.
Interestingly, the inverse scenario also requires additional logic:
def dot(module) when is_atom(module) do
module.function()
end
dot(_module@1) when erlang:is_atom(_module@1) ->
case _module@1 of
#{function := _@2} ->
elixir_erl_pass:parens_map_field(function, _@2);
_@2 -> _@2:function()
end.
Because map key access can also end with parentheses:
iex(1)> map = %{field: :value}
%{field: :value}
iex(2)> map.field()
warning: using module.function() notation (with parentheses) to fetch map field :field is deprecated, you must remove the parentheses: map.field
(elixir 1.18.2) src/elixir.erl:386: :elixir.eval_external_handler/3
(stdlib 6.2) erl_eval.erl:919: :erl_eval.do_apply/7
(elixir 1.18.2) src/elixir.erl:364: :elixir.eval_forms/4
(elixir 1.18.2) lib/module/parallel_checker.ex:120: Module.ParallelChecker.verify/1
(iex 1.18.2) lib/iex/evaluator.ex:336: IEx.Evaluator.eval_and_inspect/3
:value
Erlang to the Rescue!
The Erlang compiler optimizes code more aggressively, leveraging type information known at compile time. It can eliminate both case
clauses entirely if confident the value is a map.
The simplest way to provide this type information is via function annotations:
def function(data) when is_map(data)
or
def function(%{} = data)
As a bonus, we’ll explore how this redundant code generation impacts the final program’s performance at the end of the article. Stay tuned