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.

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.

Comments are closed

This is a static version of the site. Comments are not available.

7 Comments

  1. Ali ELBaitam on November 23, 2020 at 6:38 am

    I got stumped when I attempted the first exercise comparing against the value of “age”. I thought the case expression should be the “age” value which I get from the function parameter: def classify_user(%User{age: age})… I didn’t know how it will work:

    case age do
    age when age >=18 -> {…}
    age when age {…}
    _ ->
    end

    I found it weird and different from other languages. I looked back at the examples in the lesson and they all looked unusual. The case expression usually is not a constant in other languages but in the lesson examples it is: case nil do, case true do, case 10 do!

    Eventually, I had to look at the solution and realized that the case expression is the entire User structure not the age value only.

    I understand the solution but I just wanted to share what someone new to Elixir struggles with.

    • Mark Ericksen on November 23, 2020 at 10:57 am

      Thanks for sharing your experience. I’ll give it some more thought about how it could be improved. Feel free to email me directly with any suggestions! 🙂

      • Ali ELBaitam on November 24, 2020 at 3:23 am

        I think I was overthinking what the exercises are asking 🙂 I have gone through many exercism.io and codewars exercises and so far pattern matching using function is what I used and never needed case..do. Case..do looks like a secondary construct in Elixir (pattern matching on functions which looks similar is used more often). One important thing I learned from this lesson though is the case..do as an expression which will be useful as I am progressing with Elixir.

        • Mark Ericksen on November 24, 2020 at 5:28 am

          Yes, the same pattern matching that applies in a function header applies in a case statement. However a case statement is often needed because you need to call a function to get the data you need to analyze.

  2. Robert Scott on September 27, 2022 at 9:55 am

    My solution was the same as Ali’s. It passes the tests and I don’t see anything wrong with it.

    This course and the Pattern Matching course are fantastic by the way and absolutely incredible value. I am really enjoying learning Elixir after a lifetime (I’m 77) of imperative programming and find these courses a great help. Many thanks for all the work you have put in to creating them.

  3. Sergey Makarichev on December 8, 2022 at 11:59 pm

    Hi, Mark!
    Please specify in this lesson – and the other lessons in this course – in which files I have to find those tests.
    Yes, according to the output I may find out that in this lesson I should look for is case_test.exs, but that may be not obvious.
    In Pattern matching course all files are specified in the lessons.

    • Mark Ericksen on December 9, 2022 at 6:18 am

      Hi Sergey,

      They practice exercises list the test file like this…

      # the whole describe block
      mix test test/case_test.exs:18
      
      # individual tests
      mix test test/case_test.exs:20
      

      Where test/case_test.exs is the file, or are you referring to something else?

Comments are closed on this static version of the site.