
- Pattern Matching: Elixir's Superpower
- What is Pattern Matching?
- Basic Pattern Matching
- Destructuring Data
- The Pin Operator
- Pattern Matching in Function Heads
- Working with Lists
- Why Linked Lists?
- List Construction
- List Operations
- Head and Tail
- Building Lists
- Maps and Keyword Lists
- Maps
- Accessing Map Values
- Updating Maps
- Keyword Lists
- Control Structures
- if/else
- cond
- case
- Functions and Functional Programming
- Function Clauses
- Guard Clauses
- Anonymous Functions
- Working with Collections
- Enum Module
- List Comprehensions
- Modules and Structs
- Modules
- Structs
- Error Handling
- Result Pattern
- with Expression
- What's Next?
- Practice Exercises
- Key Points
Basic Syntax and Data Types - Learn Elixir Part 2
Now that you've got Elixir running and written your first few lines of code, let's dig deeper into what makes this language so powerful. In this article, we'll explore pattern matching in detail, work with more complex data structures, and start thinking in a functional way.
Pattern Matching: Elixir's Superpower
Pattern matching is how you control program flow in Elixir. It's a fundamental part of Elixir which allows you to match values, data structures and functions.
What is Pattern Matching?
Think of pattern matching as a way to both assign values and make decisions at the same time. When you write {name, age} = {"Alice", 30}
, Elixir doesn't just assign values - it checks if the patterns match and extracts the values if they do.
Basic Pattern Matching
# Simple assignment
x = 1
# This works because 1 matches 1
1 = x
# This would fail - pattern matching error
# 2 = x
The key insight is that =
in Elixir does pattern matching, not just assignment. The left side is a pattern, and the right side is the value to match against.
Destructuring Data
Pattern matching really shines when you're working with structured data:
# Tuples
{name, age} = {"Alice", 30}
# name = "Alice", age = 30
# Lists
[first, second, third] = [1, 2, 3]
# first = 1, second = 2, third = 3
# Maps
%{name: user_name, age: user_age} = %{name: "Bob", age: 25}
# user_name = "Bob", user_age = 25
Notice how the pattern on the left side mirrors the structure of the data on the right side. This makes your code both readable and safe.
The Pin Operator
Sometimes you want to match against an existing value rather than assign to it. That's where the pin operator (^
) comes in:
x = 1
^x = 1 # This works - matches against existing x
^x = 2 # This fails - MatchError!
The pin operator tells Elixir "use the current value of this variable, don't assign to it."
Pattern Matching in Function Heads
This is where pattern matching really shines. You can write different versions of the same function that handle different cases:
defmodule UserHandler do
def process_user({:ok, user}) do
"Processing user: #{user.name}"
end
def process_user({:error, reason}) do
"Error: #{reason}"
end
def process_user(nil) do
"No user provided"
end
end
When you call process_user({:ok, %{name: "Alice"}})
, Elixir automatically calls the first version. When you call process_user({:error, "Not found"})
, it calls the second version. This is much cleaner than writing if/else statements.
Working with Lists
Lists are fundamental in Elixir. They're linked lists, which means they're built from a series of nodes where each node points to the next one.
Why Linked Lists?
Linked lists have some interesting properties:
- Adding to the front is very fast
- Accessing by index is slower (you have to walk through the list)
- They work perfectly with recursion
List Construction
# Empty list
[]
# Single element
[1]
# Multiple elements
[1, 2, 3, 4, 5]
# Mixed types
[1, "hello", :atom, 3.14, %{key: "value"}]
Lists can contain any type of data, and you can mix different types in the same list.
List Operations
# Concatenation
[1, 2, 3] ++ [4, 5, 6] # [1, 2, 3, 4, 5, 6]
# Subtraction (removes first occurrence)
[1, 2, 3, 4] -- [2, 4] # [1, 3]
# Membership
2 in [1, 2, 3] # true
5 in [1, 2, 3] # false
The ++
operator concatenates lists, but be careful - it's not very efficient for large lists because it has to copy the entire left list.
Head and Tail
Lists are built from a head (first element) and tail (rest of the list). This is crucial for recursion:
[head | tail] = [1, 2, 3, 4, 5]
# head = 1, tail = [2, 3, 4, 5]
# You can extract multiple elements
[first, second | rest] = [1, 2, 3, 4, 5]
# first = 1, second = 2, rest = [3, 4, 5]
The |
operator separates the head from the tail. This pattern is used extensively in recursive functions.
Building Lists
Because lists are linked lists, adding to the front is very efficient:
# Prepending (fast)
[0 | [1, 2, 3]] # [0, 1, 2, 3]
# Building a list backwards (common pattern)
defmodule ListBuilder do
def build_list(n) do
build_list(n, [])
end
defp build_list(0, acc), do: acc
defp build_list(n, acc) do
build_list(n - 1, [n | acc])
end
end
ListBuilder.build_list(5) # [1, 2, 3, 4, 5]
This pattern of building a list backwards and then reversing it is very common in functional programming.
Maps and Keyword Lists
Maps
Maps are your go-to data structure for key-value pairs. They're like dictionaries or hashes in other languages:
# Creating maps
user = %{
name: "Alice",
age: 30,
email: "alice@example.com"
}
# String keys
config = %{
"host" => "localhost",
"port" => 8080
}
# Mixed keys
mixed = %{
:name => "Bob",
"age" => 25,
1 => "one"
}
Maps are very flexible - you can use atoms, strings, or even numbers as keys.
Accessing Map Values
There are several ways to get values from maps:
user = %{name: "Alice", age: 30}
# Dot notation (for atom keys)
user.name # "Alice"
# Bracket notation
user[:name] # "Alice"
# Map.get with default
Map.get(user, :age) # 30
Map.get(user, :city, "N/A") # "N/A"
# Map.fetch (returns {:ok, value} or :error)
Map.fetch(user, :name) # {:ok, "Alice"}
Map.fetch(user, :city) # :error
The dot notation is convenient but will crash if the key doesn't exist. Map.get
with a default is safer.
Updating Maps
Since data is immutable, updating a map creates a new map:
user = %{name: "Alice", age: 30}
# Update existing key
updated_user = %{user | age: 31}
# Add new key
user_with_city = Map.put(user, :city, "New York")
# Remove key
user_without_age = Map.delete(user, :age)
# Merge maps
user1 = %{name: "Alice", age: 30}
user2 = %{age: 31, city: "New York"}
merged = Map.merge(user1, user2)
# %{name: "Alice", age: 31, city: "New York"}
The |
operator in %{user | age: 31}
updates existing keys. Map.put
and Map.delete
work with any keys.
Keyword Lists
Keyword lists are lists of tuples where the first element is an atom. They're commonly used for function options:
# Creating keyword lists
options = [name: "Alice", age: 30, city: "New York"]
# This is equivalent to
options = [{:name, "Alice"}, {:age, 30}, {:city, "New York"}]
# Common use case: function options
String.split("hello world", " ", trim: true)
Keyword lists are useful when you need to preserve order or have duplicate keys, but for most cases, maps are more efficient.
Control Structures
Elixir has a few control structures, but you'll often use pattern matching instead. This is a key difference from imperative languages.
if/else
age = 18
if age >= 18 do
"Adult"
else
"Minor"
end
# unless (opposite of if)
unless age < 18 do
"Can vote"
end
if
and unless
are expressions that return values, not statements.
cond
cond
is like a switch statement, but more flexible:
age = 25
cond do
age < 13 -> "Child"
age < 18 -> "Teenager"
age < 65 -> "Adult"
true -> "Senior" # catch-all
end
Each condition is evaluated in order, and the first one that's true is executed. The true
at the end is a catch-all.
case
case
uses pattern matching, which makes it very powerful:
result = {:ok, "success"}
case result do
{:ok, message} -> "Success: #{message}"
{:error, reason} -> "Error: #{reason}"
_ -> "Unknown result"
end
The _
pattern matches anything and is used as a catch-all.
Functions and Functional Programming
Function Clauses
Elixir functions can have multiple clauses with different patterns. This is one of the most powerful features:
defmodule Math do
def factorial(0), do: 1
def factorial(n) when n > 0 do
n * factorial(n - 1)
end
def factorial(_n) do
{:error, "Cannot calculate factorial of negative number"}
end
end
Math.factorial(5) # 120
Math.factorial(0) # 1
Math.factorial(-1) # {:error, "Cannot calculate factorial of negative number"}
When you call factorial(5)
, Elixir tries each clause in order. The first one doesn't match (5 ≠ 0), the second one matches (5 > 0), so it executes that one.
Guard Clauses
Guards let you add conditions to function clauses:
defmodule Validator do
def validate_age(age) when is_integer(age) and age >= 0 do
{:ok, age}
end
def validate_age(age) when is_integer(age) and age < 0 do
{:error, "Age cannot be negative"}
end
def validate_age(_age) do
{:error, "Age must be an integer"}
end
end
Guards are limited to a specific set of functions and operators, but they're very useful for adding conditions to pattern matching.
Anonymous Functions
Sometimes you need a function but don't want to define it in a module:
# Anonymous function
add = fn a, b -> a + b end
add.(2, 3) # 5
# Shorthand with capture operator
add = &(&1 + &2)
add.(2, 3) # 5
# Using them with lists
numbers = [1, 2, 3, 4, 5]
Enum.map(numbers, fn x -> x * 2 end) # [2, 4, 6, 8, 10]
# Even shorter with capture
Enum.map(numbers, &(&1 * 2)) # [2, 4, 6, 8, 10]
Enum.map(numbers, &Integer.to_string/1) # ["1", "2", "3", "4", "5"]
The &
operator is a shorthand for creating anonymous functions. &1
, &2
, etc. refer to the arguments.
Working with Collections
Enum Module
The Enum
module provides functions for working with collections. It's one of the most commonly used modules:
numbers = [1, 2, 3, 4, 5]
# map - transform each element
Enum.map(numbers, &(&1 * 2)) # [2, 4, 6, 8, 10]
# filter - keep elements that match a condition
Enum.filter(numbers, &(&1 > 3)) # [4, 5]
# reduce - combine all elements into a single value
Enum.reduce(numbers, 0, &(&1 + &2)) # 15
# find - find the first element that matches
Enum.find(numbers, &(&1 > 3)) # 4
# any? - check if any element matches
Enum.any?(numbers, &(&1 > 3)) # true
# all? - check if all elements match
Enum.all?(numbers, &(&1 > 0)) # true
These functions are the building blocks of functional programming. They let you transform data without changing it.
List Comprehensions
List comprehensions provide a concise way to create lists:
numbers = [1, 2, 3, 4, 5]
# Basic comprehension
doubled = for n <- numbers, do: n * 2
# [2, 4, 6, 8, 10]
# With filters
evens = for n <- numbers, rem(n, 2) == 0, do: n
# [2, 4]
# Multiple generators
pairs = for x <- [1, 2], y <- [3, 4], do: {x, y}
# [{1, 3}, {1, 4}, {2, 3}, {2, 4}]
# Creating maps
squares = for n <- numbers, into: %{}, do: {n, n * n}
# %{1 => 1, 2 => 4, 3 => 9, 4 => 16, 5 => 25}
List comprehensions are often more readable than nested Enum
functions, especially for complex transformations.
Modules and Structs
Modules
Modules organize your code and provide namespacing:
defmodule User do
# Module attributes (constants)
@default_age 18
@valid_roles [:admin, :user, :guest]
# Public functions
def create(name, age \\ @default_age) do
%{
name: name,
age: age,
role: :user
}
end
def is_adult?(user) do
user.age >= 18
end
# Private functions
defp validate_age(age) when age >= 0, do: true
defp validate_age(_), do: false
end
Module attributes (like @default_age
) are constants that are evaluated at compile time. Private functions (starting with defp
) can only be called from within the module.
Structs
Structs are maps with a predefined set of keys. They're like classes in object-oriented languages:
defmodule User do
defstruct name: "", age: 0, email: "", role: :user
def create(name, age, email) do
%User{name: name, age: age, email: email}
end
def is_admin?(%User{role: :admin}), do: true
def is_admin?(_), do: false
end
# Using the struct
user = %User{name: "Alice", age: 30, email: "alice@example.com"}
user.name # "Alice"
user.role # :user (default value)
Structs provide compile-time guarantees about the structure of your data and can have default values.
Error Handling
Elixir has a "let it crash" philosophy, but provides tools for handling errors gracefully.
Result Pattern
The most common pattern is to return {:ok, result}
or {:error, reason}
:
defmodule SafeOperations do
def divide(a, b) when b == 0, do: {:error, "Cannot divide by zero"}
def divide(a, b), do: {:ok, a / b}
def read_file(path) do
case File.read(path) do
{:ok, content} -> {:ok, content}
{:error, reason} -> {:error, "Failed to read file: #{reason}"}
end
end
end
This approach is preferred because it's explicit and forces you to handle both success and failure cases.
with Expression
When you have multiple operations that might fail, with
is your friend:
def process_user_data(user_id) do
with {:ok, user} <- get_user(user_id),
{:ok, profile} <- get_profile(user.profile_id),
{:ok, preferences} <- get_preferences(user.preferences_id) do
{:ok, %{user: user, profile: profile, preferences: preferences}}
else
{:error, reason} -> {:error, reason}
end
end
The with
expression tries each operation in sequence. If any fails, it stops and executes the else
block. If all succeed, it executes the main block.
What's Next?
You're getting comfortable with Elixir's fundamentals! In the next article, we'll explore recursion, work with files, and start building proper Mix projects.
Practice Exercises
Try these to reinforce what you've learned:
- Pattern Matching: Write a function that takes a tuple
{name, age}
and returns a greeting based on age - List Operations: Create a function that finds the maximum value in a list using recursion
- Map Manipulation: Write a function that updates a user map with new information
- Function Composition: Use the pipe operator to process a list of numbers through multiple transformations
Key Points
- Pattern matching is fundamental to Elixir programming
- Lists are linked lists - fast to prepend, slower to access by index
- Maps are your primary key-value data structure
- Functions can have multiple clauses with different patterns
- The Enum module provides powerful collection operations
- List comprehensions offer a concise way to transform data
- Modules and structs help organize your code
- Error handling often uses pattern matching rather than exceptions
- Pattern Matching: Elixir's Superpower
- What is Pattern Matching?
- Basic Pattern Matching
- Destructuring Data
- The Pin Operator
- Pattern Matching in Function Heads
- Working with Lists
- Why Linked Lists?
- List Construction
- List Operations
- Head and Tail
- Building Lists
- Maps and Keyword Lists
- Maps
- Accessing Map Values
- Updating Maps
- Keyword Lists
- Control Structures
- if/else
- cond
- case
- Functions and Functional Programming
- Function Clauses
- Guard Clauses
- Anonymous Functions
- Working with Collections
- Enum Module
- List Comprehensions
- Modules and Structs
- Modules
- Structs
- Error Handling
- Result Pattern
- with Expression
- What's Next?
- Practice Exercises
- Key Points