jonnifyfsharp

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.

C4.04.2 · part 1

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 k set to v (overwriting any prior value). O(log n).
Map.tryFind k
returns Some v or None — a miss is a value, not an exception.
Map.containsKey k
a boolean presence test. Map.count gives the size.

A new map every write — and the old one stays

This is the property that makes an immutable map practical. Adding a key does not mutate the map; it returns a new one. Below, before holds one key; after adds a second. Step the write: the diagram shows the new map's root spawned (violet), the shared node kept (the old node, not copied), and the readout shows both maps' counts — before stays at 1.

after = Map.add "k2" 2 before · the old map is untouched

before "k1" → 1 after "k2" → 2 SHARED — not copied "k1" → 1
before this write, before holds "k1" (count 1). Press “add” to build after.
let before = Map.empty |> Map.add "k1" 1
let after  = before |> Map.add "k2" 2

Map.count before    // val it : int = 1   (before is untouched)
Map.count after     // val it : int = 2
// after shares before's nodes; only the root-to-change path is rebuilt — O(log n)

A write is a fork, not a mutation. The new map is the old one plus a rebuilt path; the rest is shared. That is why holding a snapshot, or retrying a transition, is cheap and safe.

C4.04.2 · part 2

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 "..."
The efficiency note for the parity: both languages' maps are immutable and persistent, so a write shares the unchanged structure in both. The shape differs — Elixir's is a 32-way HAMT (O(1) average; the next two C4 modules build it), F#'s default 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

Related in this course