Book 1 β€” Elixir Fast-Track & the BEAM

You already know how to program. This book maps what you know from JavaScript/TypeScript/Python onto Elixir as fast as possible, then goes where those languages can't follow: the BEAM virtual machine and its millions of cheap, isolated processes.

πŸ““ Companion notebook: open book1-elixir-and-beam.livemd in Livebook and run every snippet as you read. The notebook has exercises this page only hints at.

1. Syntax Speedrun

Elixir is a functional language: no classes, no this, no mutation. Everything is modules (namespaces for functions) and data (immutable values). Here's the same logic in three languages:

defmodule Greeter do
  def hello(name, greeting \\ "Hello") do
    "#{greeting}, #{name}!"
  end
end

Greeter.hello("world")          # "Hello, world!"
Greeter.hello("BEAM", "Yo")    # "Yo, BEAM!"
function hello(name: string, greeting = "Hello"): string {
  return `${greeting}, ${name}!`;
}

hello("world");       // "Hello, world!"
hello("BEAM", "Yo");  // "Yo, BEAM!"
def hello(name, greeting="Hello"):
    return f"{greeting}, {name}!"

hello("world")        # "Hello, world!"
hello("BEAM", "Yo")   # "Yo, BEAM!"

The type cheat sheet

ElixirClosest JS/Python ideaNotes
:ok, :error (atoms)string enums / symbolsConstants whose value is their own name. Used everywhere.
{:ok, value} (tuple)[tag, value] pairFixed-size, contiguous. The standard success/failure envelope.
[1, 2, 3] (list)array / listLinked list! Prepend is O(1), index is O(n).
%{name: "Ada"} (map)object / dictKey–value. map.name for atom keys, map["k"] for others.
%User{name: "Ada"} (struct)typed object / dataclassA map with a fixed set of keys, defined in a module.
"hello" (binary)stringUTF-8 binaries. Concatenate with <>.
fn x -> x * 2 endarrow function / lambdaShorthand: &(&1 * 2). Call with f.(2).
Mindset shift #1: immutability is total. There is no push, no obj.x = 1, no list.sort() that mutates. Every operation returns a new value. Map.put(user, :age, 30) gives you a new map; user is untouched. This feels expensive but isn't β€” the VM shares structure under the hood, and it's what makes millions of concurrent processes safe.

2. Pattern Matching β€” the feature that changes everything

= in Elixir is not assignment. It's the match operator: it asserts that the left shape matches the right value, binding variables along the way. Think of it as destructuring (which you know from JS/Python) promoted to the core control-flow mechanism of the entire language.

{:ok, user} = fetch_user(42)          # binds user, or CRASHES if fetch returned {:error, _}
[first | rest] = [1, 2, 3]              # first = 1, rest = [2, 3]
%{name: n} = %{name: "Ada", age: 36}  # n = "Ada" (partial match is fine)

Functions dispatch on patterns. Instead of if/else chains, you write multiple clauses:

defmodule Shipping do
  def cost({:ok, %{weight: w}}) when w > 50, do: {:error, :too_heavy}
  def cost({:ok, %{weight: w}}),                do: {:ok, w * 1.5}
  def cost({:error, reason}),                   do: {:error, reason}
end

The runtime tries clauses top-to-bottom and runs the first whose pattern (and when guard) matches. This replaces most if, switch, and null-checking you'd write elsewhere. For inline branching there's case:

case HTTP.get(url) do
  {:ok, %{status: 200, body: body}} -> parse(body)
  {:ok, %{status: 404}}             -> {:error, :not_found}
  {:error, reason}                  -> {:error, reason}
end

QUIZ 1.1

After %{a: x} = %{a: 1, b: 2}, what is x?
Map patterns only require that the keys you mention exist and match. Extra keys are ignored. (Lists and tuples, by contrast, must match exactly in length.)

3. Data Transformation & the Pipe

With no mutation and no methods, all work is function calls transforming data. The pipe operator |> passes the previous result as the first argument of the next call β€” like method chaining, but for plain functions:

"the quick brown fox"
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.filter(&(String.length(&1) > 3))
|> Enum.join(" ")
# "Quick Brown"
"the quick brown fox"
  .split(" ")
  .map(w => w[0].toUpperCase() + w.slice(1))
  .filter(w => w.length > 3)
  .join(" ")
// "Quick Brown"

Enum is your Array.prototype + itertools: map, filter, reduce, group_by, sort_by, chunk_every… For lazy evaluation over large/infinite data, swap Enum for Stream (same API, computed on demand).

Deep dive: why linked lists?

Immutable data makes linked lists cheap: prepending [0 | list] creates one new cell that points at the existing (shared, immutable) tail β€” O(1), zero copying. Arrays would need full copies on every "change". This is why idiomatic Elixir builds lists by prepending and reverses at the end, and why Enum.at(list, 10_000) is a code smell.

4. The BEAM β€” a different kind of virtual machine

Everything so far was "nice functional language". Here's the reason Elixir exists. The BEAM (Erlang's VM, battle-tested since the 80s in telecom switches) has a concurrency model unlike Node's event loop or Python's threads+GIL:

BEAM VM (one OS process) Scheduler 1 (CPU core 1) run queue of processes Scheduler 2 (CPU core 2) preemptive, ~4k reductions Scheduler N (core N) work-stealing between cores Each process: β€’ own heap β€’ own GC β€’ own mailbox β€’ ~2 KB to start β€’ shares NOTHING β€’ crashes alone
Node.jsPython (threads)BEAM
Unit of concurrencycallbacks/promises on 1 loopOS threads (GIL-bound)processes (VM-level, not OS)
Cost per unitcheap, but shared state~MBs, expensive~2 KB β€” run millions
Schedulingcooperative (you yield)OS preemptiveVM preemptive β€” no task can hog a core
Shared memoryeverything sharedeverything shared + locksnothing shared β€” messages only
One task crashescan take down the processcan corrupt shared stateonly that process dies
Why preemption matters: in Node, a hot loop (while(true){}) freezes every request on the server. On the BEAM, the scheduler suspends any process after a budget of "reductions" (~function calls) and lets others run. An infinite loop in one process costs you one process, not the system. This is the property that makes "soft real-time" and predictable latency possible.

5. Processes: spawn, send, receive

A process is spawned from a function. It runs until the function returns, with its own isolated heap. Processes communicate only by sending messages to each other's mailbox β€” the actor model:

# spawn/1 returns a PID (process identifier)
pid = spawn(fn ->
  receive do                          # blocks until a message matches
    {:ping, from} -> send(from, :pong)
  end
end)

send(pid, {:ping, self()})            # self() = my own PID; send never blocks

receive do
  :pong -> IO.puts("got pong!")
after 1000 -> IO.puts("timeout")     # receive can time out
end

Key facts about the mailbox model:

send is asynchronous and never fails β€” even to a dead PID (the message is dropped). receive pattern-matches against the mailbox: it scans queued messages for the first match and blocks if none match. Messages between processes are copied (isolation is physical, not just conventional). And state lives in recursion β€” a "stateful" process is just a function that loops, passing new state to itself:

defmodule Counter do
  def loop(count) do
    receive do
      :increment      -> loop(count + 1)     # "new state" = recurse with new arg
      {:get, caller}  -> send(caller, count); loop(count)
    end
  end
end

pid = spawn(Counter, :loop, [0])
send(pid, :increment)
send(pid, :increment)
Mindset shift #2: this recursion loop IS the secret of Elixir state. Every GenServer, every Phoenix channel, every LiveView you'll ever write is this pattern with nicer clothes: an immutable value threaded through an infinite receive loop, one message at a time. No locks, no races β€” a process handles exactly one message at a time by construction.

QUIZ 1.2

Process A sends a message to process B, but B's receive has no clause matching it. What happens?
Unmatched messages stay queued in the mailbox (a classic source of memory leaks in hand-rolled processes β€” one reason GenServer, which handles every message, exists).

Isolation means a crash is contained. But often you want to know when another process dies. Two mechanisms:

linkmonitor
Directionbidirectionalone-way (observer watches target)
On deathlinked process is also killed (exit signal propagates)observer receives a :DOWN message
Escape hatchProcess.flag(:trap_exit, true) converts kill signals into messagesβ€”
Use for"these processes live and die together""tell me if that thing dies"
# Links: crash propagates
spawn_link(fn -> raise "boom" end)   # ...and now the caller crashes too

# Monitors: crash becomes data
{pid, ref} = spawn_monitor(fn -> raise "boom" end)
receive do
  {:DOWN, ^ref, :process, ^pid, reason} -> IO.inspect(reason)
end

This is the foundation of Erlang's famous "let it crash" philosophy. Instead of defensive programming β€” try/catch around everything, handling states that "should never happen" β€” you write code for the happy path and let broken processes die. A supervisor process (linked, trapping exits) notices and restarts them fresh. Corrupted state is thrown away; clean state is rebuilt. That's Book 2.

Common misreading: "let it crash" does not mean "don't handle errors". Expected failures (user typos, 404s) are handled as values: {:error, reason}. It's the unexpected failures β€” bugs, corrupted state, hardware weirdness β€” that you let crash, because a restart from known-good state beats limping along corrupted.

QUIZ 1.3

You want a worker process restarted automatically if it dies. What's the right primitive under the hood?
Monitors would work but links+trap_exit is the actual supervisor mechanism: the supervisor is linked to children, traps exits so it doesn't die with them, and restarts on the exit message. try/rescue can't catch another process crashing at all.

Recap

Elixir the language: immutable data, pattern matching as control flow, pipelines of pure functions. Elixir the runtime: millions of ~2 KB isolated processes, preemptively scheduled across all cores, sharing nothing, communicating by message, crashing alone. Now open the notebook and make some processes.