Dive C4.04.2
Immutable maps
Add a key and you get back a new map. The old one is untouched — and almost nothing was copied.
F#'s Map<'K,'V> is an immutable balanced search tree. Its
verbs map straight from the Elixir course's E4.04:
Map.add for Map.put, Map.tryFind for a
safe Map.get, Map.containsKey for membership. Two things define
it. First, Map.tryFind returns a 'V option —
Some v or None — so a missing key is a value, not an exception.
Second, and the heart of this dive: every write returns a new map, and the old one is never touched.
The new map shares the old one's tree nodes wherever it can, so a write rebuilds only the O(log n) path from
the root to the change — not a deep copy. Every echo below was produced by dotnet fsi.
The verbs — add, tryFind, containsKey
A map is built by adding key-value pairs. Map.add takes a key, a value, and a map, and
returns a map with the pair set. Map.tryFind looks a key up and returns an option, so
the caller handles "missing" as data. Map.containsKey answers presence as a boolean,
and Map.count reports the size.
let rows0 : Map<string, string> = Map.empty let rows1 = rows0 |> Map.add "JOB0a" "payload-a" let rows2 = rows1 |> Map.add "JOB0b" "payload-b" Map.tryFind "JOB0a" rows2 // val it : string option = Some "payload-a" Map.tryFind "JOB0z" rows2 // val it : string option = None Map.containsKey "JOB0b" rows2 // val it : bool = true Map.count rows2 // val it : int = 2
Map.tryFind returning an option is the idiom. The indexer
rows2.["JOB0a"] also exists but raises on a missing key, so it is for
cases where absence is a bug. Prefer tryFind and match on
Some / None.- Map.empty
- the empty map; the seed every map grows from. Its type is inferred from the first
add. - Map.add k v
- returns a new map with
kset tov(overwriting any prior value). O(log n). - Map.tryFind k
- returns
Some vorNone— a miss is a value, not an exception. - Map.containsKey k
- a boolean presence test.
Map.countgives the size.
Remove returns a new map too
Every transition follows the same rule. Map.remove returns a new map without the key,
leaving the source intact — so the value before a removal is still queryable.
let after = Map.empty |> Map.add "k1" 1 |> Map.add "k2" 2 let afterRemove = after |> Map.remove "k1" Map.containsKey "k1" afterRemove // val it : bool = false Map.containsKey "k1" after // val it : bool = true (after intact)
Elixir E4.04 — Map.put / Map.get
# Elixir — also immutable; put returns a new map rows = Map.put(rows, "JOB0a", payload) Map.get(rows, "JOB0a") # the payload (or nil)
F# — Map.add / Map.tryFind (verified in FSI)
// F# — immutable too; add returns a new map, tryFind is total let rows = rows |> Map.add "JOB0a" payload Map.tryFind "JOB0a" rows // val it : string option = Some "..."
Map is an O(log n) balanced tree — but the
no-mutation, share-on-write guarantee is the same.EchoMQ in F#
Status — echo_fs is an in-memory model. The EchoMQ protocol — the Lua transitions executing on a Valkey wire — is not implemented (there is no Lua and no Valkey client in the code); that is the coming-soon transport (EchoMQ.Connector). This block shows how the model uses the structure, not a running bus.
The F# EchoMQ port (echo_fs) keeps each queue's job rows in a
Map<string, Job>. Every transition returns a new QueueState
with a fresh rows map — the old one stays a valid, queryable snapshot. That is the immutable map this dive
teaches, in the live bus.
EchoMQ in F#
The rows field is a Map; admission writes a new one. The enqueue
transition — refuse a duplicate, else add the row — is quoted verbatim from
echo_fs/EchoMQ/Store.fs:
if st.Rows.ContainsKey id then st, Admission.Duplicate else let row = Job.create id payload { st with Rows = st.Rows.Add(id, row) Pending = st.Pending.Add id }, Admission.Enqueued
st.Rows.Add(id, row) produces a new map inside a new
QueueState; the caller's old state is untouched. The same shape, distilled and verified
in FSI:
let rows1 = (Map.empty: Map<string,string>).Add("JOB0a", "payload-a") let rows2 = rows1.Add("JOB0b", "payload-b") rows1.ContainsKey "JOB0b" // val it : bool = false (rows1 is the prior snapshot) rows2.ContainsKey "JOB0b" // val it : bool = true // each admission forks the rows map; the old one is still valid to query or retry
The in-memory Store keeps its rows in an immutable
Map today (echo_fs/EchoMQ/Store.fs,
Job.fs — built and tested). The Valkey transport (EchoMQ.Connector) lands next;
until then Connector.eval raises "EchoMQ Valkey connector: coming soon".
The map
An immutable Map: add / tryFind / remove, every write a new map that shares the old one.
The next dive
The same tree, without values, answers one question: is this element present? That is the Set, and the queue's pending ids.
Where this leads
The immutable map gives a safe lookup and a cheap fork on every write — the property the store leans on. The
next dive takes the Set: the same balanced tree holding keys without values, answering
membership and dedup, and the queue's pending ids.
References
Sources
- FSharp.Core API reference — the
Mapmodule (add,tryFind,containsKey,remove). - Microsoft — F# documentation — F# collection types and the map.
- Microsoft — .NET documentation — immutable collections on .NET.
Related in this course
- Elixir — Maps, sets & hashing (E4.04) —
Map.put/Map.geton the BEAM. - C4.04 — Maps, sets & hashing — the module hub.
- C4.04.3 — Sets — the next dive.
- C1.07 — Collections — list, array, seq, Map & Set in F#.