fbpx logo-new mail facebook Dribble Social Icon Linkedin Social Icon Twitter Social Icon Github Social Icon Instagram Social Icon Arrow_element diagonal-decor rectangle-decor search arrow circle-flat
Development

My Experience Learning Pattern Matching in Elixir

Cain Watson Tandem Alum

last updated April 6, 2021

Over the last year I’ve been learning about the Elixir language and a feature it has called Pattern Matching. Coming from more imperative languages like Java and JavaScript, seeing pattern matching was both unsettling yet fascinating for me. In this post, I am going to share my experiences, findings, and reactions while discovering pattern matching. We’ll start with the basics and then I’ll demonstrate using it in conjunction with other nifty language constructs Elixir has.

What Is = Doing?

Like many languages, in Elixir we can define variables using the = operator. For instance, we can create a variable named x to the value of 77:

1 x = 77
2 x # 77

However, something nefarious is afoot. The equals operator has another name: the match operator. We’re going to expose this operator’s true intentions. Let’s look at another snippet:

1 x = 77 # 77
2 # 😎 heh, that's normal.
3 77 = x # 77
4 # 😮 what is this!?

Now, we might read the second line of code as assigning the number 77 to x. This makes no sense though: 77 is a constant and we’re stuck with the shape and form it occupies in our minds and hearts.

Well, the truth is that the match operator isn’t reading it that way. Instead, it’s asking a question: Is 77 equal to x? or Does x match the pattern: 77? This is the crux of what pattern matching is: Does some value follow a certain pattern? Because we defined x beforehand as 77 the answer is yes.

But what if x wasn’t 77?

1 x = 42 # 42
2 77 = x
3 ** (MatchError) no match of right hand side value: 42

The question “Is 77 equal to 42?” was asked, and in turn, a MatchError was raised. Yeesh, kinda melodramatic. The world as we know it is still in check though. We didn’t try to change the number 77, we just tried to ask a question and got an abundantly clear answer.

Pattern matching also works for complex data structures:

1 flowers = ["chrysanthemum", "lily", "rose"]
2 [flower1, flower2, "rose"] = flowers
3 flower1 # "chrysanthemum"
4 flower2 # "lily"

Does the pattern on the left match the value on the right? Is flowers set to a list with three elements with the third one set to the string “rose”? Yep! 🌹

Named labels in a pattern match such as flower1, flower2, and x in the first example act as catch-all patterns, meaning the value could be anything (a string, a number, or even another list!). Conveniently, if the pattern matches, Elixir will assign the actual value to those labels like a regular variable.

Things get even more interesting when creating more complex patterns. For instance, we can could use the | (cons) operator to create a pattern that deconstructs the first element and the rest of the elements:

1 flowers = ["chrysanthemum", "lily", "rose"]
2 [flower1 | other_flowers] = flowers
3 flower1 # "chrysanthemum"
4 other_flowers # ["lily", "rose"]

I hear you ask “But what’s the point? Is there some sort of example you’re going to show that displays how useful this is?” Of course! Let’s dig into using using pattern matching with control flow!

Case Macro

We can use pattern matching with case/2 to run code based on a pattern match.

1 temperature = "cold"
2 message = case temperature do
3  "hot" -> "🥵 Too hot!"
4  "cold" -> "🥶 Too cold!"
5  "warm" -> "😌 Just right."
6 end
7 message # "🥶 Too cold!"

But what happens when we provide a value that matches none of the patterns?

1 temperature = "like, really hot"
2 message = case temperature do
3  "hot" -> "🥵 Too hot!"
4  "cold" -> "🥶 Too cold!"
5  "warm" -> "😌 Just right."
6 end
7 ** (CaseClauseError) no case clause matching: "like, really hot"

💥 Another error! We could handle this by adding an extra catch-all pattern that will match anything in case something unexpected is passed in. For lack of a better label, let’s call it catch_all.

1 temperature = "like, really hot"
2 message = case temperature do
3  "hot" -> "🥵 Too hot!"
4  "cold" -> "🥶 Too cold!"
5  "warm" -> "😌 Just right."
6  catch_all -> "Didn't see that one coming."
7 end
8 message # "Didn't see that one coming."

Another neat feature is any variable prefixed with an underscore will be treated as an ignored variable. So we could rename our case to use _catch_all. This is useful for when we want to have a pattern that matches everything, but don’t actually need use the value that was matched.

Often times though you’ll see the label _ used. This is typically done when we don’t feel additional naming is necessary. It also throws an error if referenced, preventing you from accidentally using the value you marked to be unused.

1 temperature = "like, really hot"
2 message = case temperature do
3  "hot" -> "🥵 Too hot!"
4  "cold" -> "🥶 Too cold!"
5  "warm" -> "😌 Just right."
6  _ -> "Didn't see that one coming."
7 end
8 message # "Didn't see that one coming."

Using pattern matching with case gives us the ability to define different patterns for an input and execute certain code based on whether that input matches a certain pattern.

I also want to mention that we don’t always need to write a catch-all when using case. Instead, when given invalid or unexpected inputs we could let the case throw an error that will be handled by a function higher up in the stack or by another process, hence the commonly used phrase in the Elixir and Erlang community: “Let it Crash”.

Return Tuples

A common pattern for functions that can error is returning a tuple (fixed-sized array) with the atom (symbol/string constant) :ok or :error in the first spot and the data or error in the second spot.

1 {:ok, file} = File.read("https://149865198.v2.pressablecdn.com/home/cain/bananas.txt")
2 file # "This file contains bananas: 🍌🍌🍌."
3
4 {:error, reason} = File.read("/home/cain/non_existent_file")
5 reason # :enoent (file does not exist error code)

This allows us to pattern match the return value and perform certain actions if an error occurred or not.

1 path = "https://149865198.v2.pressablecdn.com/home/cain/bananas.txt"
2 case File.read(path) do
3  {:ok, file} ->
4   do_stuff_with_file(file)
5
6  {:error, reason} ->
7   handle_error(reason)
8 end

Another pattern is to define a second version of the function that ends with an exclamation mark and does throw an error when it fails.

1 file = File.read!("https://149865198.v2.pressablecdn.com/home/cain/bananas.txt")
2 file # "This file contains bananas: 🍌🍌🍌."
3
4 file = File.read!("/home/cain/non_existent_file")
5 ** (File.Error) could not read file "/home/cain/non_existent_file": no such file or directory

To determine when to use one or the other, the Elixir docs have a great explanation:

“The version without ! is preferred when you want to handle different outcomes using pattern matching…However, if you expect the outcome to always to be successful (for instance, if you expect the file always to exist), the bang variation can be more convenient and will raise a more helpful error message (than a failed pattern match) on failure.”

Functions

I lastly wanted to show how pattern matching can also be used in function parameters. Similar to method overloading in other languages, we can define a function multiple times with different parameter patterns, and depending on arguments passed to it, the function will execute different code.

1 @doc "Returns `true` if provided value is zero and `false` if not."
2 def zero?(0), do: true
3 def zero?(_num), do: false
4
5 zero?(0) # true
6 zero?(123) # false

And another, this time featuring recursion:

1 @doc "Returns `true` if at least one element contains zero and `false` if not."
2 def list_has_zero?([]), do: false
3 def list_has_zero?([0 | _tail]), do: true
4 def list_has_zero?([_not_zero | tail]), do: list_has_zero?(tail)
5
6 list_has_zero?([]]) # false
7 list_has_zero?([0]) # true
8 list_has_zero?([1, 0]) # true

Recursion is a very powerful tool and in my experience, it becomes significantly easier to write and understand when each case is defined upfront. Pattern matching here allows us to define each of those cases and run specific logic given those cases.

Wrap Up

Discovering pattern matching was very foreign to me at first, but after digging deeper I now understand how powerful and useful it can be and I even find myself wishing it were in other languages I’m using.

Let’s do something great together

We do our best work in close collaboration with our clients. Let’s find some time for you to chat with a member of our team.

Say Hi