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.
book1-elixir-and-beam.livemd in Livebook and run every snippet as you read. The notebook has exercises this page only hints at.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!"| Elixir | Closest JS/Python idea | Notes |
|---|---|---|
:ok, :error (atoms) | string enums / symbols | Constants whose value is their own name. Used everywhere. |
{:ok, value} (tuple) | [tag, value] pair | Fixed-size, contiguous. The standard success/failure envelope. |
[1, 2, 3] (list) | array / list | Linked list! Prepend is O(1), index is O(n). |
%{name: "Ada"} (map) | object / dict | Keyβvalue. map.name for atom keys, map["k"] for others. |
%User{name: "Ada"} (struct) | typed object / dataclass | A map with a fixed set of keys, defined in a module. |
"hello" (binary) | string | UTF-8 binaries. Concatenate with <>. |
fn x -> x * 2 end | arrow function / lambda | Shorthand: &(&1 * 2). Call with f.(2). |
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.= 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
%{a: x} = %{a: 1, b: 2}, what is x?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).
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.
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:
| Node.js | Python (threads) | BEAM | |
|---|---|---|---|
| Unit of concurrency | callbacks/promises on 1 loop | OS threads (GIL-bound) | processes (VM-level, not OS) |
| Cost per unit | cheap, but shared state | ~MBs, expensive | ~2 KB β run millions |
| Scheduling | cooperative (you yield) | OS preemptive | VM preemptive β no task can hog a core |
| Shared memory | everything shared | everything shared + locks | nothing shared β messages only |
| One task crashes | can take down the process | can corrupt shared state | only that process dies |
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.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)
receive has no clause matching it. What happens?Isolation means a crash is contained. But often you want to know when another process dies. Two mechanisms:
link | monitor | |
|---|---|---|
| Direction | bidirectional | one-way (observer watches target) |
| On death | linked process is also killed (exit signal propagates) | observer receives a :DOWN message |
| Escape hatch | Process.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.
{: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.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.