Last modified: July 17, 2025
·
9 min read

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:

  1. Pattern Matching: Write a function that takes a tuple {name, age} and returns a greeting based on age
  2. List Operations: Create a function that finds the maximum value in a list using recursion
  3. Map Manipulation: Write a function that updates a user map with new information
  4. 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