Elixir: Notes about with
Posted on Thu 08 October 2020 in Elixir
This post explains how to use the keyword with
for conditional code.
Introduction
Let's discuss this example from the Joy of Elixir book:
# readfile_case.ex
file_data = %{name: "haiku.txt"}
case Map.fetch(file_data, :name) do
{:ok, name} ->
case File.read(name) do
{:ok, contents} ->
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
{:error, :enoent} ->
IO.puts "Could not find a file called #{name}"
end
:error -> "No key called :name in file_data map"
end
The structure of the code is as follows:
case expression_a do
pattern ->
case expression_b do
pattern_ok ->
do_something
pattern_error_b ->
do_something
end
pattern_error_a -> do_something
end
In this case, we read a text file called "haiku.txt"
:
- If the map
file_data
contains the key:name
, we pass the first case. - If we can read the file, then we pass the second case and return a single string.
- Otherwise, we evaluate the clause of the pattern that matches the result of the case.
This is the output of the script:
iex(1)> c "readfile_case.exs"
"I love Elixir\nIt is so easy to learn\nGreat functional code"
We can improve the readability of that block of code by using with
. The modified code is given below:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
with {:ok, name} <- Map.fetch(file_data, :name),
{:ok, contents} <- File.read(name) do
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
The output is the same as above:
iex(1)> c "readfile_with.exs"
"I love Elixir\nIt is so easy to learn\nGreat functional code"
We can say that the latter code is easier to read. Its structure is:
with expression_a,
...,
expression_n do
do_something
else
pattern_error_a -> do_something_with_a
...
pattern_error_n -> do_something_with_n
end
In short, with
allows us to specify all conditions that must be met before doing a given task (e.g., do_something
). If any of these conditions fail, we then evaluate one of the clauses in the else
block. In the previous example, it is more intuitive for us that we need to meet two conditions for reading the file:
with {:ok, name} <- Map.fetch(file_data, :name), # if file_data contains the key :name and
{:ok, contents} <- File.read(name) do # if we can read the file
# work with the contents of the file
Notice that we use the <-
operator to define each expression. We could rather use =
, but it will do something else. We discuss it at the end of the post. If Map.fetch
fails, then we obtain an atom :error
. If File.read
fails, then we get a tuple {:error, msg}
, like {:error, :enoent}
. We can handle these errors in the else
block:
with ... do
do_something
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
Make it fail
Let's look for :filename
rather than :name
in file_data
:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
with {:ok, name} <- Map.fetch(file_data, :filename), # modified
{:ok, contents} <- File.read(name) do
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
We got an error message, as expected:
iex(1)> c "readfile_with.exs"
:name key missing in file_data
Now, suppose we are interested in reading other file, say "poem.txt"
:
# readfile_with.exs
file_data = %{name: "poem.txt"} # modified
with {:ok, name} <- Map.fetch(file_data, :name),
{:ok, contents} <- File.read(name) do
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> IO.puts ":name key missing in file_data"
{:error, :enoent} -> IO.puts "Couldn't read file"
end
Again, the code works as expected and shows a proper message:
iex(2)> c "readfile_with.exs"
Couldn't read file
Some gotchas
Use <-
instead of =
One thing that I noted when using with
for the first time is the <-
operator. Indeed, we can use either <-
or =
in the previous example. Although, there is a subtle difference. Let's replace <-
with =
in the previous example:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
with {:ok, name} = Map.fetch(file_data, :name), # modified
{:ok, contents} = File.read(name) do # modified
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
Now, we get this warning:
iex(1)> c "readfile_with.exs"
warning: "else" clauses will never match because all patterns in "with" will always match
readfile_with.exs:3
"I love Elixir\nIt is so easy to learn\nGreat functional code"
Actually, we can change the code to introduce an error. Let's change the name of the text file again:
# readfile_with.exs
file_data = %{name: "poem.txt"} # modified
with {:ok, name} = Map.fetch(file_data, :name), # modified
{:ok, contents} = File.read(name) do # modified
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
This time, we also get a compilation error. This error is due the result of File.read(name)
does not match{:ok, contents}
. In this case, the result is {:error, :enoent}
:
iex(1)> c "readfile_with.exs"
warning: "else" clauses will never match because all patterns in "with" will always match
readfile_with.exs:3
== Compilation error in file readfile_with.exs ==
** (MatchError) no match of right hand side value: {:error, :enoent}
readfile_with.exs:4: (file)
(elixir) lib/kernel/parallel_compiler.ex:229: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7
** (CompileError) compile error
(iex) lib/iex/helpers.ex:200: IEx.Helpers.c/2
Now, let's replace :name
with :filename
:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
with {:ok, name} = Map.fetch(file_data, :filename), # modified
{:ok, contents} = File.read(name) do # modified
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
:error -> ":name key missing in file_data"
{:error, :enoent} -> "Couldn't read file"
end
Again, we got an error. This time, the result of Map.fetch(file_data, :filename)
does not match {:ok, name}
. The result is :error
:
iex(1)> c "readfile_with.exs"
warning: "else" clauses will never match because all patterns in "with" will always match
readfile_with.exs:3
== Compilation error in file readfile_with.exs ==
** (MatchError) no match of right hand side value: :error
readfile_with.exs:3: (file)
(elixir) lib/kernel/parallel_compiler.ex:229: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7
** (CompileError) compile error
(iex) lib/iex/helpers.ex:200: IEx.Helpers.c/2
Note that if we use =
instead of <-
, the else
block is ignored. From Programming Elixir, p. 38:
In the previous example, the head of the
with
expression used=
for basic pattern matches. If any of these had failed, aMatchError
exception would be raised. But perhaps we'd want to handle this case in a more elegant way. That's where the<-
operator comes in. If you use<-
instead of=
in awith
expression, it performs a match, but if it fails it returns the value that couldn't be matched.
Let's check our previous errors. In the first case, the resulting value is :error
since :filename
is not in the map:
iex(1)> file_data = %{name: "haiku.txt"}
%{name: "haiku.txt"}
iex(2)> with {:ok, name} <- Map.fetch(file_data, :name), do: name # success
"haiku.txt"
iex(3)> with {:ok, name} <- Map.fetch(file_data, :filename), do: name # failure
:error
In the second case, we tried to read a file that does not exist. Hence, the resulting value is {:error, :enoent}
:
iex(6)> with {:ok, contents} <- File.read("haiku.txt"), do: contents # success
"rixilE evol I\nnrael ot ysae os si tI\nedoc lanoitcnuf taerG"
iex(7)> with {:ok, contents} <- File.read("poem.txt"), do: contents # failure
{:error, :enoent}
We can use the resulting value of with
to match one of the clauses in the else
block. Although, I noted that Dave did not mention that in his book. Let's assign the result of with
to a variable and print it:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
result = 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")
end
IO.puts result
This is the result:
iex(3)> c "readfile_with.exs"
I love Elixir
It is so easy to learn
Great functional code
Now, introduce an error:
# readfile_with.exs
file_data = %{name: "poem.txt"} # invalid filename
result = with {:ok, name} <- Map.fetch(file_data, :name),
{:ok, contents} <- File.read(name) do # <- operator
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
end
IO.puts "#{inspect result}" # do not use IO.puts result, it will throw an exception
# because result may not be a string
This is the result:
iex(6)> c "readfile_with.exs"
{:error, :enoent}
Now, use another key:
# readfile_with.exs
file_data = %{name: "haiku.txt"}
result = with {:ok, name} <- Map.fetch(file_data, :filename), # invalid key
{:ok, contents} <- File.read(name) do
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
end
IO.puts "#{inspect result}" # do not use IO.puts result, it will throw an exception
# because result may not be a string
This is the result:
iex(9)> c "readfile_with.exs"
:error
Scope of variables
From Programming Elixir, p. 37:
[...]
width
allows you to define a local scope for variables. If you need a couple of temporary variables when calculating something, and you don't want those variables to leak out into the wider scope, usewith
.
In other words, the variables defined within a with
are temporary. In the following code, we define two variables: name
and contents
. Note that we also use the same variable names within the with
block. We then print the value of name
and the value of contents
. You will see that these variables are unchanged:
# readfile_with.exs
name = "unchanged"
contents = "unchanged"
file_data = %{name: "haiku.txt"}
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")
end
IO.puts "name: #{name}"
IO.puts "contents: #{contents}"
This is the output:
iex(4)> c "readfile_with.exs"
name: unchanged
contents: unchanged
Order of clauses in the else
block
Finally, let's talk about the order of the clauses in the else
block. At first, I believed that you must define each clause in the same order as the conditions in the header of the with
block. However, that is not true. Instead, you must define the most general clause at the end. That is, something like _ -> "Generic error"
must be the last clause. Just for completeness, let's see what happens when you use _
to match the result of any of the conditions of the header of with
:
# readfile_with.exs
file_data = %{name: "poem.txt"}
result = with {:ok, name} <- Map.fetch(file_data, :name),
{:ok, contents} <- File.read(name) do
line = contents
|> String.split("\n", trim: true)
|> Enum.map(&String.reverse/1)
|> Enum.join("\n")
IO.puts "#{inspect line}"
else
_ -> "Generic error"
{:error, :enoent} -> "Couldn't read file"
:error -> ":name key missing in file_data"
end
IO.puts result
This is the result:
iex(19)> c "readfile_with.exs"
Generic error
Note I believed that Elixir would give me a warning to indicate that _ -> "Generic error"
will match anything. But it did not. As far as I know, this kind of warning only happens when working with modules. For example, if you try to run this code, Elixir will show a warning:
defmodule SampleModule do
def hi(_) do
IO.puts "Hi, stranger!"
end
def hi(%{name: name}) do
IO.puts "Hi, #{name}"
end
end
SampleModule.hi(%{name: "laura"})
This is the output:
iex(1)> c "sample_module.exs"
warning: this clause cannot match because a previous clause at line 3 always matches
sample_module.exs:7
Hi, stranger!
[SampleModule]