Chapter 11 Collections
SourceSilo's standard collections come in two flavours: containers — concrete data structures like Vec, HashMap, HashSet — and the traits that describe how to traverse and transform them (Foldable, Mappable, Filterable, Iterator). The traits are where most of the interesting code lives: the same .map, .filter, .fold methods work on every container that implements them.
This chapter covers the main containers, the traversal traits, and the typical pipelines you'll write with them.
Vec — length in the type
Vec is the workhorse sequential container. Its length can appear in the type, which lets the compiler track size invariants through chained operations.
There are three common ways to construct one:
1 2 3 vec/3 # variadic call — explicit count
1 2 3 vec/3 .collect # same, via a quotation of pushes
( Vec .default) # an empty (Vec a | 0)
Any quotation that pushes a statically-known number of values can be fed through .collect:
[ 1 2 3 4 5 ] .collect # (Vec Int | 5)
[ "a" "b" ] .collect # (Vec Str | 2)
The length shows up in the type, and the compiler tracks it through operations that preserve or combine lengths:
1 2 vec/2 3 4 vec/2 .append # (Vec Int | 4)
.first and .last retrieve the endpoints, always returning (Option elem):
1 2 3 vec/3 .first # ⌊Some(1)⌉
( Vec .default) .first # ⌊None⌉
Each has an unwrapping ! sibling whose signature includes +Panic — it panics on empty input. The interesting bit is that the +Panic gets elided when the compiler can prove the input is non-empty from its type alone:
1 2 3 vec/3 .first! # ⌊1⌉ — type encodes length 3, +Panic elided
some-vec .first! # needs +Panic; caller must discharge it
On a (Vec Int | 3) the panic branch is unreachable, so the compiler drops +Panic from the call's effect row. On an existential-length input like (Vec Int), emptiness is a runtime question and the caller inherits +Panic as normal.
Existential-length Vec
When a Vec's length comes from a runtime source — user input, an I/O result, another Vec of unknown size — the type (Vec elem) marks the length as existential: present at runtime, erased at the type level. Most library functions accept (Vec elem) so they compose with both length-indexed and existential-length Vecs.
HashMap and HashSet
Hash containers follow the standard conventions. Both are mutable (via .insert etc.) and both derive their capabilities from the element or key type implementing Eq and Hash.
( HashMap .default) pop-> m
"Alice" "name" m .insert pop-> m
30 "age" m .insert pop-> m
"name" m .lookup # ⌊Some("Alice")⌉
"email" m .lookup # ⌊None⌉
.lookup returns (Option val). .insert returns a pair of (updated map, displaced value if any), so you typically re-bind the map after each mutation.
Sets follow the same shape:
( HashSet .default) pop-> s
1 s .insert pop-> s
2 s .insert pop-> s
1 s .insert pop-> s # still { 1 2 } — duplicates ignored
For ordered variants (iteration order matters, range queries work, but hashing isn't required), reach for BTreeMap / BTreeSet. Same API, different complexity trade-offs.
Ranges
Ranges are first-class, not syntactic sugar. They're ordinary values implementing Foldable:
0 .. 10 [ "{}" format print ] .for-each
0 .. 100 0 [ + ] .fold # ⌊4950⌉
Half-open (a..b), from-only (a..), to-only (..b), and unbounded (..) all exist. An unbounded range isn't foldable — it has no end — but it's still useful in other contexts (slicing, pattern matching).
Traversal traits
The heavy lifting happens in four traits:
| Trait | Core methods | Semantics |
|---|---|---|
Foldable |
.fold, .for-each, .any, .all, .count |
Eager whole-collection |
Mappable |
.map, .flat-map |
Shape-preserving xform |
Filterable |
.filter |
Keep some, drop others |
Iterator |
.next |
Lazy, element-at-a-time |
Vec, HashMap, HashSet, BTreeMap, BTreeSet, Range, and Str all implement Foldable / Mappable / Filterable. Iterator is implemented by types that need lazy traversal — directory iterators, character iterators over strings, lookahead parsers — and is a sibling to Foldable rather than a replacement: you reach for Iterator when short-circuiting or statefulness matters, otherwise the Foldable surface is cleaner and more efficient.
See the traits chapter for how traits compose; see :impl Iterator in the standard library for the core pattern, and the pattern matching chapter for how (Option elem) returns interact with :let/:if-let.
Functional pipelines
The typical shape is: source -> transforms -> terminator.
1 2 3 4 5 6 7 8 9 10 vec/10
[ 2 * ] .map # ⌊[2 4 6 8 10 12 14 16 18 20]⌉
[ 10 ">" ] .filter # ⌊[12 14 16 18 20]⌉
0 [ + ] .fold # ⌊80⌉
.map preserves length and shape; .filter preserves shape but shortens; .fold terminates the pipeline into a scalar. The same pattern works with any Foldable / Mappable / Filterable source — replace vec/10 with a range, a HashSet, or a string-as-codepoint-sequence and the middle doesn't change.
Effects flow through naturally. A .map body with +Console just makes the whole pipeline +Console:
:fn chatty-double ( ( Vec Int ) -> ( Vec Int ) ) + Console
[ peek-> x x "doubling {}" format print x 2 * ] .map
:end
The .map method's signature is polymorphic over the quotation's effect row (see quotations), so it composes cleanly.
Building collections
.collect is the universal "materialise this pipeline into a collection" terminator. It works because output variadics let the quotation signal its element count to the runtime:
[ 1 2 3 ] .collect # (Vec Int | 3)
0 .. 100 [ 2 * ] .collect # (Vec Int | 100)
0 .. 10 [ peek-> n n 2 * ] .collect # (Vec (Pair Int Int) 10)
The target type (Vec, HashSet, HashMap, ...) is inferred from context. When context doesn't determine the target, name it explicitly with qualified dispatch:
[ 1 2 3 ] ( HashSet .collect) # (HashSet Int)
Unwrapping ! siblings
Several endpoint accessors come in two shapes: the Option-returning safe form and an !-suffixed unwrapping sibling.
| Safe form | Returns | Unwrapping sibling | Signature returns |
|---|---|---|---|
.first |
(Option elem) |
.first! |
elem, with +Panic |
.last |
(Option elem) |
.last! |
elem, with +Panic |
.min |
(Option elem) |
.min! |
elem, with +Panic |
.max |
(Option elem) |
.max! |
elem, with +Panic |
.reduce |
(Option elem) |
.reduce! |
elem, with +Panic |
The ! variants declare +Panic because they panic on empty input. They don't require a non-empty type — they work on any Vec — but when the call site's type proves the input is non-empty, the compiler elides +Panic from the caller's effect row. That's how you get the "unwrap freely when it's safe, see the panic effect when it isn't" behaviour without two separate method names or manual proof.
Use the safe form when you want to branch on emptiness explicitly; reach for the ! sibling when the type already makes emptiness impossible — you'll get the bare value and no effect-row cost.
Key points
Vectracks length in its type when it can; existential length is(Vec elem). Most library functions accept the existential form..collectis the idiomatic way to build aVec(or any target container) from a quotation of pushes. Output variadics let the compiler know the element count.HashMapandHashSetuse.insert/.lookup/.contains. Ordered siblings areBTreeMap/BTreeSet.- Ranges are ordinary values implementing
Foldable. Bounded ranges are foldable; unbounded ranges aren't. - Four traversal traits:
Foldable,Mappable,Filterable,Iterator. The first three are the eager surface;Iteratoris lazy, element-at-a-time. .first/.last/.min/.max/.reducealways return(Option elem). Their!siblings return the bare element with+Panicdeclared; the+Panicis elided when the input's type proves non-emptiness.
Next: strings and formatting — the string-specific API on top of the generic collection protocols.