Mastering Pattern-Matching in Elixir
Olaleye Blessing
Posted on January 8, 2024
Elixir is a functional programming language that is great for building scalable applications. Pattern matching is a powerful feature in Elixir. At its core, pattern matching allows developers to destructure data and make decisions based on its structure. You can use pattern matching in many ways. It's not just for one thing; it's all over the language, making it a big and important part of Elixir programming.
Prerequisite
To understand this article, you should have a basic knowledge of Elixir syntax.
Basics of Pattern-Matching
For any Elixir developer, it's essential to understand the way pattern matching works. It's like the language's secret handshake for working with data powerfully and intuitively.
Let’s look at an example of how pattern-matching works:
iex(1)> user = {"Blessing", "bb@gmail.com", "Male"}
If you're used to languages like JavaScript or Python, you might think this is an assignment, but it's not. Here in Elixir, we're matching the variable user
with the data on the right side. In pattern matching, the left side is like a template, and the right side is the actual data. If the matching works, the variable gets bound to the Elixir data. If not, it throws an error.
Take for example,
iex(1)> 2 = 1
You’d get an error because the Elixir expression, 2
doesn’t match the pattern on the right, 1
(MatchError) no match of right hand side value: 1
Going forward, we will look at how to pattern match in:
- tuples
- lists
- maps
- structs
- functions
- case
- with
Tuples
From our first example in this article, we can pattern-match the tuple and create 3 variables that are bound to the elements of the tuple:
iex(1)> {name, email, gender} = {"Blessing", "bb@gmail.com", "Male"}
{"Blessing", "bb@gmail.com", "Male"}
When the right expression is evaluated, the variables name
, email
, and gender
are bound to the elements of the tuple.
iex(1)> name
"Blessing"
iex(2)> email
"bb@gmail.com"
iex(3)> gender
"Male"
This example works because we are matching a tuple that has exactly three elements. What happens if we increase/decrease the number of variables?
iex> {name, email, gender, unknown} = {"Blessing", "bb@gmail.com", "Male"}
** (MatchError) no match of right hand side value: {"Blessing", "bb@gmail.com", "Male"}
The match failed because the left-hand side is expecting a tuple of 4 elements but our Elixir expression only returns a tuple of 3 elements. An error will occur as long as the number of tuple elements on the left-hand side doesn’t match the right-hand side.
It is important to know that you can use underscore(_
) to ignore values you are not interested in.
iex(1)> {name, _, _} = {"Blessing", "bb@gmail.com", "Male"}
{"Blessing", "bb@gmail.com", "Male"}
iex(2)> name
"Blessing"
Lists
Pattern matching with lists also works the same way it works with tuples. Take an example:
iex(1)> [a, b, c, d] = [1, 2, 3, 4]
After the list expression is evaluated, the variables will be bound to the list elements.
We can go further by grabbing the first two values and saving the remaining as a list in another variable.
iex(1)> [a, b | others ] = [1, 2, 3, 4]
[1, 2, 3, 4]
iex(2)> a
1
iex(3)> b
2
iex(4)> others
[3, 4]
Just as tuple, an error will be thrown when we try to pattern match lists with different lengths.
iex(1)> [a, b, c, d, e] = [1, 2, 3, 4]
** (MatchError) no match of right hand side value: [1, 2, 3, 4]
An error is also thrown when we try to pattern match an empty list using the cons operator(|
):
iex(1)> [a | others] = []
** (MatchError) no match of right hand side value: []
This is because we don’t have enough elements on the right-hand side.
Maps
Pattern matching with maps is also quite straightforward:
iex(1)> %{name: name, email: email, gender: gender} = %{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
%{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
Just like in the previous sections, the variables name
, email
, and gender
will be bound to the map keys. Unlike tuples and lists, the keys on the left side can take any order. The below also produces the same value as the above:
iex(1)> %{email: email, gender: gender, name: name} = %{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
%{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
We can also decide to match the value of a key without specifying other keys.
iex(1)> %{email: email} = %{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
%{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
iex(2)> email
"bb@gmail.com"
An error occurs if the expression doesn’t contain a key you are trying to pattern match:
iex(1)> %{name: name} = %{email: "bb@gmail.com", gender: "Male"}
** (MatchError) no match of right hand side value: %{email: "bb@gmail.com", gender: "Male"}
Structs
Think of a struct as a specialized map with explicitly defined fields. Structs offer a way to organize and manage data with a predetermined structure. They are similar to maps, they are built on top of maps. Consider a Person
struct that looks like this:
iex(1)> defmodule Person do
...(1)> defstruct [:name, :email, :gender]
...(1)> end
When you pattern match a struct, it provides a basic security check. This ensures that you're only matching against the struct you've defined beforehand. Because a struct is an extension of a map, we can pattern-match a struct using a map pattern.
iex(1)> p_1 = %Person{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
%Person{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
iex(2)> %{name: name} = p_1
%Person{name: "Blessing", email: "bb@gmail.com", gender: "Male"}
iex(3)> name
"Blessing"
As you can see, the above didn’t throw an error even tho our pattern was a pure map. Depending on the use case of our application, the above might lead to some bugs. It’ll be better if our pattern is the struct we are expecting:
iex(1)> %Person{name: name} = p_1
....
iex(2)> name
"Blessing"
With this, we are sure that our name variable is coming from a Person
struct.
Just like map, an error will also be thrown if we try to pattern-match a key that doesn’t exist.
Functions
Pattern matching in functions helps us to execute a function based on their argument. Let’s look at a simple example:
iex(1)> test = fn
...(1)> "Blessing" -> "My developer"
...(1)> "Bikky" -> "Nurse"
...(1)> _ -> "Unknown"
...(1)> end
In the above example, different results will be returned based on the argument passed to the function. The last pattern, underscore(_
) will match any other value apart from "Blessing"
and "Bikky"
.
iex(1)> test.("Blessing")
"My developer"
iex(2)> test.("Bikky")
"Nurse"
iex(3)> test.("Olaleye")
"Unknown"
Pattern matching in functions also comes in handy when dealing with structs:
iex(1)> test = fn
...(1)> %Person{} -> "known"
...(1)> %{} -> "maybe"
...(1)> _ -> "unknown"
...(1)> end
The above is basically saying if the argument:
- is created from our
Person
struct, the return“known”
. e.g ourp_1
variable from the structs section - is created from a plain
map
, then return“maybe”
. e.g%{alive: true}
- is any other thing, then return
"unknown
. e.g2
,true
or:thief
Case macro
Pattern matching in case
macro looks like this:
iex(1)> case expression do
...(1)> pattern_1 -> do_something()
...(1)> pattern_2 -> do_something_else()
...(1)> pattern_3 -> other_thing()
...(1)> _ -> always_match()
...(1)> end
The expression
gets evaluated to a valid Elixir value. The result is then pattern-matched against each of our patterns. The function of the first pattern that matches gets evaluated.
The last pattern(underscore) is called a catch-all. If none of the first 3 patterns matches, then the last pattern gets evaluated. Without it, an error is raised if none of the first 3 patterns matches.
With/1
The with/1 special form lets you chain multiple expressions together. Pattern-matching in with/1
works almost the same way as others. The expressions provided to with/1
continue to execute as far as the patterns are matched:
iex(1)> with {:ok, name} <- expression_1(),
...(1)> {:ok, email} <- expression_2(),
...(1)> {:ok, age} <- expression_3() do
...(1)> IO.inspect({name, email, age})
...(1)> end
Unlike others, with/1
won’t raise an error if a pattern is not matched. It will rather stop executing. For example, if expression_2()
returns {:error, "no email"}
, with/1
will stop execution and expression_3()
will never run.
It’s common to provide an else
block to handle error situations. Also, you can pattern-match the errors:
iex(1)> with {:ok, name} <- expression_1(),
...(1)> {:ok, email} <- expression_2(),
...(1)> {:ok, age} <- expression_3() do
...(1)> IO.inspect({name, email, age})
...(1)> else
...(1)> {:error, "no_email"} -> IO.inspect("No email")
...(1)> _ -> IO.inspect("Unknown issue")
...(1)> end
Benefits of Pattern-matching
There are so many benefits to pattern-matching but we will be looking at a few:
Code Readability
Pattern matching enhances code readability by matching patterns within data or control flow. Take for example, our case/2
section:
iex(1)> case expression do
...(1)> pattern_1 -> do_something()
...(1)> pattern_2 -> do_something_else()
...(1)> pattern_3 -> other_thing()
...(1)> _ -> always_match()
...(1)> end
It’s clear what is happening when we have a particular result. It would be quite verbose if we had used nested if
s.
Destructuring
Pattern matching is the cleanest way to extract values from data structure and assign them to variables. It is even more useful when extracting values from nested data structures.
iex(1)> [%{name: name} | others] = [%{name: "Blessing", age: 12, alive: true}, %{name: "Olaleye", age: 98, alive: false}]
[%{name: "Blessing", age: 12, alive: true}, %{name: "Olaleye", age: 98, alive: false}]
iex(2)> name
"Blessing"
In the above, we extracted the name of the first person in the list.
Error Handling
Pattern matching can be used effectively for error handling. We can define specific patterns for different error scenarios and handle them differently. An example is what we did in our with/1
section:
iex(1)> with ...,
...(1)> ...,
...(1)> ... do
...(1)> ...
...(1)> else
...(1)> {:error, "no_email"} -> IO.inspect("No email")
...(1)> _ -> IO.inspect("Unknown issue")
...(1)> end
Practical Examples
Recursion
Pattern matching proves invaluable when implementing recursive functions. Let's explore a factorial
function example in Elixir.
defmodule MathOperations do
def factorial(0), do: 1
def factorial(n), do: n * factorial(n - 1)
end
Pattern matching shines when we set the base case to stop the recursion, like when the value hits 0. In the following example, factorial/1
uses pattern matching with two clauses. The initial clause matches when the argument is 0. This serves as the stopping point for the recursion. The second clause matches as far as the argument is not 0.
Authentication
Let’s look at a real example from one of my projects:
1...def login(conn) do
2... %{body_params: body_params} = conn
3... login_params = %{
4... email: Map.get(body_params, "email"),
5... password: Map.get(body_params, "password")
6... }
7... with {:ok, _} <- validate_login_params(login_params),
8... {:ok, user} <- Accounts.login(login_params) do
9... authenticate_user(conn, user)
10.. else
11.. {:error, msg} -> Router.json_resp(:error, conn, msg, 401)
12.. end
13..end
14..defp validate_login_params(%{email: nil, password: nil}), do: {:error, "Please provide email and password"}
15..defp validate_login_params(%{email: nil, password: _}), do: {:error, "Please provide email"}
16..defp validate_login_params(%{email: "", password: _}), do: {:error, "Please provide email"}
17..defp validate_login_params(%{email: _, password: nil}), do: {:error, "Please provide password"}
18..defp validate_login_params(%{email: _, password: ""}), do: {:error, "Please provide password"}
19..defp validate_login_params(_params), do: {:ok, true}
In the above code,
- We first collect the credentials of a user (lines 2 to 6).
- Next, we apply
validate_login_params/1
to match various credential scenarios. This function uses pattern matching to handle cases like missing or empty email/password. It returns a tuple indicating success or an error message if validation fails. - We then pattern-match the result of
validate_login_params/1
in thewith
construct (lines 7 to 12). If the validation succeeds ({:ok, *}
),* we proceed to attempt user login usingAccounts.login/1
. IfAccounts.login/1
also returns{:ok, }
, we call theauthenticate_user/2
function. - In case of validation failure from any of
validate_login_params/1
orAccounts.login/1
, we pattern match the error in the else clause.
This authentication example demonstrates how pattern matching is used to handle different scenarios in user login validation, making the code clear and concise.
Conclusion
Pattern matching is a powerful tool for writing clean, concise, and efficient code. By mastering pattern matching, you'll unlock the full potential of Elixir and write effective programs with ease.
Posted on January 8, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.