Lists & maps
Coming from Python / Node? Hale has no built-in
list/[]that grows, nodict/{}, noVec<T>orMap<K,V>. Instead you declare a small locus and annotate it with a form —@form(vec)for a growable list,@form(hashmap)for a keyed map. You get the same operations (push,get,len,set, …); they’re just methods on a locus you named.
A growable list — @form(vec)
@form(vec)
locus Names {
capacity { heap items of String; }
}
fn main() {
let names = Names { };
names.push("Ada");
names.push("Grace");
println(names.len()); // 2
let first = names.get(0) or ""; // "Ada"
}
Three things are happening:
@form(vec)tells the compiler “this locus is a growable list.” It synthesizes the methods for you:push,get,set,pop,len,is_empty, and sorting.capacity { heap items of String; }is where the list’s storage lives. Read it as “this list holdsStrings.” The element type comes from here.getandpopare fallible — an index might be out of bounds — so you address them withor, just like any fallible call:let x = names.get(99) or "(missing)";
Iterate with for over the items:
for name in names.items {
println(name);
}
(The indexed while i < names.len() + .get(i) walk also works,
and is what you want when you need the index — but prefer .items
as the default: it reads better and, on hashmaps especially, it’s
dramatically faster. A hashmap walk via key_at(i)/entry_at(i)
rescans from slot 0 on every call — O(cap×len) for the whole walk —
while for e in m.entries visits each occupied slot once.)
The element type can be anything — a primitive, or one of your
own type records:
type Player { id: String; score: Int; }
@form(vec)
locus Roster {
capacity { heap players of Player; }
}
A keyed map — @form(hashmap)
A map keys entries by a field on the value itself — the key is
one of the record’s fields, named with indexed_by:
type Account { user: String; balance: Int; }
@form(hashmap)
locus Accounts {
capacity { pool entries of Account indexed_by user; }
}
fn main() {
let accts = Accounts { };
accts.set(Account { user: "ada", balance: 100 });
accts.set(Account { user: "grace", balance: 250 });
let a = accts.get("ada") or Account { user: "", balance: 0 };
println(a.balance); // 100
println(accts.has("grace")); // true
}
set(value)takes the whole record and reads the key out of itsindexed_byfield — there’s no separate key argument.get(key)andremove(key)are fallible (the key might be absent);has(key)returns a plainBool.- Keys are
IntorString.
This “the key is a field of the value” shape matches how keyed stores almost always look in practice — you rarely have a key that isn’t already part of the thing you’re storing.
A bounded queue — @form(ring_buffer)
When you want a fixed-size FIFO that drops the oldest entry once it’s full (recent-events buffers, sliding windows):
@form(ring_buffer, cap = 64)
locus Recent {
capacity { pool events of String; }
}
push returns a Bool — false when the buffer is full — so
you decide whether to drop or apply backpressure. pop is
fallible on empty.
A list inside a type — bounded[T; N]
The forms above are loci — whole entities with their own
lifecycle. A type is pure data, so it can’t hold one. What it CAN
hold (since 2026-07-02) is a bounded collection — a
fixed-capacity list laid out inline in the value:
type Message {
id: String;
tags: bounded[String; 32];
}
fn main() {
let msg = Message { id: "msg1" }; // tags starts empty —
// bounded fields can't be
// spelled in a literal
push(msg.tags, "urgent") or raise;
push(msg.tags, "billing") or raise;
for tag in msg.tags {
println(tag);
}
println(count(msg.tags)); // 2
}
Six operations, all compiler intrinsics (types stay method-free,
like len(s)):
push(f, x)— append; fallible withCapacityError { cap, count }when full. What to do at capacity is your policy, written in theorarm.at(f, i)— read sloti; fallibleIndexErrorout of range.set(f, i, x)— overwrite a live slot; fallibleIndexError.count(f)— the live count (the capacity lives in the type).clear(f)— reset to empty.truncate(f, n)— shrink the count (never grows); withset, this is the drop-front idiom for FIFO windows.
Use bounded when the maximum is known and the list is a field of
a value — per-message tags, route parameters, a chat window. The
old workaround (a tab-separated string you re-parse on every read)
is retired: pond’s router, LLM, and conversation libraries all
migrated. Whole-struct copies carry the elements automatically, and
scalar-element bounded values even cross the zero-copy bus as flat
bytes.
Why a form instead of a generic type
A list isn’t just “a type parameterized by its element” — it’s a
bundle of decisions: contiguous memory, dynamic length, who owns
the storage, what happens to it when the owner goes away. A form
makes those decisions at the declaration, and picks an
implementation tuned for the element type. The upshot for you at
this level is simple: @form(vec) is your list, @form(hashmap)
is your map. The reasoning behind forms — and how to choose
between them on performance grounds — is in Forms under the
hood at the systems level.
One form per locus: a locus is a list or a map, not both. If you need both, that’s two loci — which is usually what the data wanted anyway.
Next: Records & data.