Case Statement
The case
statement is a little powerhouse of pattern matching. It is a common tool and you want to be comfortable with it.
Contents
Simple Yet Powerful
Understanding the basics of the case
statement is simple. If you are familiar with case
or switch
statements in other languages then you already have a good idea of how it works. The case
statement evaluates an expression and starts matching it against the different conditions starting from the top. It stops at the first match.
The difference with the Elixir case
compared to other languages is that pattern matching support is fully baked in. This is the real power of the case
statement! It is a compact way to test something against a set of pattern match clauses.
case expression do
pattern ->
expression_when_matches
pattern ->
expression_when_matches
end
There are a few nuances we should cover, so let’s get to it!
Something Must Match
If nothing matches, a type of no-match error is thrown. In this case, a CaseClauseError
.
number = 10
case number do
4 -> "Four"
5 -> "Five"
end
#=> ** (CaseClauseError) no case clause matching: 10
#=> (stdlib) erl_eval.erl:965: :erl_eval.case_clauses/6
The equivalent of an else
or default
in other languages is a completely open pattern. The underscore character _
works well for this.
number = 10
case number do
4 -> "Four"
5 -> "Five"
_ -> "Something else..."
end
#=> "Something else..."
Support for Guard Clauses
Guard clauses are supported in case
statement patterns.
grade_lookup = {:ok, 86}
case grade_lookup do
{:ok, grade} when grade >= 90 ->
"A"
{:ok, grade} when grade >= 80 ->
"B"
{:ok, grade} when grade >= 70 ->
"C"
{:ok, grade} when grade >= 60 ->
"D"
_ ->
"F"
end
Comparison Operators
Guard clauses are great for comparing values in a pattern. If you want to use Comparison Operators like >
, >=
, <
, and <=
as part of a pattern, then guard clauses are what you want. This is a good time to explain how different types are compared to each other when using these operators. You will see how this is important in a moment.
It should be obvious that you can compare values of the same type and get an expected result.
1 < 5
#=> true
1 > 5
#=> false
What may not be obvious is how Comparison Operators work when comparing different types. Here is the type comparison order used in Elixir.
number < atom < reference < function < port < pid < tuple < map < list < bitstring
Exactly how things compare isn’t important to remember. The point is that different types are comparable. You can read more about the specifics on operator ordering in the documentation on Elixir’s operators.
The reason for this is purely a pragmatic one. This means any sorting algorithms you write don’t need to worry about how to compare different types. This sorting example shows this in action:
Enum.sort([100, :nugget, 2.0, nil, :an_atom, 99, -10, nil, "ABC"])
#=> [-10, 2.0, 99, 100, :an_atom, nil, nil, :nugget, "ABC"]
The most relevant point for our guard clause discussion here is how nil
is handled. Remember, nil
is an atom! This means…
100 < nil
#=> true
nil > 100
#=> true
100 > nil
#=> false
:any_atom > 100
#=> true
This means that a pattern match with a guard clause comparing a nil
to a numeric value might surprise you. Example:
result = {:ok, nil}
case result do
{:ok, value} when value > 100 ->
"Matched > 100"
_ ->
"Did not match > 100"
end
#=> "Matched > 100"
Because nil
is an atom, it is sorted higher than a number. So nil > 100
is true
.
Pattern matching can help solve this for us. Testing explicitly for the nil
and handle that.
result = {:ok, nil}
case result do
{:ok, nil} ->
"Was nil"
{:ok, value} when value > 100 ->
"Matched > 100"
_ ->
"Did not match > 100"
end
#=> "Was nil"
My preferred solution is that more functions return {:ok, result}
and {:error, reason}
tuples. Then never return a nil
as a successful result unless that truly is considered a valid and expected response. This pattern of coding helps avoid the problem of an unexpected nil
comparison at all.
To be clear, this comparison behavior isn’t unique to guard clauses. This applies everywhere in Elixir.
Errors in Guards Prevent Match
An expression that would cause an error in normal code can still be used in a guard clause. The hd/1
function returns the head of a list. It throws an error when given an argument that isn’t a list.
hd([1])
#=> 1
hd(:not_a_list)
#=> ** (ArgumentError) argument error
#=> :erlang.hd(:not_a_list)
case :not_a_list do
value when hd(value) == 1 ->
"Won't match"
_value ->
"Matches here"
end
#=> "Matches here"
So you can safely use functions in a guard clause that would otherwise cause errors. If it isn’t valid for the data being evaluated, it results in that clause not matching.
Pattern Matching from the Top
The case
statement works by matching the data against the patterns starting from the top. It stops on the first pattern match.
number = 100
case number do
value when is_integer(value) ->
"It's a number!"
100 ->
"It's the number 100!"
_ ->
"Not sure what that is..."
end
#=> "It's a number!"
In the above example, matching against the pattern 100
is a much more specific and exact match, but it stops at the first match it makes. So take care to think about the order your patterns are listed! Put the most specific patterns first.
Match a Bound Variable
The Pin Operator ^
helps to pattern match against an existing bound variable. This can be used in a case
statement.
ten = 10
value = 10
case value do
4 -> "Four"
5 -> "Five"
^ten -> "Ten"
_ -> "???"
end
#=> "Ten"
Notice how the variable must be bound prior to being used in a pattern match? For this reason, the Pin Operator does not work when pattern matching in function declarations. This is an advantage for the case
statement.
Assigning a Value From
Variables don’t leak. You cannot bind and override a value inside a case and have it leak outside the case
. Here’s an example of something common in other languages that does not work in Elixir the way you might expect.
boolean_value = true
value = 1
case boolean_value do
true ->
IO.puts "Made it here"
value = 2
_ ->
value
end
#=> warning: variable "value" is unused (if the variable is not meant to be used, prefix it with an underscore)
#=> iex:6
#=>
#=> Made it here
value
#=> 1
In other languages you would expect value
to have been set to the value 2
. This doesn’t happen because inside the scope of the case
, it is creating a new local variable called value
. It’s the new variable that the warning is referring to.
If you want to bind the result of a case
statement to a variable, do it like this instead.
boolean_value = true
result =
case boolean_value do
true ->
"My desired result"
_ ->
"Dunno..."
end
result
#=> "My desired result"
Pipe Friendly
You already know how cool the Pipe Operator is. Did you know that you pipe into a case statement? Because we’re using a pipeline, let’s try an example using a module. It may look weird at first. Play with it a little.
defmodule Testing do
def classify_text(value) do
value
|> String.downcase()
|> case do
"hello" <> _rest ->
:greeting
_ ->
:unknown
end
end
end
Testing.classify_text("HELLO!")
#=> :greeting
Testing.classify_text("Hello...")
#=> :greeting
Testing.classify_text("I like tacos")
#=> :unknown
This comes in handy at the end of a pipeline where you want to convert the result and return it. The alternative is to break it into two statements.
defmodule Testing do
def classify_text(value) do
to_test = value |> String.downcase()
case to_test do
"hello" <> _rest ->
:greeting
_ ->
:unknown
end
end
end
The variable to_test
exists only to shuttle the data between the pipeline and the case
. You have the option to pipeline directly into the case as well.
Practice Exercises
The following practice exercises all use the downloaded project. Make sure you have that available. There is real value in a hands-on experience! It’s when you do it yourself that you really learn it and put it together.
I recommend you still check out the solutions after you have the tests passing. It can be helpful to see another way to express the solution.
Exercise #1 – Guard Clauses
In this exercise use guard clauses with the case
statement to classify a user struct passed to the function. The job of your function is classify the user as an :adult
or a :minor
. If it can’t be classified you return an error and the reason (ie. {:error, reason}
).
To do this, write guard clauses that examine the user’s age. If age >= 18
, return {:ok, :adult}
. If the age < 18
, return {:ok, :minor}
.
Remember the information on Comparison Operators and nil
? Pay attention to the tests to see how failure cases are expected to be handled.
NOTE: You could solve this using function clause pattern matching, however for now, focus on solving it with the case
statement.
# the whole describe block
mix test test/case_test.exs:18
# individual tests
mix test test/case_test.exs:20
mix test test/case_test.exs:25
mix test test/case_test.exs:33
mix test test/case_test.exs:35
Exercise #2 – Converting an Error
The downloaded project code contains a text file under test/support/secret_numbers.txt
. In this exercise, we use the File.read/1
function to open and read the contents of the file.
The “problem” with the File.read/1
function is that when a file does not exist, it returns {:error, :enoent}
. The value :enoent
means “No such file or directory” and comes from Posix systems. While it is an accurate error, it isn’t what we want to use in our system. Using a case
statement, open and read a file. When it succeeds, return {:ok, file_contents}
. When it fails, convert the :enoent
into a friendly message and return {:error, "File not found"}
.
The tests for this exercise are:
mix test test/case_test.exs:45
mix test test/case_test.exs:50
Exercise #3 – Converting a DB Result
Ecto is the default library for working with a relational SQL database in Elixir.
For simplicity, the practice project doesn’t expect or require a database to be present. The project provides a fake interface that, for our purposes, acts like a database query result.
A common pattern is to run a query that returns the 1 “thing” you want. An example is the need to return a specific Customer or User. The real function used is Ecto.Repo.one/2
. It returns “the thing” or nil
. In this exercise we’re going to get a User
but we’ll use the fake interface CodeFlow.Fake.Users.one/1
. It receives a user_id
and returns the %User{}
with that ID or nil
if not found.
Create a find_user
function that receives a user_id
, uses CodeFlow.Fake.Users.one/1
in a case
statement to transform the “user or nil” result into an {:ok, user}
or {:error, "User not found"}
result. We want the result as a tuple because it works better for pattern matches that we’ll see in future examples.
The tests for this exercise are:
mix test test/case_test.exs:55
mix test test/case_test.exs:65
When is this used?
A good question to ask is, “when do I use this?” The case
statement is most commonly used inside a function body when you first have to execute a function in order to get the data you need. A very common example is when loading something from a database.
def add_billing_event(customer_id, billing_code) do
case Customers.find(customer_id) do
{:ok, customer} ->
# add billing event information here using "customer"
Billing.add_event(customer, billing_code)
{:error, _reason} = error ->
# Couldn't find a customer with that ID
# Log it, return error, etc
error
end
end
Recap
The case
statement helps us do the following things:
- Compare data against a set of patterns.
- Use the full power of pattern matching including guard clauses.
- Convert general function results to something tailored to our application and needs.
A few extra things we learned along the way:
- A
case
must provide a pattern that matches. - Comparison Operators may treat
nil
differently than you’d expect. - A pipeline can pipe into a
case
.
7 Comments
Comments are closed on this static version of the site.
Comments are closed
This is a static version of the site. Comments are not available.