Joy of Elixir

12. Conditional code

In the last chapter, we have encountered a situation where our code can have different outcomes. Outcomes that we cannot predict from within the code itself. As an example, when we call File.read/1 we could have two main outcomes:

  1. The file exists, and so the file is read successfully. We get back an {:ok, contents} tuple.
  2. The file does not exist, and so we get back a {:error, :enoent} tuple instead.

We have dealt with this in the past by making our program crash if we don't get an expected value. We do this by pattern matching on the outcome that we expect.

iex> {:ok, contents} = File.read("haiku.txt")

In this example, if File.read/1 succeeds then everyone is happy. We're expecting to get back a tuple here with two elements: 1) an :ok atom, and 2) the contents of the file.

But if our expectations aren't met because the haiku.txt file is missing, then things aren't so great:

{:ok, contents} = File.read("haiku.txt")
** (MatchError) no match of right hand side value: {:error, :enoent}

Elixir tells us that something went wrong and will refuse to do anything more about it until we give it different instructions.

We need to cater for this sort of thing happening in the Elixir programs that we write. Sometimes, files are missing. The way that we can handle this is by using some conditional code within Elixir. Elixir has four main helpers for conditional code. They are: case, cond,if, and with. Conditional code allows us to give Elixir different instructions depending on what happens during the running of any Elixir program. Let's look at some examples.

case

As we just talked about, we saw a case where file reading could fail. When we call this function, it can have two outcomes:

  1. The file exists, and so the file is read successfully. We get back an {:ok, contents} tuple.
  2. The file does not exist, and so we get back a {:error, :enoent} tuple instead.

In both cases, we get back a tuple, but what we can do with that tuple is dependent on what's inside. If we get back {:ok, contents} then we can carry on working with that file. But if we get {:error, :enoent} then we will have to make our program stop.

One of the ways to get Elixir to behave this way is to use case:

iex> case File.read("haiku.txt") do
  {:ok, contents} ->
    contents
    |> String.split("\n", trim: true)
    |> Enum.map(&String.reverse/1)
    |> Enum.join("\n")
  {:error, :enoent} ->
    IO.puts "Could not find haiku.txt"
end

This case statement uses much of the same code we saw in the previous chapter, but now goes different routes depending on the output of File.read/1. If File.read/1 returns something that matches the pattern of {:ok, contents}, then our file's code will be parsed and reversed correctly, resulting in this output:

"I love Elixir\nIt is so easy to learn\nGreat functional code"

However, if that File.read/1 call results in something that matches {:error, :enoent}, then we will see an error message telling us that we couldn't find that file.

Could not find haiku.txt

These two "forks in the road" for this case statement are referred to as clauses.

You might recognise this code as being similar to a function we defined back in Chapter 6:

iex> road = fn
  "high" -> "You take the high road!"
  "low" -> "I'll take the low road! (and I'll get there before you)"
  _ -> "Take the 'high' road or the 'low' road, thanks!"
end

This is because it is the same underlying principle. We're using pattern matching inside the case in this chapter to determine what to do, just like we did in that function 6 chapters ago. Before the -> we tell Elixir what we want to match. Then after that, we tell it what we want to do once that match happens. In our case statement, we put that "after" code on separate lines and this is just to increase readability of the code. We could've done the same in our function too:

iex> road = fn
  "high" ->
    "You take the high road!"
  "low" ->
    "I'll take the low road! (and I'll get there before you)"
  _ ->
    "Take the 'high' road or the 'low' road, thanks!"
end

You might notice that in this function block, we have the catch-all clause at the end (_ ->). This is the last-ditch effort for the function to do something. It's worth knowing that we could do the same thing in our case statements too:

iex> case File.read("haiku.txt") do
  {:ok, contents} ->
    contents
    |> String.split("\n", trim: true)
    |> Enum.map(&String.reverse/1)
    |> Enum.join("\n")
  {:error, :enoent} ->
    IO.puts "Could not find haiku.txt"
  _ ->
    IO.puts "Something unexpected happened, please try again."
end

In this code, if Elixir sees something that is not known to the case statement then it will give us a completely different message. While we're on this topic of catch-all clauses, I want to show you one more precise way of doing this too:

iex> case File.read("haiku.txt") do
  {:ok, contents} ->
    contents
    |> String.split("\n", trim: true)
    |> Enum.map(&String.reverse/1)
    |> Enum.join("\n")
  {:error, :enoent} ->
    IO.puts "Could not find haiku.txt"
  {:error, _} ->
    IO.puts "Something unexpected happened, please try again."
end

This time, our last clause will not match everything and anything. It will only match tuples that have exactly two items in them, and the first item must be :error. As we can see here, this is using the pattern matching feature in Elixir that we've seen a few times throughout this book already. The tuple for the last clause isn't exactly {:error, _}, but it is something that is in the same pattern. This pattern matching is why the last clause would match any other error that may be returned from File.read/1.

This is a better approach, because it is clearer to anyone else seeing this code what we might expect when something unexpected happens.

Now we know Elixir has two places where these clauses are used: functions and case blocks. We're about to see another one.

cond

The case statement is good to use if you want to compare the outcome of one particular action and do different things depending on whatever that outcome is. In the last section, that outcome was the output of a File.read/1 function call.

What we'll see in this section is that case has a cousin called cond which provides us a way of checking multiple conditions (cond is short for "condition"), and then running some code for whatever clause is true. Here's a quick example:

iex> num = 50
50
iex> cond do
  num < 50 -> IO.puts "Number is less than 50"
  num > 50 -> IO.puts "Number is greater than 50"
  num == 50 -> IO.puts "Number is exactly 50"
end
Number is exactly 50
:ok

Izzy asks: "What does <, > and == mean? We've never seen those before!" Yes! This is the first time in twelve chapters that we've seen these things. Now is a great time to cover what they do. <, > and == are ways to compare two values in Elixir. You can probably guess from the code that < means "less than", > means "greater than", and that == is "exactly equal to". But what is this code actually doing?

If we take the comparisons out of the cond and run them individually, we'll have a clearer picture of what this code is doing:

iex> num > 50
false
iex> num < 50
false
iex> num == 50
true

These comparisons are going to compare the two values and then tell us if those comparisons are true or false. This is our first exposure to code that outputs either true or false. Think of it like this: if we were to ask the question of "is num equal to 50", what would the answer be? We would normally say "yes, it is equal". Elixir's version of an answer to this question is true.

When we use these comparisons in cond, the first clause where the comparison results in true will execute that clause's code. Go ahead and change the number in the cond code above to see how it might react to those changes.

if, else and unless

Now that we've seen what case and cond can do, let's look at two more related conditional statements: if and unless and their compatriot else.

The cond statement was really helpful if we had multiple conditions to compare against. In the previous code, we wanted to check if the number was less than, greater than or exactly equal to 50:

iex> num = 50
50
iex> cond do
       num < 50 -> IO.puts "Number is less than 50"
       num > 50 -> IO.puts "Number is greater than 50"
       num == 50 -> IO.puts "Number is exactly 50"
     end
Number is exactly 50
:ok

But what if we only wanted to check if the number was exactly 50? Well, we could remove the first two clauses from this cond statement:

iex> cond do
       num == 50 -> IO.puts "Number is exactly 50"
     end
Number is exactly 50
:ok

This is one way of writing this code and it will work perfectly fine. However, if the number was not 50, then we would see an error come from this code:

iex> num = 10
10
iex> cond do
       num == 50 -> IO.puts "Number is exactly 50"
     end
** (CondClauseError) no cond clause evaluated to a true value

This is happening because cond always requires at least one of its conditions to evaluate to a true value. In the code we've just attempted, num == 50 will be false, not true, and because it is the only clause in this cond we will see this error.

If we've got code like this in Elixir where we're running code conditionally and we don't want Elixir to show us big scary error messages like this one, we should be using if instead. Let's look at how we could attempt the same code with if:

iex> num = 10
10
iex> if num == 50 do
        IO.puts "Number is exactly 50"
      end
nil

Because the condition here is not true, nothing happens. The way that Elixir represents nothing is with nil. We asked Elixir to only execute code if the condition is true, but it wasn't. So nil is the outcome.

unless

Now we've seen how to do something if a particular condition is true, but what happens if we want to do something if it is false? For this, Elixir gives us unless:

iex> num = 10
10
iex> unless num == 50 do
        IO.puts "Number is not 50"
      end
Number is not 50
:ok

In this short example, Elixir will output our "Number is not 50" message if the number is not 50. If the number is 50, then nothing (nil) will be the result of this code.

If you're unsure of whether to use if or unless, try reading the code out loud. Does it make more sense to say "unless the number is equal to 50"? In this case, yes it does. But let's try another example:

iex> num = 10
10
iex> unless num != 50 do
        IO.puts "Number is 50"
      end
Number is 50
:ok

This time, the code reads in English as "unless the number is not equal (!=) to 50". This sentence contains a double negative with the use of "unless" and "not", and so using unless in this example is unsuitable. The code should use an if instead:

iex> num = 50
50
iex> if num == 50 do
       IO.puts "Number is 50"
     end
Number is 50
:ok

Now the code reads as "if the number is equal to 50", and that makes a lot more sense!

else

We've now seen if and its opposite unless, but what if we wanted to do if and unless at the same time? What if we wanted Elixir to do some code if a particular condition was true, but then do some other code if it wasn't true?

For this, Elixir gives us else:

iex> num = 10
10
iex> if num == 50 do
       IO.puts "Number is 50"
     else
       IO.puts "Number is not 50"
     end
Number is not 50
:ok

This would read like: "if the number is 50, show 'Number is 50', otherwise, show 'Number is not 50'". In this code, our number is not 50, and so we see Elixir tell us that.

with

There's one more feature of Elixir to do with conditional code that I would love to show you before we finish off this chapter. It is called with. The with feature allows us to chain together multiple operations, and only continue if each of those operations is successful. Before we look at how to use with, let's look at the type of problem it is good at solving.

Let's say that we had somehow came across a map containing this data in our travels:

iex> file_data = %{name: "haiku.txt"}

This map is designed to tell us what file to read our haiku from. This map contains a single key which is the atom :name, and its value is the string "haiku.txt". So we could know by accessing the name key in this map what file to read from. Here's one way we could do it:

iex> File.read(file_data["name"])
{:ok, "rixilE evol I..."}

But what would happen here if the map didn't contain this key, but instead a differently named key? Then our code would break:

iex> file_data = %{filename: "haiku.txt"}
%{filename: "haiku.txt"}
iex> File.read(file_data["name"])
{:error, :enoent}

Our programs need to be written in such a way to protect against this sort of thing. In this particular case, we must make them expect to read a value from a key called :name, not :filename. Once it has done that, then it can try to read that file.

One way to write this would be to use a case statement inside another case statement, like this:

file_data = %{name: "haiku.txt"}
case Map.fetch(file_data, :name) do
  {:ok, name} ->
    case File.read(name) do
      {:ok, contents} ->
        contents
        |> String.split("\n", trim: true)
        |> Enum.map(&String.reverse/1)
        |> Enum.join("\n")
      {:error, :enoent} ->
        IO.puts "Could not find a file called #{name}"
    end
  :error -> "No key called :name in file_data map"
end

In this code, we attempt to find the :name key in file_data with Map.fetch/2. This Map.fetch/2 function is new to us here, and so we'll quickly cover what it does.

The way Map.fetch/2 works is that if there is a key in the map then Map.fetch/2 will return {:ok, name}. If there isn't, it will return the atom :error.

We pattern match on either of these two outcomes during this case statement. In this example, there is going to be a :name key, and so it will match the {:ok, name} part of our case statement. Then the second case statement will come into effect.

This second case statement attempts to read a file using File.read/1. If this file exists, then this function will return {:ok, contents}. If the file does not exist, {:error, :enoent} will be returned instead. In this case, we know that the file exists from our previous attempts at reading it, and so this code will execute successfully.

This code is a little hard to read, as we need to really focus to keep our mind on where we are in the code. A with statement can simplify this code:

with {:ok, name} <- Map.fetch(file_data, :name),
     {:ok, contents} <- File.read(name) do
  contents
  |> String.split("\n", trim: true)
  |> Enum.map(&String.reverse/1)
  |> Enum.join("\n")
  |> IO.puts
else
  :error -> ":name key missing in file_data"
  {:error, :enoent} -> "Couldn't read file"
end

In Elixir, with statements are to be read like a series of instructions of what to do when everything goes right. In this example, if Map.fetch/2 succeeds and returns {:ok, name}, then Elixir will be able to use it in the next step, when it calls File.read/1. After that, our code will work as intended.

However, if Map.fetch/2 fails, then :error will be returned. We handle that in the else inside this with block, telling with that if we see :error returned, that we want to see an error message saying that the :name key was missing. Then if File.read/1 fails and returns {:error, :enoent}, then we want it to tell us that it couldn't read the file.

This code is a little neater than the double-case code because all of our code that deals with the success of our program is grouped neatly at the top, and all the code that deals with the failure is grouped at the bottom.

I would encourage you to play around here with the with statement to get a better understanding of it. What happens if you change file_data so that it doesn't have a :name key? What happens if that haiku.txt file goes missing?