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