Why Learn Elixir in 2023? — Elixir Tutorial for Beginners

Jan Dudulski

elixir

At Monterail, we have fancied the Elixir language for quite a long time. However, for some reasons, we failed to get it into production usage except for a few minor implementations. And I felt like this should change. So when Plataformatec shared the idea of how to learn the language together in teams, within a company, we didn’t hesitate long. In simplest terms—you go through the book for beginners called Learn Functional Programming with Elixir, dedicate each week to the next chapter, and do exercises listed at the end of each chapter. Doesn’t it sound like an awesome initiative? 

I want to share the reasons why I think you should learn Elixir in 2023 (yeah, my opinion hasn't changed since 2019 when creating this post!) Let's start with why we got interested in Elixir many years ago. 

So we started learning Elixir Phoenix during regular meetings every Friday evening. And then Advent came, also to the coding life and we expanded Elixir education onto participating in the Advent of Code with the language creator, José Valim. AoC is a series of small programming puzzles for a variety of skill sets and skill levels in any programming language you like. It allowed us to learn A LOT in such a short time—it was challenging, exhausting but most above all, really fun!

And as a result of our effort, we managed to introduce Elixir to a key part of our client’s application.

We learned our lessons so I decided to share with you some of my findings, i.e. patterns, specific functions or quirks. A very experienced Elixir developer probably won’t catch much new here but if you are new in the alchemists' community, you’re more than likely to benefit from it.

Scripting with Elixir — a short tutorial

When you come from the world similar to Ruby, you might have an issue at the beginning with organizing a workflow when you just want to script a small thing. How to test the script with Elixir? Do I need a full mix application? Do I have to recompile the code after every change? Luckily, there is a way:


defmodule Example do
  def fun(bar, baz) do
    ...
  end
end

case System.argv() do
  ["--test"] ->
    ExUnit.start()

    defmodule ExampleTest do
      use ExUnit.Case

      import Example

      test "my code" do
        assert fun() == 1
      end
    end

  [arg1, arg2] ->
    Example.fun(arg1, arg2) |> IO.puts()
end

Simple case on System.argv() allows you to create a script with tests as simple to run as typing elixir script.exs --test.

And when talking about testing, I cannot keep myself from mentioning Doctest—it’s so cool that it should be banned. 

Beware of the syntax

Anonymous function with ampersand is probably the single “weird” syntax exception in the Elixir. Ampersand functions require some practice to get used to and they probably hit you a few times when used in pipes. So, just remember - this won’t work:

input |> &(&1 + 1).()

To make it valid, you need additional parentheses:

input |> (&(&1 + 1)).()

Simply . has higher precedence over & so the former version is parsed as:

input |> &((&1 + 1).())

which, of course, doesn’t make any sense.

Pattern matching is probably much more powerful than you think

a toy story meme

 

If I had to select a single feature of Elixir (and functional languages in general) which I miss in Ruby, it would be pattern matching. It gets into habit so much and so quickly that you will miss it too, after just days, if not hours, spent with Elixir. Just look at the possibilities it gives you:

Blurring unnecessary parts

From simple assignment:

{num, _rest} = Integer.parse("38e")

through an option to ignore redundant part of the map:

regex = ~r/^(?<first_name>\S+) (?<last_name>\S+)$/
%{"first_name" => first_name} = Regex.named_captures(regex, "Jan Dudulski")

to elegant way to fetch some data for manipulation:

def coerce(%{"date" => date} = map) do
  %{map | "date" => NaiveDateTime.from_iso8601!(date)}
end

Recurrency and functions

 You can iterate on a list:

def fun([head | tail]), do: ...

as well as on strings:

def fun(<<head, tail::binary>>), do: ...

Please read the documentation to learn more about the binaries and the syntax <<>>.

And by the way - you can pattern match arguments of anonymous functions too:

example = fn
  {:ok, var} -> var
  :error -> :error
end

In functional languages you usefoldlaka reducea lot. So it’s useful to get such idiom down:

list |> Enum.reduce([], &fun/2)

def fun(first_elem, []) do
  # initialization logic with first elem
end
def fun(elem, acc) do
  # reduce rest of elems
end

Finally list comprehensions. They require some practice to click and fully discover their possibilities. E.g., you can filter the input thanks to pattern matching:

list = [{1, 2}, {1, 2, 3}, {2, 3}]

for {x, y} <- list, do: x + y

# outputs: [3, 5]

List comprehensions

As I said, it requires some practice to realize the power underneath. E.g. you can unfold list of ranges with some additional filtering:


list_of_ranges = [1..10, 12..15]

for range <- list_of_ranges, i <- range, Integer.mod(i, 3) == 0, do: i

# outputs: [3, 6, 9, 12, 15]

Or you can simply generate a map with coordinates and initial value:


for i <- 1..3, j <- 1..3, into: %{}, do: {% raw %}{{i, j}, 0 }{% endraw %}


# outputs:

%{
  {1, 1} => 0,
  {1, 2} => 0,
  {1, 3} => 0,
  {2, 1} => 0,
  {2, 2} => 0,
  {2, 3} => 0,
  {3, 1} => 0,
  {3, 2} => 0,
  {3, 3} => 0
}

Pipelining

Everyone seems to love pipe operator |>. You might love it even more when you realize that it cooperates nicely with IO.inspect:

list
|> Enum.something(...)
|> IO.inspect(label: "Show me data on this step")
|> Enum.something(...)
|> IO.inspect(label: "Show me data on another step")
|> ...

Another way to pipeline data on multiple steps might be achieved with with keyword but it has another special feature - else block so you can safely handle special cases, like errors. E.g.:

with {:ok, val1} <- do_something(arg),
     {:ok, {one, two, three}} <- do_something_else(val1),
     {:ok, val1} <-  do_some_overwrite(one, two) do
  final_result(val1, one, two, three)
else
  {:error, msg} -> # would be called when any of above won't match
    return_error_msg
    
  {:different_error, msg} ->
    return_different_error_msg
end

Standard library

As with every language you have to spend some time to learn the standard library and, in some cases, you might code some multi-line monster, instead of using idiomatic solution. Like this:

if Map.member?(map, key) do
  Map.put(map, key, initial_value)
else
  Map.put(map, key, map[key] + 1)
end

# instead of

Map.update(map, key, initial_value, &(&1 + 1))
OR
    def example(input) do
      ...
      {:ok, result}
    end
    
    list
    |> Enum.map(&example/1)
    |> Enum.something(fn {_, result} -> result end))
    
    # instead of
    list
    |> Enum.map(&example/1)
    |> Enum.something(&elem(&1, 0))
OR
example = compute(arg)

case example do
  ...
end

# instead of

compute(arg)
|> case do
  ...
end

# yes, you can pipe into case, if and others!

Non-standard library

Do you need to parse an input? Do you think about regex? Stop. And consider NimbleParsec, another awesome child of the creator of Elixir - José Valim.

Stream will change your way of thinking

One of the most exposed killer feature of Elixir is the Beam and the way it handles concurrency. Elixir raises it’s appealing with constructs likeTask.async_stream, thanks to which it’s dead simple to rewrite synchronous operations on an enumerable into asynchronous:

list |> Enum.map(fn elem -> something end)

# into

list |> Task.async(fn elem -> something end) |> Enum.map(fn {:ok, result} -> result end)

However, for regular tasks I found Streamto be much more attractive feature, and I have to stress iterative and unfold off here.

It’s one of the things, that once learnt, you want to use everywhere. With Stream.unfold/2your code becomes more declarative. It’s hard to explain in plain words so let’s look at some code that will show 5 consecutive Fibonacci numbers summing up to something above 10: 

{0, 1} # initial accumulator - first two Fibonacci numbers
|> Stream.unfold(fn {h1, h2} ->
  next_val = h1 + h2
  # produce next value and return accumulator for next call
  {h1, {h2, next_val}}
end)
|> Stream.chunk_every(5, 1) # chunk into: [0, 1, 1, 2, 3], [1, 1, 2, 3, 5]...
|> Stream.map(&Enum.sum/1) # sum every chunk
|> Enum.find(&(&1 > 10)) # return the answer

Know your IEx

h Module.function will give you documentation. IEx.Info.info(something) will give you some nice details (e.g. type) of something.

i something will give you almost the same as above but in more verbose form.

recompilerecompiles the code when you are in iex -S mix mode.

v returns last line (like `_` in `irb`) and v(num)returns line at num.

IO.inspect([100], charlists: false) will output [100] instead of [d]and IO.inspect(limit: :infinity) will not shrink the output for long lists/maps etc. just be careful with inspecting - that might hit performance (as any IO operations in general).

When should you use Erlang?

Every module is a symbol in Elixir, e.g. Enumis equal to :"Elixir.Enum". To access erlang modules just use their names as symbols. Two examples that I found useful already:

:timer.tc(Module, fun_name, [arg1, arg2]) the simplest “benchmarking” tool.

:array.new,:array.set(index, value, arr), :array.get(index, arr) - when list is not enough you can use Erlang’s arrays.

Ready to take up the challenge? 

If you struggle to find motivation or opportunity to learn a new language, “the Advent of Code” might be a trigger to start. And when you invite others, you will also gain an extra dose of motivation, healthy “competition” and a lot of material for further discussion. 

At least, that was our experience. And by the way - we didn’t change our minds. Elixir is a really cool language!

Cta image
Jan Dudulski avatar
Jan Dudulski