Part 2: The Language
What Makes Elixir Click
Just a couple of “aha” moments that make Elixir different.
We’ll cover three things:
- Pattern Matching — a feature that changes how you think about code
- Processes & Concurrency — lightweight, isolated, fault-tolerant
- LiveView — real-time web UIs without JavaScript
Pattern Matching: The Basics
In most languages, = means assignment. In Elixir, = means match.
It works like destructuring, but it also validates the shape of your data.
# Destructure a tuple
{status, message} = {:ok, "Hello, GR Web Dev!"}
IO.puts("Status: #{status}")
IO.puts("Message: #{message}")
# Destructure a map — pull out just what you need
user = %{name: "Torey", role: :developer, city: "Grand Rapids"}
%{name: name, city: city} = user
IO.puts("#{name} from #{city}")
# Match on a specific value — this is where it gets interesting
{:ok, result} = {:ok, 42}
IO.puts("Got: #{result}")
# What happens when the match FAILS?
{:ok, result} = {:error, "something went wrong"}
That’s not a bug — it’s a feature. If you expect :ok and get :error,
Elixir tells you immediately instead of silently continuing with bad data.
JavaScript: Similar to destructuring — but it also validates the shape of your data at the same time.
Pattern Matching: Function Heads
In Elixir, you can define the same function multiple times with different patterns. Elixir picks the first one that matches.
At first I thought this was a bad idea, calling/creating a different function, based on the args… but then it clicked!
Instead of creating if/case statements to handle different inputs with a single function, you can create focused functions based on the inputs.
defmodule Greeter do
# If we get an :ok tuple, celebrate
def respond({:ok, data}) do
"Success! Got: #{inspect(data)}"
end
# If we get an :error tuple, handle it
def respond({:error, reason}) do
"Something went wrong: #{reason}"
end
# Catch-all for anything else
def respond(other) do
"Unexpected: #{inspect(other)}"
end
end
Greeter.respond({:ok, [1, 2, 3]})
Greeter.respond({:error, "not found"})
Greeter.respond("something random")
No if/else. No switch. Each function clause handles one case, and the code
reads like a specification.
Pattern Matching: A Practical Example
Here’s something closer to real code. Imagine handling different shapes of API responses:
defmodule ApiHandler do
# Success with data
def handle_response(%{status: 200, body: body}) do
{:ok, body}
end
# Created
def handle_response(%{status: 201, body: body}) do
{:ok, body}
end
# Not found
def handle_response(%{status: 404}) do
{:error, :not_found}
end
# Rate limited — note the header extraction
def handle_response(%{status: 429, headers: headers}) do
retry_after = Map.get(headers, "retry-after", "60")
{:rate_limited, String.to_integer(retry_after)}
end
# Server error
def handle_response(%{status: status}) when status >= 500 do
{:error, :server_error}
end
# Anything else
def handle_response(%{status: status}) do
{:error, {:unexpected_status, status}}
end
end
# Try different responses:
ApiHandler.handle_response(%{status: 200, body: %{users: ["Alice", "Bob"]}})
ApiHandler.handle_response(%{status: 429, headers: %{"retry-after" => "30"}})
No if/else chains, no switch statements. Each clause is self-documenting.
Pattern Matching: The with Statement
Real-world code often has multiple steps that can each fail. In other languages there’s an Interactor pattern that often requires a whole library to implement. Elixir has the built-in with statement
defmodule OrderProcessor do
def process_order(order_id) do
with(
{:ok, order} <- find_order(order_id),
{:ok, _} <- validate_inventory(order),
{:ok, charge} <- charge_payment(order),
{:ok, shipment} <- create_shipment(order)
) do
{:ok, %{order: order, charge: charge, shipment: shipment}}
else
{:error, :not_found} -> {:error, "Order not found"}
{:error, :out_of_stock} -> {:error, "Item is out of stock"}
{:error, :payment_declined} -> {:error, "Payment was declined"}
{:error, reason} -> {:error, "Unexpected error: #{inspect(reason)}"}
end
end
# Simulated steps — in production these hit databases and APIs
defp find_order(1), do: {:ok, %{id: 1, item: "Elixir Book", amount: 29_99}}
defp find_order(2), do: {:ok, %{id: 2, item: "Sold Out Tee", amount: 19_99}}
defp find_order(3), do: {:ok, %{id: 3, item: "Elixir Book", amount: 99_99}}
defp find_order(_), do: {:error, :not_found}
defp validate_inventory(%{item: "Elixir Book"}), do: {:ok, :in_stock}
defp validate_inventory(_), do: {:error, :out_of_stock}
defp charge_payment(%{amount: amount}) when amount < 50_00, do: {:ok, %{charge_id: "ch_123", amount: amount}}
defp charge_payment(_), do: {:error, :payment_declined}
defp create_shipment(order), do: {:ok, %{tracking: "1Z999AA10123456784", order_id: order.id}}
end
# Happy path — all steps succeed
OrderProcessor.process_order(1)
# Out of stock — short circuits at validate_inventory
OrderProcessor.process_order(2)
The with statement chains operations that return {:ok, value} or {:error, reason}.
If any step fails, it jumps straight to the else block.
Processes: The Heart of Elixir
Everything in Elixir runs inside processes — not OS processes, not threads. They’re ~2KB each, completely isolated, with their own memory and garbage collection.
# Spawn a process — it runs concurrently
spawn(fn ->
IO.puts("Hello from process #{inspect(self())}")
end)
IO.puts("Hello from the main process #{inspect(self())}")
# Let's spawn 10,000 just to show it's trivial.
{time_microseconds, _} =
:timer.tc(fn ->
1..10_000
|> Enum.map(fn i ->
spawn(fn -> i * i end)
end)
end)
IO.puts("Spawned 10,000 processes in #{time_microseconds / 1_000}ms")
Processes: Supervisors — “Let It Crash”
What happens when a process crashes? In most languages, you write defensive code to prevent every possible error. In Elixir, you let it crash and have a Supervisor automatically restart it.
defmodule UnreliableWorker do
use GenServer
def start_link(opts) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
def get_count(pid), do: GenServer.call(pid, :get_count)
def do_work(pid), do: GenServer.call(pid, :do_work)
@impl true
def init(_opts) do
IO.puts(" [Worker] Started! (PID: #{inspect(self())})")
{:ok, %{count: 0}}
end
@impl true
def handle_call(:get_count, _from, state) do
{:reply, state.count, state}
end
@impl true
def handle_call(:do_work, _from, state) do
new_count = state.count + 1
if new_count >= 3 do
# Simulate a crash on the 3rd call
raise "Boom! Something went wrong on call ##{new_count}"
end
IO.puts(" [Worker] Completed work ##{new_count}")
{:reply, {:ok, new_count}, %{state | count: new_count}}
end
end
# Start a Supervisor watching our unreliable worker
children = [
{UnreliableWorker, name: :my_worker}
]
{:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one)
IO.puts("--- Doing work ---")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 1")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 2")
IO.puts("\n--- This next call will crash the worker ---")
try do
UnreliableWorker.do_work(:my_worker)
catch
:exit, _ -> IO.puts(" [Caller] The worker crashed!")
end
# Give the supervisor a moment to restart the worker
Process.sleep(100)
IO.puts("\n--- Worker was automatically restarted by the Supervisor ---")
IO.puts("--- Notice: it's a NEW process with fresh state ---")
UnreliableWorker.do_work(:my_worker) |> IO.inspect(label: "Call 1 (restarted)")
UnreliableWorker.get_count(:my_worker) |> IO.inspect(label: "Count")
What just happened:
- The worker crashed on the 3rd call
- The Supervisor detected the crash
- The Supervisor started a brand new worker automatically
- The new worker has fresh state (count = 0)
- Everything keeps running — the crash was isolated and recovered
This is the “let it crash” philosophy. Instead of writing defensive code for every possible failure, you design for recovery. Your Supervisor is like a manager who automatically fixes problems instead of you writing code to prevent every possible issue.
In production at Vianet: Our email rate limiter, database connections, TCP connections to Verisign — they’re all supervised. If any of them crash, they restart automatically. The application keeps running.
LiveView: Real-Time Web Without JavaScript
LiveView is a Phoenix framework feature that gives you real-time, interactive UIs rendered entirely on the server.
How It Works
┌──────────────┐ ┌──────────────┐
│ Browser │ │ Server │
│ │ 1. HTTP GET │ │
│ │ ──────────────────>│ │
│ │ Full HTML page │ │
│ │ <──────────────────│ │
│ │ │ │
│ │ 2. WebSocket │ │
│ │ <════════════════> │ │
│ │ │ │
│ User clicks │ 3. Event sent │ │
│ a button │ ──────────────────>│ Processes │
│ │ │ the event, │
│ │ 4. HTML diff │ re-renders │
│ │ <──────────────────│ │
│ Browser │ │ │
│ patches DOM │ │ │
└──────────────┘ └──────────────┘
- First page load is regular HTML — fast, SEO-friendly
- WebSocket connection upgrades the page to real-time
- User interactions send tiny events over the WebSocket
- Server re-renders and sends back only the HTML that changed
What the Code Looks Like
Here’s a simplified version of a real search feature from one of our apps:
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:query, "")
|> assign(:results, [])
end
# User types in the search box
# Search runs asynchronously
def handle_event("search", %{"query" => query}, socket) do
results = MyApp.Search.find(query)
{:noreply,
socket
|> assign(:results, results)
end
end
Recap
-
Pattern matching — function heads replace if/else,
withchains operations with error handling - Processes — lightweight (~2KB), isolated, supervised — “let it crash” instead of defensive code
- LiveView — real-time UI without JavaScript, server renders HTML diffs over WebSocket
Next up: Let’s see these patterns in production code…