Step up your Elixir game

Step up your Elixir game

As you may have notice from our previous articles, we write more and more Elixir code at Kalvad. It comes very handy when we need to build scalable systems and the amazing Phoenix LiveView framework allows us to create rich and real-time web-applications.

Elixir is a pure functionnal programming language (and comes with all its benefits) but unlike other language of the like, you can also write Elixir code the same way you would write Javascript, Python or Ruby.
This is great when you are new with the language, but if you don't pay attention, you might keep your old habit and never uses the cool features that make Elixir such a wonderful language.

In this article, I will try to introduce all the cool and unique features of Elixir you must start to use if you want to write proper Elixir code.

Pattern matching

Pattern matching is something you will soon really miss when working with other languages. You can see it as a multi layers filter. Each layer being function with different input parameters. Order matters since all function will be tried from top to bottom until one matches. If no function match, an error will be raised.

defmodule World do
	def great() do
    	case Enum.random(0..4) do
        	0 -> "Hello"
            1 -> "Bonjour"
            3 -> "Hallo"
            4 -> "Hola"
        end
    end
end

defmodule Parser do
  def origin("Hello") do
    :english
  end

  def origin("Bonjour") do
    :french
  end

  def origin("Hallo") do
    :german
  end

  def origin("Hola") do
    :spanish
  end

  def origin(x) do
    :unknown
  end
end

Parser.origin(World.great)

This is a very simple example using strings. But pattern matching is much more powerful. The last chapter of this article will introduce more advanced pattern matching.

Case

case is probably the first statement you will learn when working with Elixir. It simply runs a pattern matching on a value. It also returns the result of the statement.

def hello_world(world) do
  is_french = case World.great() do
    "Bonjour" -> true
    x -> false
  end
end

It is very useful, however, you should not extensively use it. Instead you should write small functions and replace your case statement with pattern matching on functions. A good rule of thumb to remember is that you should not embed a case inside another case.

Conditions

Conditions are extremely common in every language. It is also available in Elixir however we don't see it that much. Because unlike the casestatement, conditions do not allow you to assign a value.

If

if false do
  "This will never be seen"
else
  "This will"
end

Elixir common practice tend to avoid if/else. Instead we usually write small functions that handle both scenario. However, the inline if/else is much more common

if false, do: "This will never be seen", else: "This will"

Cond

This statement allow you to test multiple conditions at once:

cond do
  1 + 1 == 3 ->
    "I will never be seen"
  2 * 5 == 12 ->
    "Me neither"
  true ->
    "But I will (this is essentially an else)"
end

Pipe

Pipes are probably the operator you need the less, but it so highly increase code readability that you should use it everywhere. It just inject the value returned by the previous statement as the first parameter of the next function.

def parse_csv(csv_file_pathname) do
	csv_file_pathname
    |> read_file()
    |> parse_content()
    |> remove_header()
end

Pipes are awesome because they really look like a... pipeline. You inject data at the top, each step will transform the data until you get the expected data at the end of the pipeline.

Better pipelines

Recently, Elixir introduced 2 new very useful macros for pipeline.

  • tap/2 takes 2 parameters. The first one is the data and the second is a function. It will execute the function and returns the initial data passed to tap.
  • then/2 takes the 2 same parameters than tap. However, unlike tap, it returns the result of the function send as second parameter.
def register(email, password) do
  email
  |> is_email_valid()
  |> then(&Regex.replace(~r/\s/, &1, "")) # Regex.replace takes the string as a second parameter. The standard pipe operator only let you inject the data as the 1st parameter. By using then, we can inject the data at any position
  |> tap(&send_email_validation(&1)) # tap will return the initial email instead of the result of `send_email_validation`
  |> create_account(password)
end

With

The pipe operator is great. However it gives no space for flexibility. You can't really handle errors properly and you can't either save a value into a variable in the middle of the process.

The with operator is amazing because it lets you handle each steps of a pipeline one by one and properly process errors if necessary.

def register_user(params) do
	with {:ok, email} <- parse_and_validate_email(params["email"]),
    	 {:ok} <- validate_password(params["password"], params["confirm_password"]),
    	 {:ok, account} <- create_account(params["email"], params["password"), 
    do
    	# you have access to variable defined within the `with` statement
    	send_register_email(email)
        account
    else
    	# you can use pattern matching to handle errors
    	{:error, :password_not_matching} -> {:error, "Password are not similar"}
        {:error, :password_too_small -> {:error, "Password should be at least 8 characters"}
        err -> err
    end
end

Guards

Guard is an additional filter to pattern matching. But unlike pattern matching, it let you execute Kernel functions. From the official documentation, below are all the tests your can do with guards:

  • comparison operators (==, !=, ===, !==, >, >=, <, <=)
  • strictly boolean operators (and, or, not). Note &&, ||, and ! sibling operators are not allowed as they're not strictly boolean - meaning they don't require arguments to be booleans
  • arithmetic unary and binary operators (+, -, +, -, *, /)
  • in and not in operators (as long as the right-hand side is a list or a range)
  • "type-check" functions (is_list/1, is_number/1, etc.)
  • functions that work on built-in datatypes (abs/1, map_size/1, etc.)

It becomes very useful to validate the data you are working with:

def convert_to(amount, 'EUR' = currency) when is_integer(amount) do
	convert_to(to_float(amount), currency)
end

def convert_to(amount, 'EUR') when is_float(amount) do
	# ...
end

Guards can also be used in other places such as in case statement:

case amount do
	n when is_integer(n) -> convert_to(to_float(amount), 'EUR')
    n -> convert_to(n, 'EUR')
end

Advanced pattern matching

With list

Learning pattern matching with list allows you to stop using the Enum module for processing list of data.

In order to do list pattern matching, it's important to understand how list are represented in Elixir.

[] # an empty list
[head | tail] # 'head' contains the first element of the list and 'tail' the other items
[head | [tail_head | tail_tail]] # since the tail is always an array, you can embed them

And it's that easy. If you want to loop through a list, you can simply do like the following:

defmodule Toto do
	def toto([head | tail]) do
		IO.puts("HEAD > #{head}")
        toto(tail)
    end
    
    def toto([]) do
    	IO.puts("DONE.")
    end
end

Toto.toto([1, 2, 3, 4, 5])

## OUTPUT
# HEAD > 1
# HEAD > 2
# HEAD > 3
# HEAD > 4
# HEAD > 5
# DONE.

With structure

You can, somehow, reproduce what a strongly typed language by clearly defining the structure you are expecting.

def register(%User{} = user) do 
	IO.puts("Register > #{inspect user}")
end

And naming the parameters

You can apply pattern matching while also naming the parameters. This can be useful when you want to do pattern matching on a struct elements for example while keeping the structure as a whole.

def register_user(%{"Hyundai" => brand} = car) do
	IO.puts("Car brand > #{brand}")
    push_car(car)
end

By values

In the first part of this article, we demonstrate how to run a function based the value of its parameters:


defmodule Parser do
  def origin("Hello") do
    :english
  end

  def origin("Bonjour") do
    :french
  end

  def origin("Hallo") do
    :german
  end

  def origin("Hola") do
    :spanish
  end

  def origin(x) do
    :unknown
  end
end

Here the VM will test the value of the parameters sent to origin and run the appropriate function. Is none of the first ones match, then the last one, not expecting a specific value, will be executed.

But we can do also test values wrapped in lists or maps:

def register(%{%User{}, "google" => provider}) do
  # register user
  :ok
end

def register(%{%User{}, "facebook" => provider}) do
  # register user
  :ok
end

register(%User{email: "xxx"}, provider: "google") # :ok
register(%User{email: "xxx"}, provider: "facebook") # :ok
register(%User{email: "xxx"}, provider: "reddit") # :error
with struct
def process([1 | tails]) do
	:ok
end

process([1, 2]) :ok
process([3, 4, 5, 6]) :error
with list

Bonus

Think about typespecs and behaviours ! Sadly, they are often left out but can really provide a great help when working on big code base. I wouldn't be able to explain it better than the official documentation.

Done for today ! Thank you for reading, and I really hope this article will help you to discover a bit more about Elixir !

If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.