Elixir from Scratch: Functional Paradigm, Pattern Matching and Pipe Operator
Introduction to Elixir for developers with OOP background: like pattern matching replaces the classic if/switch, the pipe operator |> for elegant composition, data immutability and why this leads to more predictable code.
Why Elixir in 2026
Elixir is not "just another functional language". It is built on the Erlang's BEAM Virtual Machine, a system designed in the 1980s by Ericsson to manage millions of concurrent telephone connections with 99.9999999% uptime. Elixir brings on this basis a modern syntax, the Mix/Hex toolchain, and Phoenix Framework — one of the best performing web frameworks documented in the sector.
Elixir's functional paradigm is not an aesthetic choice: it is what makes fault tolerance and massive concurrency possible of BEAM. To understand OTP and GenServer (later articles), you first need to master these fundamentals.
What You Will Learn
- Installation and setup with Mix, IEx (interactive REPL)
- Basic types: atom, tuple, list, map, keyword list
- Pattern matching: Elixir's most powerful feature
- Pipe operator |>: Readable function composition
- Immutability: Because you can't change a variable
- Functions: named, anonymous, and higher-order functions
- Modules: How to organize code in Elixir
Installation and Setup
# Installazione su Linux/Mac (via asdf, raccomandato)
asdf plugin add erlang
asdf plugin add elixir
asdf install erlang 26.2.5
asdf install elixir 1.16.3
asdf global erlang 26.2.5
asdf global elixir 1.16.3
# Verifica
elixir --version
# Erlang/OTP 26 [erts-14.2.5] [...]
# Elixir 1.16.3 (compiled with Erlang/OTP 26)
# Crea un nuovo progetto
mix new my_app
cd my_app
# Avvia il REPL interattivo
iex -S mix
# Struttura progetto generata
# my_app/
# ├── lib/
# │ └── my_app.ex (modulo principale)
# ├── test/
# │ └── my_app_test.exs (test)
# ├── mix.exs (configurazione + dipendenze)
# └── README.md
Base Types and Data Structures
# IEx: esplora i tipi di Elixir
# Atoms: costanti identificate dal loro nome
:ok
:error
:hello
true # equivale a :true
false # equivale a :false
nil # equivale a :nil
# Integers e floats
42
3.14
1_000_000 # underscore per leggibilita'
# Strings: binary UTF-8
"Ciao, mondo!"
"Linea 1\nLinea 2"
"Interpolazione: #{1 + 1}" # "Interpolazione: 2"
# Atoms binari vs String
:hello == "hello" # false - tipi diversi!
:hello == :hello # true - stessa identita'
# Tuple: sequenza fissa di lunghezza nota
{:ok, "valore"}
{:error, :not_found}
{1, 2, 3}
# Pattern comune: tagged tuple per risultati
# {:ok, value} oppure {:error, reason}
# List: linked list (efficiente per head/tail)
[1, 2, 3, 4, 5]
["mario", "luigi", "peach"]
[head | tail] = [1, 2, 3] # Pattern matching!
# head = 1, tail = [2, 3]
# Map: key-value store
%{name: "Mario", age: 35} # Atom keys (piu' comune)
%{"name" => "Mario", "age" => 35} # String keys
# Keyword list: list di tuple {atom, value} (ordinate)
[name: "Mario", age: 35, city: "Milano"]
# Equivale a: [{:name, "Mario"}, {:age, 35}, {:city, "Milano"}]
# Usata per opzioni di funzione
# Range
1..10 # Range inclusivo
1..10//2 # Step 2: [1, 3, 5, 7, 9]
Pattern Matching: The Elixir Core
In Elixir, = it's not an assignment: it's an operation
of match. The left side is "matched" to the right side.
If the match succeeds, the variables in the pattern are bound to the corresponding values.
If it fails, a is raised MatchError.
# Pattern matching: le basi
# Binding semplice
x = 42 # x viene legata a 42
42 = x # OK: x e' 42, il match succeede
# 43 = x # MatchError: 43 != 42
# Tuple destructuring
{:ok, value} = {:ok, "risultato"}
# value = "risultato"
{:ok, value} = {:error, :not_found}
# MatchError! :ok != :error
# Case expression: match multiplo
result = {:error, :timeout}
case result do
{:ok, value} ->
IO.puts("Successo: #{value}")
{:error, :not_found} ->
IO.puts("Non trovato")
{:error, reason} ->
IO.puts("Errore generico: #{reason}")
_ ->
IO.puts("Fallback: qualsiasi altro caso")
end
# Output: "Errore generico: timeout"
# Lista head/tail
[first | rest] = [1, 2, 3, 4, 5]
# first = 1, rest = [2, 3, 4, 5]
[a, b | remaining] = [10, 20, 30, 40]
# a = 10, b = 20, remaining = [30, 40]
# Map matching (partial match: extra keys sono ok)
%{name: name, age: age} = %{name: "Mario", age: 35, city: "Milano"}
# name = "Mario", age = 35
# Pin operator ^: usa il valore corrente, non rebind
existing = 42
^existing = 42 # OK: match con il valore corrente di existing
# ^existing = 43 # MatchError
# Pattern matching in function definitions
defmodule HttpResponse do
# Diverse implementazioni per pattern diversi
def handle({:ok, %{status: 200, body: body}}) do
IO.puts("Success: #{body}")
end
def handle({:ok, %{status: 404}}) do
IO.puts("Not Found")
end
def handle({:ok, %{status: status}}) when status >= 500 do
IO.puts("Server Error: #{status}")
end
def handle({:error, reason}) do
IO.puts("Request failed: #{reason}")
end
end
# Uso
HttpResponse.handle({:ok, %{status: 200, body: "Hello"}}) # Success: Hello
HttpResponse.handle({:ok, %{status: 404}}) # Not Found
HttpResponse.handle({:error, :timeout}) # Request failed: timeout
# Guards (when clause): condizioni aggiuntive
defmodule Validator do
def validate_age(age) when is_integer(age) and age >= 0 and age <= 150 do
{:ok, age}
end
def validate_age(age) when is_integer(age) do
{:error, "Age #{age} out of valid range (0-150)"}
end
def validate_age(_) do
{:error, "Age must be an integer"}
end
end
Pipe Operator: Composition of Transformations
The pipe operator |> passes the result of the previous expression
as the first argument of the next function. Transform nested calls
in a readable linear sequence — the code expresses a pipeline of
transformations in the order in which they occur.
# Senza pipe: nidificazione profonda (leggibilita' scarsa)
result = Enum.sum(Enum.filter(Enum.map([1, 2, 3, 4, 5], fn x -> x * 2 end), fn x -> x > 4 end))
# Con pipe: pipeline leggibile dall'alto in basso
result =
[1, 2, 3, 4, 5]
|> Enum.map(fn x -> x * 2 end) # [2, 4, 6, 8, 10]
|> Enum.filter(fn x -> x > 4 end) # [6, 8, 10]
|> Enum.sum() # 24
# Esempio reale: processamento di una lista di utenti
defmodule UserProcessor do
def process_active_users(users) do
users
|> Enum.filter(&active?/1) # Solo utenti attivi
|> Enum.sort_by(& &1.name) # Ordina per nome
|> Enum.map(&enrich_with_metadata/1) # Aggiungi metadata
|> Enum.take(50) # Top 50
end
defp active?(%{status: :active}), do: true
defp active?(_), do: false
defp enrich_with_metadata(user) do
Map.put(user, :display_name, format_display_name(user))
end
defp format_display_name(%{name: name, city: city}) do
"#{name} (#{city})"
end
defp format_display_name(%{name: name}) do
name
end
end
users = [
%{name: "Mario", status: :active, city: "Milano"},
%{name: "Luigi", status: :inactive, city: "Roma"},
%{name: "Peach", status: :active, city: "Torino"},
]
UserProcessor.process_active_users(users)
# [
# %{name: "Mario", status: :active, city: "Milano", display_name: "Mario (Milano)"},
# %{name: "Peach", status: :active, city: "Torino", display_name: "Peach (Torino)"},
# ]
Immutability: Data that Never Changes
In Elixir, data structures are immutable: you cannot modify a list, an existing map or tuple. Functions always return new structures. This eliminates a whole class of bugs (side effects, shared mutable state) and that's what makes BEAM's competition so robust.
# Immutabilita': le operazioni restituiscono nuovi dati
# Liste
original = [1, 2, 3]
new_list = [0 | original] # Prepend: [0, 1, 2, 3]
original # Invariato: [1, 2, 3]
# Map: non puoi modificare, ottieni una nuova map
user = %{name: "Mario", age: 35}
updated_user = Map.put(user, :city, "Milano")
# updated_user = %{name: "Mario", age: 35, city: "Milano"}
user # Ancora %{name: "Mario", age: 35}
# Syntactic sugar per update map
updated = %{user | age: 36} # Aggiorna solo age
# %{name: "Mario", age: 36}
# NOTA: questa sintassi fallisce se la chiave non esiste
# Rebinding: le variabili possono essere riassegnate nello stesso scope
x = 1
x = x + 1 # x e' ora 2 (ma il valore 1 non e' cambiato)
# Questo NON e' mutazione: e' binding di x a un nuovo valore
# Con il pin operator, previeni il rebinding accidentale
y = 42
case some_value do
^y -> "Uguale a 42" # Match solo se some_value == 42
_ -> "Diverso"
end
Modules and Functions
# Definizione moduli e funzioni
defmodule MyApp.Calculator do
@moduledoc """
Modulo di esempio per operazioni aritmetiche.
"""
# Funzione pubblica: doc + type spec
@doc "Somma due numeri interi."
@spec add(integer(), integer()) :: integer()
def add(a, b), do: a + b
# Funzione con guards
@spec divide(number(), number()) :: {:ok, float()} | {:error, String.t()}
def divide(_, 0), do: {:error, "Division by zero"}
def divide(a, b), do: {:ok, a / b}
# Funzione privata (non accessibile fuori dal modulo)
defp validate_positive(n) when n > 0, do: :ok
defp validate_positive(_), do: :error
# Funzioni anonime
def run_examples do
double = fn x -> x * 2 end
triple = &(&1 * 3) # Capture syntax: shorthand per fn
IO.puts(double.(5)) # 10 (nota il . per chiamare fn anonima)
IO.puts(triple.(5)) # 15
# Higher-order functions
[1, 2, 3, 4, 5]
|> Enum.map(double) # Passa funzione anonima come argomento
|> IO.inspect() # [2, 4, 6, 8, 10]
end
end
# Uso
MyApp.Calculator.add(3, 4) # 7
MyApp.Calculator.divide(10, 2) # {:ok, 5.0}
MyApp.Calculator.divide(10, 0) # {:error, "Division by zero"}
# Module attributes: costanti compile-time
defmodule Config do
@max_retries 3
@base_url "https://api.example.com"
@supported_currencies [:EUR, :USD, :GBP]
def max_retries, do: @max_retries
def base_url, do: @base_url
end
Enum and Stream: Collection Processing
# Enum: operazioni eager su liste (calcola subito)
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Enum.map(numbers, fn x -> x * x end)
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Enum.filter(numbers, &(rem(&1, 2) == 0))
# [2, 4, 6, 8, 10] -- solo pari
Enum.reduce(numbers, 0, fn x, acc -> acc + x end)
# 55 -- somma totale
Enum.group_by(numbers, &(rem(&1, 3)))
# %{0 => [3, 6, 9], 1 => [1, 4, 7, 10], 2 => [2, 5, 8]}
# Stream: operazioni lazy (calcola solo quando necessario)
# Utile per collection grandi o infinite
result =
Stream.iterate(0, &(&1 + 1)) # Lista infinita: 0, 1, 2, 3, ...
|> Stream.filter(&(rem(&1, 2) == 0)) # Solo pari (lazy)
|> Stream.map(&(&1 * &1)) # Quadrati (lazy)
|> Enum.take(5) # Prendi i primi 5 (trigger computation)
# [0, 4, 16, 36, 64]
# Stream da file: legge una riga alla volta (memory-efficient)
# File.stream!("large_file.csv")
# |> Stream.map(&String.trim/1)
# |> Stream.filter(&String.contains?(&1, "2026"))
# |> Enum.to_list()
Conclusions
Elixir's functional paradigm is not a restriction: it is the foundation that makes everything else possible. Pattern matching eliminate nested conditional branches. The pipe operator renders the transformations of data readable as prose. Immutability ensures that the functions do not have hidden side effects. These principles, combined with the BEAM VM, they are what makes Elixir so suitable for distributed systems high availability.
Upcoming Articles in the Elixir Series
- Article 2: BEAM and Processes — Massive Concurrency without Shared Threads
- Article 3: OTP and GenServer — Distributed State with Built-in Fault Tolerance
- Article 4: Supervisor Trees — Designing Systems that Never Die







