Tools For Pattern-Based Design in Elixir

2245 VIEWS

·

Have you ever had to refactor some code – or implement a feature – and due to all the edge cases, you end up dealing with a bunch of nested IF/CASE statements? This can create a lot more mental overhead than necessary, in terms of making sense of the logic. Also, it can make testing a bit more intensive, since you have to create scenarios that hit all of these logic branches. And when you refactor these nested conditionals, you can sometimes break stuff without realizing it, and that is never fun.

Today we’re going to use Elixir to create a simple validation based on a user’s age. We will be looking at multi-clause functions with pattern matching and guards in Elixir to accomplish this.

One of the most important uses of pattern matching in Elixir is matching the arguments of a function. By pattern matching the arguments of a function, Elixir allows us to overload function calls and use multiple definitions of the same function, based upon the arguments it was called with.

Let’s Install Elixir. Trust Me You’re Gonna Love It!

Just type “iex” into your terminal and an interactive Elixir shell should spin up. Think of this as irb in Ruby. This is where you can play around with the Elixir language a bit. After you hit return you should see something like this.

 > iex
Erlang/OTP 22 [erts-10.4.4] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe] [dtrace]
 
Interactive Elixir (1.9.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

Now let’s make a variable. Let’s try and assign the integer “1” to the variable “x”.

iex(1)> x = 1
#=> 1

That seemed to work, but you’d probably be surprised to learn that the equal sign (=) isn’t used for assignment in Elixir. It’s used for matching. To be more specific, it’s used for pattern matching. If we were to swap those values around, it would still return “1”.


iex(2)> 1 = x
#=> 1

Normally, this would throw an error, but since we’re not assigning the value 1 to x, it’s OK. Forget all about assignment. In Elixir, we match using the equal sign (=).

This is pattern matching in its most basic form. There are plenty of articles out there on the basics of pattern matching in Elixir and I highly recommend checking some of them out.

Some of you might be asking yourself, “OK, but how or why do I use this?” And that’s a fair question. One way it’s pretty commonly used is with data deconstruction. So for example, let’s go back to iex and make a user.

iex(1)> user_1 = %{username: "JoeSchmoe90", dob: %{month: 02, day: 17, year: 1980}}
#=> %{dob: %{day: 17, month: 2, year: 1987}, username: "JoeSchmoe90"}

Say we need to pull out the year from this map. We could do this by digging into our map.

iex(2)> user_1[:dob]
#=> %{day: 17, month: 3, year: 1980}

We need to go another level deeper; and while we’re at it, lets match this value to a variable.

iex(2)> user_1[:dob][:year]
#=> 1980

That’s one way to do it, but let’s do this another way – a more Elixir-like way.

iex(3)> %{dob: %{year: year_born}} = user_1
#=> %{dob: %{day: 17, month: 3, year: 1980}, username: "JoeSchmoe90"}
iex(4)> year_born
#=> 1980

Cool! We have this value. But it’s pretty useless without a use case. So, say you had to do some validations on a user, as we often do.

# say we had some users with some nested information
 
user_1 = %{username: "JoeSchmoe90", dob: %{month: 03, day: 17, year: 1980}}
user_2 = %{username: "JoeSchmoe817", dob: nil}
user_3 = %{username: "JoeSchmoe199", dob: %{month: 06, day: 17, year: "1990"}}
 
# Next we need to create a validation that only authorizes users 
# over the age of 18.
 
# Simple enough we just do some math with the year born.
def authorized_user?(user) do
 year_born = user[:dob][:year]
 
 if (current_year() - year_born) >= 18 do
   true
 else
   false
 end
end

This works, and it’s pretty easy to follow; but what if we have a user that never provided a date of birth?

user_2 = %{username: "JoeSchmoe90", dob: nil}

This would break the arithmetic inside of our if statement since we wouldn’t be able to subtract “nil” from the current year. You might think: I’ll just throw another ‘if’ statement in there.

def authorized_user?(user) do
 year_born = user[:dob][:year]
   if year_born == nil do
     false
   else
     if (current_year() - year_born) >= 18 do
       true
     else
      False
     end
   end
end

This also works, but it’s getting a little noisy. It’s OK for now… but say that for whatever reason our user has a date of birth, but somehow the year was converted to a string.

user_3 = %{username: "JoeSchmoe199", dob: %{month: 06, day: 17, year: "1990"}}

This would also break our code since we can’t subtract the string “1990” from the current year. So you need to go into your code and add another ‘if’ statement to deal with this; or maybe more than one. Who knows? … You can see how this can get ridiculous pretty quickly.

def authorized_user?(user) do
 year_born = user[:dob][:year]
 if is_integer(year_born) && year_born != nil do
     if (current_year() - year_born) >= 18 do
       true
     else
       false
     end
   else
 
     year_born = year_born
     |> String.to_integer()
 
     if is_integer(year_born) do
       (current_year() - year_born) >= 18
     else
       {:error, "invalid dob"}
     end
 end
end

How do we use some of the tools built into Elixir to solve this problem in a more readable way? I wrote this and I’m having a hard time keeping track of everything, and I don’t like that. Instead, what I’ll do is refactor this using some pattern matching, and multi-clause functions.

 def authorized_user?(%{dob: nil} = _user), do: false
 def authorized_user?(%{dob: %{year: nil}} = _user), do: false
 
 def authorized_user?(%{dob: %{year: year_born}} = user) when is_integer(year_born) do
   (current_year() - year_born) >= 18
 end
 
 def authorized_user?(%{dob: %{year: year_born}} = user) when is_binary(year_born) do
   {:error, "invalid dob"}
 end

Believe it or not, the code above solves our problem! Let’s break it down.

# This is what you can do with MultiClause Functions
# You can have multiple functions with the same name that behave differently
# based on the parameters provided.
 
 # This Function Is Triggered If dob is nil, and returns false
 def authorized_user?(%{dob: nil} = _user), do: false
 
 # This Function Is Triggered When The Year Inside our dob map is nil, and returns false.
 def authorized_user?(%{dob: %{year: nil}} = _user), do: false
 
 
 # This Function Is Triggered When A Year Is Present And It Happens To Be An Integer
 #  We Use Pattern Matching To Be Able To Pull Out The Value We Need Inside Our Function Header
 def authorized_user?(%{dob: %{year: year_born}} = user) when is_integer(year_born) do
   (current_year() - year_born) >= 18
 end
 
 
 # This Function Is Triggered When Year Is A String And Raises An Error
 def authorized_user?(%{dob: %{year: year_born}} = user) when is_binary(year_born) do
   {:error, "invalid dob"}
 end
 

Now we can take a user, and when we pass it into authorized_user() – and depending on the user attributes – certain functions will run and others won’t. This is a pretty common pattern in Elixir and Phoenix. We cut our lines of code in half. I think it’s easier to read and reason about. Especially when you understand the basics of pattern matching and multi-clause functions.


Melvin is a backend engineer that attended the Turing School Of Software And Design in Denver Colorado, and is currently working with Elixir And Phoenix. Be sure to checkout his talk "From Bootcamp To Elixir Contracts" from Lonestar Elixir 2020 to learn more about his unusual background.


Discussion

Leave a Comment

Your email address will not be published. Required fields are marked *

Menu
Skip to toolbar