collections — lists & maps

Mix has two compound data types: the list (an ordered, 0-indexed sequence) and the map (an ordered key→value table, insertion order preserved). Both are first-class values — store them in variables, nest them, pass them to functions, return them, iterate them, and serialise them with data. Most list + map builtins live in the list and map categories — list them live with mix builtins list / mix builtins map — but a few multi-type ones (contains, reverse, length/len) sit in the string category since they also operate on strings, so mix builtins list won't show every name used here. Get one-line help for any name with mix what NAME.

One-line model: lists are [a, b, c] 0-indexed (negatives count from the end, for reads AND writes); maps are {key: value} read with .key or ["key"]. push/pop/shift and index-assignment mutate in place; everything else returns a fresh copy. Lists pass into functions by value, and List == List raises — use deep_eq(a, b).

Literals & printing

$nums = [10, 20, 30]
$user = { name: "ada", age: 36 }
print("" .. $nums)
print("" .. $user)
print("" .. [])
print("" .. {})
[10, 20, 30]
{name: ada, age: 36}
[]
{}

A list literal is [] with ,-separated elements; a map literal is {} with key: value pairs. Map keys are bare identifiers, keyword lexemes, or string literals{ name: "ada" } and { "name": "ada" } are the same map. Since 0.21 a keyword is accepted as a bare key wherever it is unambiguously a name: { label: 1, to: "x", on: true, parse: 2 } all parse, and the same keywords work in dot-field reads and writes ($m.to, $m.label = 2). The one exception is fn — it lexes to the same token as function, so it needs the quoted key { "fn": 1 } and bracket access $m["fn"] (.fn is a parse error).

Map keys are always strings. A bare number is not a valid literal key — { 22: "ssh" } is a parse error; quote it ({ "22": "ssh" }). Indexing with a number coerces to the key's string form, so $m[22] = "ssh" stores key "22" and $m[22] / $m["22"] both read it back.

Elements and values are any Mix value, so nesting is free:

$cfg = { server: { host: "node1", ports: [80, 443] }, tls: true }
print("" .. $cfg)
{server: {host: node1, ports: [80, 443]}, tls: true}

Note the print form is debug-ish, not round-trippable: strings inside a container print without quotes ([a, b], not ["a", "b"]). For a parseable representation use data_encode / json_encode.

Indexing

Lists are 0-indexed. A negative index counts from the end (-1 is the last element). An out-of-range index returns nil (never an error) — the same three rules hold for a string (yielding a one-character string) and, since v0.64.0, for bytes/buffer (yielding the byte as a number 0-255; see io):

$l = [10, 20, 30]
print($l[0])
print($l[2])
print($l[-1])
print($l[5])
10
30
30
nil

Maps are read with dot ($m.key) or bracket ($m["key"]) syntax; they are equivalent. Use the bracket form when the key is computed or held in a variable, or when it isn't a bare identifier. A missing key returns nil:

$m = { name: "ada", age: 36 }
print($m.name)
print($m["age"])
print($m.missing)
$k = "name"
print($m[$k])
ada
36
nil
ada

The "*" wildcard key

If a map has a key "*", an indexing miss falls back to that value instead of nil. Handy for default/catch-all tables (e.g. a port→service map — note the quoted numeric keys, and that the numeric index 80 coerces to the string key "80"):

$svc = { "22": "ssh", "80": "http", "*": "unknown" }
print($svc[80])
print($svc[12345])
http
unknown

Footgun: pos is 1-based, indexing is 0-based

index_of (and list/substr indexing) are 0-based; the string-search builtin pos is 1-based and returns 0 for "not found". Don't mix them up:

$fruits = ["apple", "pear", "plum"]
print("index_of (0-based): " .. ("" .. index_of($fruits, "pear")))
print("index_of miss: " .. ("" .. index_of($fruits, "fig")))
print("pos (1-based): " .. ("" .. pos("pear", join($fruits, " "))))
index_of (0-based): 1
index_of miss: -1
pos (1-based): 7

index_of(list, value) → 0-based index, or -1 when absent. pos(needle, haystack) is a string op (see strings) — 1-based, 0 when absent.

Iterating

for each walks a list directly. Over a map with ONE variable it yields the keys (in insertion order) — for each $k in $m and for each $k in keys($m) are equivalent; look values up with $m[$k]. With two variables it yields (key, value) since 0.68.0, so no lookup is needed:

$ports = [22, 80, 443]
for each $p in $ports
  print("- port " .. ("" .. $p))
end

$m = { host: "node1", port: 8080, tls: true }
for each $k in $m
  print($k .. " = " .. ("" .. $m[$k]))
end
- port 22
- port 80
- port 443
host = node1
port = 8080
tls = true

Over a string for each yields one-character strings, and since v0.64.0 over a bytes/buffer it yields the bytes as numbers 0-255 (see io). Every source is materialised at loop entry, so mutating it in the body never extends the loop.

A second variable binds the position for a list, string or bytes, and the value for a map — see control-flow for the 0.68.0 change and what stayed put.

For value-producing transforms prefer a hof (map/filter/reduce/ sort_by) over a hand-rolled for each + push.

Length & emptiness

length (alias len) returns the element count for a list and the key count for a map (for a string it counts codepoints — see strings; for a bytes/buffer it counts bytes, v0.64.0 — see io). is_empty tests for zero length:

print("" .. length([10, 20, 30]))
print("" .. len({ a: 1, b: 2 }))
print("" .. is_empty([]))
print("" .. is_empty({ a: 1 }))
3
2
true
false

Mutating builtins (in place)

Three builtins mutate the list in place. push appends and returns nil; pop/shift remove and return the removed element (or nil on an empty list).

$l = [1, 2, 3]
push($l, 4)
print("after push: " .. ("" .. $l))
print("pop -> " .. ("" .. pop($l)))
print("shift -> " .. ("" .. shift($l)))
print("now: " .. ("" .. $l))
after push: [1, 2, 3, 4]
pop -> 4
shift -> 1
now: [2, 3]

Footgun: what push mutates depends on what you hand it

push/pop/shift reach the list through the variable slot. That makes their behaviour shape-dependent, and it is the single most-tripped-over corner in Mix:

  • First argument is a bare variable (push($l, 3)) → mutates $l in place, and push returns nil. So $l = push($l, 3) ✗ clobbers $l to nil. Call it as a statement: push($l, 3) ✓.
  • First argument is anything elsepush($m["a"], 3), push($m.a, 3) — there is no slot to mutate, so it appends to a copy and returns the new list. A bare push($m["a"], 3) therefore does nothing at all: the write is lost, silently. Assign the result back instead: $m["a"] = push($m["a"], 3) ✓.

Since 0.33.0 mix lint catches the dead form as MIX-E1501, and the same release lets you skip the idiom entirely for maps of maps by writing the nested assignment directly (see Nested assignment).

$l = [1, 2]
print("push returns: " .. ("" .. push($l, 3)))
$m = { a: [1] }
$m["a"] = push($m["a"], 2)
print("" .. $m)
push returns: nil
{a: [1, 2]}

Index-assignment

Assigning to an index mutates in place. Maps auto-create the key (so this is how you build a map incrementally) — via bracket ($m["a"] = 1) or single-level dot ($m.b = 2), which are equivalent. List writes resolve signed indices exactly like reads: a negative index writes from the end ($l[-1] = v sets the last element):

$m = {}
$m["a"] = 1
$m.b = 2
print("" .. $m)

$l = [10, 20, 30]
$l[1] = 99
$l[-1] = 77
print("" .. $l)
{a: 1, b: 2}
[10, 99, 77]

Out-of-range and mistyped list writes are loud errors (since 0.20.5 — they were previously silent no-ops, and a negative write corrupted element 0):

  • an out-of-range index (either direction) raises list index 7 out of range (length 3) — a lost write is data loss; use push to grow a list;
  • only a number indexes a list on write — $l["a"] = v raises cannot index list with string; a NaN/infinite index is rejected too;
  • index-assigning into a scalar ($x = 5 then $x[0] = 1) raises cannot index-assign into number — and since 0.33.0 the dot form raises too ($x.f = 1 previously discarded the write in silence).

This is a deliberate read/write asymmetry: a missing read still returns nil (safe default), a missing write errors (silent data loss).

Nested assignment

Since 0.33.0 an assignment target may carry any number of accessors, in any mix of .field and ["key"], over maps and lists alike:

$m = {}
$m["u"]["k"] = 1
$m.a.b.c = "deep"
$l = [[1, 2]]
$l[0][1] = 99
print("" .. $m)
print("" .. $l)
{u: {k: 1}, a: {b: {c: deep}}}
[[1, 99]]

This is not sugar for the old read-mutate-write-back dance: it walks the path and mutates in place, so building a map of maps is linear where the workaround was quadratic (it deep-copied the inner container on every write).

A missing or nil intermediate is auto-created as a map — but only when the next accessor is a name or a string key, which is the only case where the intent is unambiguous:

  • $m["g1"]["k"] = 1 on an empty $m creates $m["g1"] = {} first ✓
  • $m["a"][0] = 1 raises: [0] could mean a list slot or the map key "0", and guessing would freeze one interpretation into your data. Assign an explicit [] or {} first.

Nothing else is auto-created, and nothing is auto-overwritten:

  • an existing scalar intermediate raises ($m = { a: 5 } then $m["a"]["b"] = 1cannot index-assign into number at $m[a]) — a map is never dropped on top of your data;
  • lists never extend, at any depth — $m["a"][5] = 9 on a 1-element list raises, exactly as a single-level write does;
  • the "*" wildcard is a read fallback only: $m.missing.k = 1 creates missing, it does not mutate $m["*"].

A failed nested write creates nothing. The path is validated before it is touched, so a write that dies halfway leaves no half-built maps behind — which matters because catch can resume after it.

Pure list builtins (return a copy)

Every builtin below leaves its argument unchanged and returns a new list.

sort / unique / reverse

print("" .. sort([3, 1, 20, 2]))
print("" .. unique([1, 2, 2, 3, 1]))
print("" .. reverse([1, 2, 3]))
[1, 2, 3, 20]
[1, 2, 3]
[3, 2, 1]

sort orders a homogeneous number list numerically; everything else is lexicographic. A list whose every element is a number sorts by value (sort([2, 10, 1, 20])[1, 2, 10, 20]). A list of strings — or a mixed list — compares by the string form of each element instead, so sort([2, "a", 1])[1, 2, a]. For any other key (a map field, a computed value, descending order) use sort_by:

print("numbers:  " .. ("" .. sort([2, 10, 1, 20])))
print("strings:  " .. ("" .. sort(["banana", "apple", "cherry"])))
print("by field: " .. ("" .. sort_by([{p: 80}, {p: 22}], fn($r) = $r["p"])))
numbers:  [1, 2, 10, 20]
strings:  [apple, banana, cherry]
by field: [{p: 22}, {p: 80}]

unique removes duplicates keeping first-seen order. reverse works on a list, a string, or (since v0.70.0) a bytes/buffer value (byte-wise, returning a new value-semantic bytes).

contains / index_of

$l = ["x", "y", "z"]
print("" .. contains($l, "y"))
print("" .. contains($l, "q"))
print("" .. index_of($l, "z"))
print("" .. index_of($l, "q"))
true
false
2
-1

contains(list, v) → bool (it also accepts a string haystack, and since 0.70.0 a bytes/buffer subject with a byte needle — but not a map; use has_key for maps). index_of(list, v) → 0-based position or -1 (also string and, since 0.70.0, bytes/buffer — see io for the byte-unit rules).

range

range(start, end) is inclusive of both ends, with an optional step. Step direction must match the start→end direction or you get an empty list. A step of 0, or a range over 10M elements, errors.

Bounds and step must be whole numbers within the i64 range (fractional, non-finite, or beyond ±2^63 raises VALUE_OUT_OF_RANGE naming the argument). Before 0.59.0 an oversized bound silently saturated: range(1e30, 1e30) answered [9223372036854775807] — a value the caller never wrote.

All supplied bounds and the optional step must coerce to numbers. Numeric strings remain valid (range("1", "3")[1, 2, 3]); an unparseable value raises TYPE_MISMATCH with its argument position instead of silently becoming 0 (or 1 for step).

print("" .. range(1, 5))
print("" .. range(0, 10, 2))
print("" .. range(5, 1, -1))
print("" .. range(5, 1))
[1, 2, 3, 4, 5]
[0, 2, 4, 6, 8, 10]
[5, 4, 3, 2, 1]
[]

flat

flat recursively flattens nested lists into one flat list:

print("" .. flat([1, [2, 3], [4, [5, 6]]]))
[1, 2, 3, 4, 5, 6]

concat

concat($a, $b, …) joins 2+ lists into one new list, one level deep — unlike flat, it does not recurse into nested elements, so a list of lists (or of maps) is concatenated without disturbing the inner structure:

print("" .. concat([1, 2], [3, 4], [5]))
print("" .. concat([[1, 2]], [[3, 4]]))
[1, 2, 3, 4, 5]
[[1, 2], [3, 4]]

It returns a fresh list and mutates nothing — the clean, O(total) way to accumulate a sequence across helpers under Mix's by-value list model: a helper returns its items, the caller concats them in, sidestepping the push($param, …)-into-a-copy trap entirely. Each argument must be a list (a non-list arg is a loud error); pass at least two.

slice / take / drop

slice(list, start[, end]) returns the half-open range [start, end). End is exclusive; omit it (or pass nil) to slice to the end. Negative indices count from the end, and all bounds clamp (out-of-range never errors):

$l = [10, 20, 30, 40, 50]
print("" .. slice($l, 1, 3))
print("" .. slice($l, -2))
print("" .. slice($l, -100, 100))
[20, 30]
[40, 50]
[10, 20, 30, 40, 50]

slice also accepts a string (slice("hello", 1, 3)"el", counting codepoints — see strings) and, since v0.64.0, a bytes/buffer value (counting bytes, returning a new value-semantic bytes — see io). A reversed range is empty in every case: slice($l, 3, 1) is [], not a wrap-around.

take(list, n) keeps the first n (negative nlast |n|); drop(list, n) skips the first n (negative n → drops the last |n|). Both also accept a string (codepoints) and, since v0.70.0, a bytes/buffer value (bytes → a new value-semantic bytes):

$l = [10, 20, 30, 40, 50]
print("take 2:   " .. ("" .. take($l, 2)))
print("take -2:  " .. ("" .. take($l, -2)))
print("drop 2:   " .. ("" .. drop($l, 2)))
print("drop -2:  " .. ("" .. drop($l, -2)))
take 2:   [10, 20]
take -2:  [40, 50]
drop 2:   [30, 40, 50]
drop -2:  [10, 20, 30]

zip

zip(a, b) pairs corresponding elements into 2-element lists, stopping at the shorter input. Destructure each pair with [0] / [1]:

print("" .. zip([1, 2, 3], ["a", "b"]))
for each $pair in zip(["host", "port"], ["node1", 8080])
  print($pair[0] .. " = " .. ("" .. $pair[1]))
end
[[1, a], [2, b]]
host = node1
port = 8080

Pure map builtins

keys / values / has_key

$m = { host: "node1", port: 8080, tls: true }
print("" .. keys($m))
print("" .. values($m))
print("" .. has_key($m, "port"))
print("" .. has_key($m, "user"))
[host, port, tls]
[node1, 8080, true]
true
false

keys/values return lists in insertion order. has_key(map, key) is the map-membership test — remember contains does not accept a map and will raise contains() expects a string, list, bytes or buffer.

merge / delete

Both are non-mutating — they return a new map and leave the original alone. merge(a, b) overlays b onto a (b wins on conflicts); delete(map, key) returns a copy without that key.

$base = { host: "node1", port: 8080 }
$out = merge($base, { port: 9090, tls: true })
print("merged: " .. ("" .. $out))
print("original: " .. ("" .. $base))
print("deleted:  " .. ("" .. delete($base, "port")))
print("still:    " .. ("" .. $base))
merged: {host: node1, port: 9090, tls: true}
original: {host: node1, port: 8080}
deleted:  {host: node1}
still:    {host: node1, port: 8080}

List == List raises (0.68.0) — use deep_eq

== and != raise TYPE_ERROR when both operands are a map or list — including a self-compare ($a == $a) and a list-vs-map cross-compare.

Until 0.68.0 they answered false/true instead — always, regardless of contents. That is not a comparison; it is a constant wearing a comparison's clothes, and it read as working code right up until someone depended on it. The raise names the fix:

TYPE_ERROR: `==` is not defined for list and list — it would always answer
false, not compare them. Use deep_eq(a, b) for structural comparison

Use deep_eq(a, b) (0.63.0) — structural equality: maps compare by key set with insertion order ignored, lists elementwise in order, scalars as ==:

print(deep_eq([1, {x: 2}], [1, {x: 2}]))   -- true
print(deep_eq({a: 1, b: 2}, {b: 2, a: 1})) -- true (map order ignored)
print(deep_eq([1, 2], [2, 1]))             -- false (list order matters)

Scalars compare as ==, with the corners that implies: a function value is never equal — even to itself, so a callback-bearing map is not deep_eq its own copy — and Buffer-vs-Bytes is false (freeze() first). Nesting deeper than 512 levels raises (catchable) rather than recursing unboundedly.

Or compare a derived value (length, join, an element) — those are scalars, so == applies normally:

$a = [1, 2, 3]
$b = [1, 2, 3]
print("deep_eq:       " .. ("" .. deep_eq($a, $b)))
print("same length:   " .. ("" .. (length($a) == length($b))))
print("same joined:   " .. ("" .. (join($a, ",") == join($b, ","))))
deep_eq:       true
same length:   true
same joined:   true

A collection compared to a SCALAR still answers — $m[$k] == nil works

Only collection-vs-collection raises. A same-type false misleads — it invites the reading "these two lists differ", which is not what was computed. A collection and a scalar differ by type, so false there is truthful, exactly as 1 == "a" is honestly false:

$reg = {}
print($reg["k"] == nil)   -- true  (key absent)
$reg.k = [1, 2]
print($reg["k"] == nil)   -- false (key present, value is a list)
print([1] == "text")      -- false
print([1] != nil)         -- true

That first pattern is the whole reason for the narrower rule: $map[$key] == nil is the key-absence idiom, and the value on the left is a list or map precisely when the key IS populated. Raising there would fail data-dependently — the same line working until the map fills up.

The raise is scoped to the OPERATORS — searching builtins are unchanged

contains, index_of, last_index_of and unique compare elements internally, without going through ==. They do not raise — and they also still treat two equal-looking collections as different:

print(contains([{a: 1}], {a: 1}))      -- false, NOT a raise
print(index_of([[1], [2]], [2]))       -- -1
print(length(unique([[1], [1], [2]]))) -- 3 — no dedup of equal lists

That asymmetry is deliberate but worth knowing: since 0.68.0 == is loud about collections while these stay quiet. Scoping the change to the operators is what keeps contains($list, $scalar) — the overwhelmingly common use — working, and there is no deep_contains yet. When you need a structural search, drive it yourself:

$found = false
for each $item in $haystack
  if deep_eq($item, $needle) then $found = true end
end

Footgun: lists pass into functions by value

A list (or map) handed to a function is copied in. Mutating the parameter — push, pop, index-assignment — changes only the function's local copy; the caller's value is untouched. The idiom is pass in → return the new value → reassign at the call site (the triad — see functions):

function with_tail($list)
  push($list, 99)
  return $list
end

$xs = [1, 2, 3]
$ys = with_tail($xs)
print("caller (unchanged): " .. ("" .. $xs))
print("returned:           " .. ("" .. $ys))
$xs = with_tail($xs)
print("after reassign:     " .. ("" .. $xs))
caller (unchanged): [1, 2, 3]
returned:           [1, 2, 3, 99]
after reassign:     [1, 2, 3, 99]

Quick reference

LISTS
[a, b, c]            list literal (0-indexed)
$l[i]   $l[-1]       index READ (negative from end; OOB -> nil)
$l[i] = v            index WRITE (negative from end; OOB/non-number = ERROR; grow with push)
length($l) / len     element count
is_empty($l)         true when zero length
push($l, v)          append, MUTATES, returns nil
pop($l) / shift($l)  remove last / first, MUTATES, returns element (nil if empty)
sort($l)             all-number list→numeric; else lexicographic (sort_by for keys)
unique($l)           dedupe, first-seen order
reverse($l)          reversed copy
contains($l, v)      bool membership
index_of($l, v)      0-based pos, or -1
range(a, b[, step])  inclusive both ends
flat($l)             recursively flatten nested lists
concat($a, $b, …)    join 2+ lists into one new list (one level)
slice($l, s[, e])    half-open [s, e); negatives + clamp; reversed range = empty
                     (also string -> string, bytes/buffer -> bytes, v0.64.0)
take($l, n) drop($l, n)   first/last n  (negative n flips the end)
zip($a, $b)          element-wise [a, b] pairs, min length

MAPS
{k: v, "x": w}       map literal (insertion-ordered; keys are STRINGS; keywords ok bare, "fn" not)
$m.key  $m["key"]    read (missing -> nil; "*" key = wildcard fallback)
$m["key"] = v        assign / auto-create key (dot form $m.key = v equivalent, one level only)
keys($m) values($m)  lists, insertion order (for each $k in $m iterates keys)
has_key($m, k)       membership (NOT contains — that errors on a map)
merge($a, $b)        new map, b wins on conflicts (non-mutating)
delete($m, k)        new map without k       (non-mutating)
length($m)           key count

See also

  • hofmap / filter / reduce / sort_by / group_by / zip-style transforms over collections (and the numeric sort_by that fixes lexical sort)
  • functions — the pass-by-value triad and closures
  • stringssplit (string → list), join (list → string), pos, codepoint vs byte semantics
  • datadata_encode, json_parse / json_encode, jq for round-trippable serialisation
  • keywords — the keyword list, and which lexemes double as bare map keys / fields
  • builtins index — every builtin by category
mix builtins list     list every list builtin with its one-line description
mix builtins map      list every map builtin
mix what NAME         one-line description of a single builtin
mix help              the full categorized builtin reference

Source of truth: the Mix repo at https://github.com/markc/cosmix (verified against mix 0.21.2 — the binary is the oracle).