Pattern Matching a Function Body: Maps
Maps are your go-to key-value collections. You are likely to use them a lot. When building a web application, the request data submitted by a client is represented as a map with string keys. Before we jump into practicing how we pattern match maps in functions, this is a good time to talk about a code pattern you will see in Elixir.
Contents
In Pieces and Whole
There is a common code pattern we use in Elixir that is worth exploring here. Let’s look at a code example and talk about it.
web_params = %{"name" => "John", "email" => "john@example.com"}
defmodule Testing do
def do_work(%{"email" => email} = params) do
IO.inspect email
IO.inspect params
"Sent an email to #{email} addressed to #{params["name"]}"
end
end
Testing.do_work(web_params)
#=> "john@example.com"
#=> %{"email" => "john@example.com", "name" => "John"}
#=> "Sent an email to john@example.com addressed to John"
In this code, we have a string-key map called params
similar to what you’d get when handling a web request. The do_work
function takes the params in and does a pattern match on some of the data. It looks like this: do_work(%{"email" => email} = params)
. What’s special here is that we are pattern matching for some data we care about and still binding everything passed in as params
.
This is a common code pattern. You can use this same approach everywhere you pattern match. It works in case
statements and even in a straight forward match expression. Check this out:
%{"email" => email} = params = %{"name" => "John", "email" => "john@example.com"}
email
#=> "john@example.com"
params
#=> %{"email" => "john@example.com", "name" => "John"}
Yes, this may look strange, but when you break it down you can see what’s happening. The match operator performs a Pattern Match of the left and right sides. When we say params
by itself, we haven’t defined a type or a data shape so it just gets bound.
This is effectively what we’re doing with the map match in the function. We still bind everything to the variable params
but we also define the pattern (data type and shape) which also pulls out the data we care most about.
This can be very helpful like in my hypothetical function below. I want to Pattern Match and access a portion of the data when it is passed in, but I still want the full customer
data because I pass it on to another function called notify_customer
.
defmodule Billing do
def apply_charge(%{id: customer_id} = customer, charge) do
record_charge(customer_id, charge)
notify_customer(customer, charge)
end
end
Still Naming for Clarity
You’ve seen how we can name an argument for developer clarity and documentation reasons using the underscore prefix like _customer
. We can do that when matching map arguments. When I don’t need the full data structure, it still adds value to name it for developer clarity and expressing the intent of the function.
defmodule Testing do
def do_work(%{"email" => email} = _customer_params) do
# use `email`
end
end
Having the argument named _customer_params
conveys meaning for what the developer was expecting to be passed in. That meaning may not be clear from just looking at a function declaration that says, “do_work(%{"email" => email}) do
“. A map with an email
string key could be for a user, customer, a login, or something else.
Be kind to your future self an any other developers that work on your code base. Name your variables even when the code doesn’t need it.
Binding to a Nested Map
Maps are the go-to data structure for key-value data. They also elegantly handle nested data. When pattern matching, it is important to understand that you can also easily bind to a nested map. This can be very handy when working with web requests, and actually gets used frequently.
An example will help demonstrate this. First, we need some nested data to pattern match against. Remember, in this example we are focusing on the function declaration and the pattern match it uses.
params = %{
"customer" => %{
"id" => 123,
"name" => "Willy Wonka Chocolates",
"bonuses" => %{
"employees" => %{
"Oompa 1" => 1_000,
"Oompa 2" => 2_000,
"Hillary" => 1_500,
"Oompa 3" => 500
},
"total" => 5_000
}
}
}
defmodule NestedBinding do
def award_bonuses(%{"customer" => %{"bonuses" => %{"total" => bonus_total} = bonuses}} = _params) do
IO.inspect bonus_total, label: "TOTAL TO VALIDATE"
IO.inspect bonuses, label: "BONUSES"
# TODO: validate intended total and employee amounts
:ok
end
end
NestedBinding.award_bonuses(params)
#=> TOTAL TO VALIDATE: 5000
#=> BONUSES: %{
#=> "employees" => %{
#=> "Hillary" => 1500,
#=> "Oompa 1" => 1000,
#=> "Oompa 2" => 2000,
#=> "Oompa 3" => 500
#=> },
#=> "total" => 5000
#=> }
#=> :ok
The pattern is where we are focusing right now:
%{"customer" => %{"bonuses" => %{"total" => bonus_total} = bonuses}} = _params
Notice that we can bind to a deeply nested map to get the bonus_total
and bind a variable to the full bonuses
map at the same time!
The ability to bind to a nested piece of data works on all data types. However, it is most helpful when working with maps, primarily because maps tend to have nested data more than other data types.
Practice Exercises
The following exercises continue using the Pattern Matching project. We will continue focusing on making a single test pass at a time.
The tests we are focusing on are in test/maps_test.exs
. Running the following command will execute all the tests in this file. Running all the tests now will show they all fail.
$ mix test test/maps_test.exs
[...]
Finished in 0.05 seconds
9 tests, 9 failures
Randomized with seed 958439
You should be more comfortable with the TDD (Test Driven Development) approach here. Let’s get started with an easy warm-up.
Exercise #1 – Maps.return_name/1
The test to focus on is in test/maps_test.exs
. Given a map with a :name
key, return the value from the function.
mix test test/maps_test.exs:20
Exercise #2 – Maps.has_sides?/1
There are two tests to focus on. The first is the “success” path and the second is handing non-matching data. This is focusing on validating the shape of the data with less focus on the values and binding variables.
Refer to the tests for examples of the input data.
mix test test/maps_test.exs:30
mix test test/maps_test.exs:40
Exercise #3 – Maps.net_change/1
The test we want to focus on is in test/maps_test.exs
. It is under “net_change/1” and the test is “subtracts beginning balance from ending balance”. If you haven’t modified the file causing the line numbers to change, then using the following mix command will work.
There are two tests for this function. This function takes a map, performs a calculation and returns the result in an {:ok, result}
tuple. The result is the difference of :ending_balance
and :initial_balance
.
mix test test/maps_test.exs:50
mix test test/maps_test.exs:60
Iterate on the code changes to make the tests pass. Use pattern matching in the function declaration.
Exercise #4 – Maps.classify_response/1
This exercise is one of my favorites! This is based on several real-world experiences of mine. At some point, you will have a project that integrates with some other external service. That external service may return XML or JSON but not in any standardized, clean way. You are left dealing with whatever they give you. Even APIs returning JSON can be messy and not follow good REST practices. This exercise is inspired by those experiences. You don’t get to control the data you have to react to. Your goal is to get the meaning and result you need from the given data.
When I was learning Elixir and encountered a situation like this, I created my first-pass solution. It was a traditional imperative step-by-step solution. Looking at my working solution, I wasn’t satisfied. I thought, “That doesn’t feel very Elixir-like yet”. I went back, looked at more Elixir code, tried again. My second attempt still didn’t feel quite “right” yet. After my third attempt, I ended up with a solution that “felt right”. It was elegant and I was elated!
I stress this point because you need to know that it’s okay to take multiple passes at a solution.
The test defines 4 sample responses from an external service. We want to be able to classify the response and identify when a request was successful and extract the desired value. There are 3 examples of how the request might fail. We want to be able to tell them apart as they will influence how the application responds.
The response data we need to look at is spread out throughout a nested map. Pattern matching lets you create an elegant solution.
There are 4 tests for this function. One is the happy-path solution and the other 3 are for handling the different error conditions we expect.
mix test test/maps_test.exs:86
mix test test/maps_test.exs:92
mix test test/maps_test.exs:96
mix test test/maps_test.exs:102
Matching Success
First, let’s detect when it succeeds. Our function should return that it succeeded with an {:ok, result}
tuple which include the extracted token. The test case include a comment that explains what about the data defines a success. The setup
for the test cases includes sample response data.
mix test test/maps_test.exs:88
Make the test pass by using pattern matching in the function declaration.
Matching When Invalid
Next, let’s add to our existing solution by moving on to the next test. Again, the test case includes a comment that defines when the response is “invalid”.
mix test test/maps_test.exs:92
Matching When Retry
Continue building on your work to add support for detecting when we should “retry” our request with the server. Again, the test case includes a comment that defines when the response tells us to “retry”.
mix test test/maps_test.exs:96
Matching When Account is Frozen
Conclude this exercise by building on your work to add support for detecting when a request failed because an account is “frozen”. Again, the test case includes a comment that defines when we can tell an account is “frozen”.
mix test test/maps_test.exs:102
Summary
All the tests for classify_response/1
should be passing!
$ mix test test/maps_test.exs
Compiling 1 file (.ex)
.........
Finished in 0.05 seconds
9 tests, 0 failures
Randomized with seed 955953
Take a look at the full solution (hidden below) and think how the same solution would look if done imperatively using nested if
statements! I love pattern matching! It makes the code declarative and clear. I feel like I’m a better programmer because of it.
When code is written this way, it is “declarative”. The code just “declares” the shape of the data it cares about. It basically says, “When the data matches this specific shape, it means we retry.”
When the same problem is solved without pattern matching (like when using nested if
statements), reading it becomes a exercise of mentally parsing code to figure out what it’s doing. Instead of applying a single pattern definition for a match statement, it is step-by-step poking the data to “feel” what the shape is. “Does it have this key? Yes? So then does it have this next key?”
As you become more comfortable writing code this way, you won’t want to go back to the old way. Code becomes elegant. Your solutions become a declarative expression of what the data means. It’s easier to understand, easier to refactor, and more fun to write!
28 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.