← Back to DevBytes

Functional Programming in Elixir

What is Functional Programming in Elixir?

Functional programming is a paradigm that treats computation as the evaluation of mathematical functions, avoiding mutable state and side effects. Elixir, built on top of the Erlang VM (BEAM), embraces functional programming as its core paradigm. In Elixir, functions are first-class citizens, data is immutable, and programs are structured primarily through the composition and transformation of data rather than through sequences of imperative commands.

Unlike object-oriented languages where you model stateful objects that interact, Elixir encourages you to think about your program as a pipeline of data transformations. Every function takes input, produces output, and leaves no trace on the original data. This fundamental shift in thinking leads to code that is easier to reason about, test, and run concurrently.

Why Functional Programming Matters in Elixir

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The functional nature of Elixir is not merely an academic preference — it is a practical necessity driven by the requirements of the BEAM. The Erlang VM was designed for building fault-tolerant, concurrent, and distributed systems. Immutability and pure functions make concurrency dramatically simpler: when data cannot change, you eliminate entire categories of bugs like race conditions and deadlocks that plague mutable-state concurrent programming.

Key benefits include:

Core Functional Programming Concepts in Elixir

Immutability

In Elixir, all data is immutable. Once a value is created, it cannot be changed. Operations that appear to modify data actually return new copies with the desired changes, leaving the original intact.

# Original list remains unchanged
original = [1, 2, 3]
modified = [0 | original]  # => [0, 1, 2, 3]
# original is still [1, 2, 3]

# Maps work the same way
user = %{name: "Alice", age: 30}
updated = %{user | age: 31}
# user is still %{name: "Alice", age: 30}

This property is essential because Elixir processes share memory safely without locks — each process sees only its own immutable copy of data, so concurrent access never causes corruption.

Pure Functions

A pure function relies only on its input arguments, produces no side effects, and always returns the same result for the same inputs. Elixir encourages pure functions as the default approach, with side effects explicitly confined to specific boundaries (like IO operations or process messaging).

# Pure function — no side effects, deterministic
defmodule Math do
  def square(x), do: x * x
end

Math.square(5)  # always returns 25
Math.square(5)  # always returns 25 — no matter how many times you call it

Higher-Order Functions

Functions in Elixir are first-class values — they can be assigned to variables, passed as arguments, and returned from other functions. Higher-order functions take functions as arguments or return new functions, enabling powerful abstractions.

# Passing a function as an argument
numbers = [1, 2, 3, 4, 5]
doubled = Enum.map(numbers, fn x -> x * 2 end)
# => [2, 4, 6, 8, 10]

# Returning a function from a function
defmodule Multiplier do
  def make_multiplier(factor) do
    fn x -> x * factor end
  end
end

double = Multiplier.make_multiplier(2)
triple = Multiplier.make_multiplier(3)
double.(5)  # => 10
triple.(5)  # => 15

Pattern Matching

Pattern matching is deeply integrated into Elixir's functional style. It allows you to destructure data and branch on its shape without explicit conditionals, making code declarative and readable.

# Pattern matching in function clauses
defmodule Greeter do
  def greet(%{name: name, role: "admin"}) do
    "Hello, Admin #{name}!"
  end

  def greet(%{name: name}) do
    "Hello, #{name}!"
  end

  def greet(_other) do
    "Hello, stranger!"
  end
end

Greeter.greet(%{name: "Bob", role: "admin"})  # => "Hello, Admin Bob!"
Greeter.greet(%{name: "Alice"})               # => "Hello, Alice!"
Greeter.greet("not even a map")               # => "Hello, stranger!"

Recursion Over Loops

Elixir does not have traditional while or for loops that mutate loop variables. Instead, you use recursion — a function calling itself with updated arguments. Elixir optimizes tail recursion so it runs efficiently without growing the stack.

# Recursive sum with accumulator (tail-recursive)
defmodule ListUtils do
  def sum(list), do: sum_acc(list, 0)

  defp sum_acc([], acc), do: acc
  defp sum_acc([head | tail], acc) do
    sum_acc(tail, acc + head)
  end
end

ListUtils.sum([1, 2, 3, 4, 5])  # => 15

In practice, higher-level abstractions like Enum.reduce/3 often replace explicit recursion, but understanding recursion is fundamental.

The Pipe Operator

The pipe operator |> is perhaps the most iconic feature of Elixir's functional style. It takes the result of one function and passes it as the first argument to the next, allowing you to chain transformations in a clear, left-to-right reading order.

# Without pipe — nested calls, hard to read
result = Enum.sum(Enum.filter(Enum.map([1, 2, 3, 4, 5], fn x -> x * 2 end), fn x -> x > 5 end))

# With pipe — flows like a data pipeline
result = [1, 2, 3, 4, 5]
  |> Enum.map(fn x -> x * 2 end)
  |> Enum.filter(fn x -> x > 5 end)
  |> Enum.sum()

# => 24  (doubled: [2,4,6,8,10], filtered: [6,8,10], sum: 24)

How to Use Functional Programming in Elixir

Modeling Data with Structs and Immutable Updates

Define your data structures as named structs, then use immutable update syntax to create modified copies. This pattern keeps your data transformations explicit and traceable.

defmodule Order do
  defstruct [:id, :items, :status, :total]

  def create(items) when is_list(items) do
    %Order{
      id: generate_id(),
      items: items,
      status: :pending,
      total: calculate_total(items)
    }
  end

  def add_item(order, item) do
    new_items = [item | order.items]
    %Order{order | items: new_items, total: calculate_total(new_items)}
  end

  def mark_shipped(order) do
    %Order{order | status: :shipped}
  end

  defp generate_id, do: :erlang.unique_integer()
  defp calculate_total(items), do: Enum.reduce(items, 0, fn item, acc -> acc + item.price end)
end

Building Pipelines for Business Logic

Express complex workflows as sequences of transformations. Each step is a pure function that takes data and returns transformed data.

defmodule Checkout do
  def process(cart) do
    cart
    |> validate_items()
    |> apply_discounts()
    |> calculate_tax()
    |> create_order()
  end

  defp validate_items(cart) do
    valid_items = Enum.filter(cart.items, fn item -> item.stock > 0 end)
    %{cart | items: valid_items}
  end

  defp apply_discounts(cart) do
    subtotal = Enum.reduce(cart.items, 0, fn item, acc -> acc + item.price end)
    discount = if subtotal > 100, do: subtotal * 0.1, else: 0
    %{cart | subtotal: subtotal, discount: discount}
  end

  defp calculate_tax(cart) do
    taxable = cart.subtotal - cart.discount
    %{cart | tax: taxable * 0.08}
  end

  defp create_order(cart) do
    %{id: generate_order_id(), cart: cart, total: cart.subtotal - cart.discount + cart.tax}
  end
end

Leveraging Pattern Matching for Control Flow

Use pattern matching in function heads, case statements, and with blocks to handle different scenarios declaratively.

defmodule FileHandler do
  def process_response({:ok, content}) do
    content
    |> String.split("\n")
    |> Enum.reject(&(String.trim(&1) == ""))
    |> parse_lines()
  end

  def process_response({:error, :enoent}) do
    "File not found — creating default configuration"
    |> create_default()
  end

  def process_response({:error, reason}) do
    "Unexpected error: #{inspect(reason)}"
  end

  defp parse_lines(lines) do
    Enum.map(lines, fn line ->
      case String.split(line, "=") do
        [key, value] -> %{key: key, value: String.trim(value)}
        [_single] -> %{key: line, value: nil}
        [] -> %{key: "", value: nil}
      end
    end)
  end
end

Using with Blocks for Happy-Path Logic

The with construct lets you chain operations that may fail, propagating errors automatically while keeping the success path clean.

defmodule UserRegistration do
  def register(params) do
    with {:ok, validated} <- validate(params),
         {:ok, user} <- create_user(validated),
         {:ok, _} <- send_welcome_email(user) do
      {:ok, user}
    else
      {:error, :invalid_email} -> {:error, "Please provide a valid email address"}
      {:error, :duplicate} -> {:error, "An account with this email already exists"}
      {:error, _} -> {:error, "Registration failed, please try again"}
    end
  end

  defp validate(%{email: email} = params) when is_binary(email) do
    if String.contains(email, "@"), do: {:ok, params}, else: {:error, :invalid_email}
  end

  defp create_user(params) do
    # Simulated database insert
    {:ok, %{id: 123, email: params.email, name: params.name}}
  end

  defp send_welcome_email(user) do
    # Simulated email sending
    IO.puts("Sending welcome email to #{user.email}")
    {:ok, %{sent: true}}
  end
end

Embracing Enum and Stream for Data Processing

Elixir provides rich modules for working with collections functionally. Enum is eager (processes immediately), while Stream is lazy (processes on demand).

# Eager processing with Enum
defmodule Analytics do
  def top_sellers(orders, count \\ 3) do
    orders
    |> Enum.flat_map(fn order -> order.items end)
    |> Enum.group_by(fn item -> item.product_id end, fn item -> item.quantity end)
    |> Enum.map(fn {product_id, quantities} -> {product_id, Enum.sum(quantities)} end)
    |> Enum.sort_by(fn {_, total_qty} -> total_qty end, :desc)
    |> Enum.take(count)
  end
end

# Lazy processing with Stream for large datasets
defmodule LogProcessor do
  def find_errors(log_stream) do
    log_stream
    |> Stream.map(&String.trim/1)
    |> Stream.reject(&(String.length(&1) == 0))
    |> Stream.filter(fn line -> String.contains(line, "[ERROR]") end)
    |> Stream.map(fn line ->
      [timestamp | message_parts] = String.split(line, " ")
      %{timestamp: timestamp, message: Enum.join(message_parts, " ")}
    end)
    |> Enum.take(100)  # Only process first 100 errors lazily
  end
end

Working with Processes Functionally

Elixir processes (lightweight actors) communicate through immutable message passing, aligning perfectly with functional principles. Each process maintains its own state recursively.

defmodule Counter do
  def start(initial_value \\ 0) do
    spawn(fn -> loop(initial_value) end)
  end

  def increment(pid) do
    send(pid, {:increment, self()})
    receive do
      {:ok, new_value} -> new_value
    end
  end

  def get(pid) do
    send(pid, {:get, self()})
    receive do
      {:value, value} -> value
    end
  end

  defp loop(value) do
    receive do
      {:increment, caller} ->
        new_value = value + 1
        send(caller, {:ok, new_value})
        loop(new_value)  # Tail-recursive state update

      {:get, caller} ->
        send(caller, {:value, value})
        loop(value)  # Same state, continue looping
    end
  end
end

# Usage
counter = Counter.start(10)
Counter.increment(counter)  # => 11
Counter.increment(counter)  # => 12
Counter.get(counter)        # => 12

Best Practices

Keep Functions Small and Focused

Each function should do one thing well. If a function exceeds 10-15 lines, consider extracting sub-functions. Small functions are easier to test, understand, and compose.

# Instead of one large function
def process_order(order) do
  validate(order)
  |> calculate_totals()
  |> apply_loyalty_points()
  |> schedule_delivery()
  |> generate_confirmation()
end

Prefer Pure Functions, Push Side Effects to the Edges

Structure your application as a pure functional core surrounded by a thin layer that handles IO, database calls, and external services. The core contains all business logic and is trivially testable.

# Pure business logic (testable without mocks)
defmodule Pricing do
  def apply_discount(price, customer_tier) do
    case customer_tier do
      :platinum -> price * 0.85
      :gold -> price * 0.90
      :silver -> price * 0.95
      _ -> price
    end
  end
end

# Impure boundary (thin adapter)
defmodule PricingAdapter do
  def discounted_price(product_id, user_id) do
    price = Database.fetch_price(product_id)  # Side effect
    tier = Database.fetch_user_tier(user_id)  # Side effect
    Pricing.apply_discount(price, tier)       # Pure
  end
end

Leverage Pattern Matching Exhaustively

Always cover all possible patterns to avoid runtime errors. Use default catch-all clauses when appropriate, and let the compiler warn you about missing cases.

defmodule Payment do
  def process({:credit_card, %{number: num, expiry: exp, cvv: cvv}}) do
    # Handle credit card
    {:ok, "Processing credit card ending in #{String.slice(num, -4, 4)}"}
  end

  def process({:paypal, %{email: email}}) do
    # Handle PayPal
    {:ok, "Processing PayPal for #{email}"}
  end

  def process({:crypto, %{wallet: wallet}}) do
    # Handle cryptocurrency
    {:ok, "Processing crypto wallet #{wallet}"}
  end

  def process(unknown) do
    {:error, "Unsupported payment method: #{inspect(unknown)}"}
  end
end

Use Guards for Type and Value Constraints

Guards add precise constraints to pattern matches, catching errors early and making function contracts explicit.

defmodule Transfer do
  def execute(amount, from_account, to_account)
      when is_number(amount) and amount > 0
      and is_binary(from_account) and is_binary(to_account)
      and from_account != to_account do
    # Perform transfer
    {:ok, "Transferred $#{amount} from #{from_account} to #{to_account}"}
  end

  def execute(amount, _from, _to) when not is_number(amount) do
    {:error, "Amount must be a number"}
  end

  def execute(amount, _from, _to) when amount <= 0 do
    {:error, "Amount must be positive, got: #{amount}"}
  end

  def execute(_amount, from, to) when from == to do
    {:error, "Cannot transfer to the same account"}
  end
end

Compose with Functions, Not Macros

Elixir offers macros for metaprogramming, but for day-to-day code, favor plain function composition. Functions are clearer, easier to debug, and compose predictably.

# Function composition helper
defmodule Functional do
  def compose(f, g) do
    fn x -> x |> g.() |> f.() end
  end
end

trim_and_upcase = Functional.compose(&String.upcase/1, &String.trim/1)
trim_and_upcase.("  hello world  ")  # => "HELLO WORLD"

Write Tests for Pure Functions First

Because pure functions are deterministic, their tests are simple and fast. Write exhaustive tests for your functional core without worrying about setup, teardown, or mocking.

defmodule PricingTest do
  use ExUnit.Case

  test "platinum tier gets 15% discount" do
    assert Pricing.apply_discount(100, :platinum) == 85.0
  end

  test "gold tier gets 10% discount" do
    assert Pricing.apply_discount(100, :gold) == 90.0
  end

  test "regular customer pays full price" do
    assert Pricing.apply_discount(100, :regular) == 100
  end
end

Embrace Recursion with Accumulators for Complex Loops

When you need to build up a result iteratively, use an accumulator pattern with tail recursion. This keeps the function pure and efficient.

defmodule Tree do
  def flatten(tree) do
    flatten_acc(tree, [])
  end

  defp flatten_acc(nil, acc), do: Enum.reverse(acc)
  defp flatten_acc({:leaf, value}, acc), do: Enum.reverse([value | acc])
  defp flatten_acc({:node, left, right}, acc) do
    acc
    |> flatten_acc(left)
    |> flatten_acc(right)
  end
end

# Example tree: {:node, {:leaf, 1}, {:node, {:leaf, 2}, {:leaf, 3}}}
Tree.flatten({:node, {:leaf, 1}, {:node, {:leaf, 2}, {:leaf, 3}}})
# => [1, 2, 3]

Conclusion

Functional programming in Elixir is not just a stylistic choice — it is the foundational paradigm that unlocks the full power of the BEAM. By embracing immutability, pure functions, pattern matching, and the pipe operator, you write code that scales effortlessly across cores and machines, resists entire categories of bugs, and expresses complex logic with remarkable clarity. The journey from imperative thinking to functional thinking takes practice, but each step — from your first pipe chain to your first tail-recursive process — brings you closer to building systems that are robust, maintainable, and a genuine joy to work with. Start by making your functions pure, your data immutable, and your pipelines readable; the rest will follow naturally.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles