Mastering Pattern-Matching in Elixir

Mastering Pattern-Matching in Elixir

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 our p_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.g 2, 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 ifs.

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 the with construct (lines 7 to 12). If the validation succeeds ({:ok, *}),* we proceed to attempt user login using Accounts.login/1. If Accounts.login/1 also returns {:ok, }, we call the authenticate_user/2 function.

  • In case of validation failure from any of validate_login_params/1 or Accounts.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.