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.keyor["key"].push/pop/shiftand index-assignment mutate in place; everything else returns a fresh copy. Lists pass into functions by value, andList == Listraises — usedeep_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$lin place, andpushreturnsnil. So$l = push($l, 3)✗ clobbers$ltonil. Call it as a statement:push($l, 3)✓. - First argument is anything else —
push($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 barepush($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; usepushto grow a list; - only a number indexes a list on write —
$l["a"] = vraisescannot index list with string; a NaN/infinite index is rejected too; - index-assigning into a scalar (
$x = 5then$x[0] = 1) raisescannot index-assign into number— and since 0.33.0 the dot form raises too ($x.f = 1previously 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"] = 1on an empty$mcreates$m["g1"] = {}first ✓$m["a"][0] = 1raises:[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"] = 1→cannot 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] = 9on a 1-element list raises, exactly as a single-level write does; - the
"*"wildcard is a read fallback only:$m.missing.k = 1createsmissing, 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]
sortorders 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, sosort([2, "a", 1])→[1, 2, a]. For any other key (a map field, a computed value, descending order) usesort_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 n → last |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
- hof —
map/filter/reduce/sort_by/group_by/zip-style transforms over collections (and the numericsort_bythat fixes lexicalsort) - functions — the pass-by-value triad and closures
- strings —
split(string → list),join(list → string),pos, codepoint vs byte semantics - data —
data_encode,json_parse/json_encode,jqfor 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).