Skip to content

Lists

Build, access, transform, aggregate, group, sort, and slice ordered growable sequences.

Updated View as Markdown

List[T] — almost always written []T — is an owned, growable sequence. It is the workhorse collection: the higher-order surface (map, filter, fold, sorted_by, …) lives on it directly, with no separate iterator type to opt into.

fn main(): void {
    mut names: []string = ["Ada", "Grace"]
    names.add("Lin")
    for name in names {
        println(name)
    }
}

Construction

A literal infers its element type from its contents. An empty literal needs context — an annotation, a parameter type, or a later add.

fn main(): void {
    mut empty: []int = []
    mut sized := List.with_capacity[int](64)
    mut fresh := List.new[string](8)
    original := [1, 2, 3]
    duplicate := List.copy(original)

    empty.reserve(16)
    sized.add(1)
    fresh.add("x")

    println("${empty.len()} of ${empty.capacity()}")
    println("${sized.len()} ${fresh.len()} ${duplicate.len()}")
}

List.new[T](capacity) and List.with_capacity[T](capacity) both create an empty list with room reserved; the capacity argument never changes the length. List.copy(other) produces an independent list and never picks a capacity smaller than the source. reserve(additional) grows the backing storage of an existing list without changing what len() reports.

Access returns an Option

Indexing is bounds-checked, and the result is an Option — xs[0] and xs.get(0) are the same expression with the same type.

fn main(): void {
    xs := [10, 20, 30]
    head: int? = xs.get(0)
    same: int? = xs[0]
    past_end: int? = xs[99]
    println("${head ?? -1} ${same ?? -1} ${past_end ?? -1}")
}

Treating the result as a bare value is a type error:

fn first(xs: []int): int {
    return xs[0]
}

Discharge it with ??, unwrap_or, or a match, depending on how much the absent case deserves to be said out loud:

fn total_or_zero(xs: []int): int {
    return xs.get(0) ?? 0
}

fn label(xs: []string): string {
    match xs.first() {
        Some(name) => return "first is ${name}"
        None => return "empty"
    }
}

fn main(): void {
    println("${total_or_zero([])} ${total_or_zero([7])}")
    println(label([]))
    println(label(["Ada"]))
}
Method Out-of-range behavior
get(index), xs[index] None
first(), last() None on an empty list
set(index, value) no operation — never extends the list
get_unchecked(index) caller must prove the index is valid

Index arguments use the Index alias declared in stubs/index.at, which is u32. ByteIndex and CharIndex are the same alias under different names, on []byte and string respectively. An int index therefore needs .to_u32(), and mixing an int into a range passed to slice is a type error.

fn head_unchecked(xs: []int): int {
    if xs.is_empty() { return 0 }
    return xs.get_unchecked(0u32)
}

fn at(xs: []int, position: int): int {
    return xs.get(position.to_u32()) ?? -1
}

fn main(): void {
    println("${head_unchecked([])} ${head_unchecked([5, 6])}")
    println("${at([5, 6], 1)} ${at([5, 6], 9)}")
}

Inspection methods are size, len (aliases), capacity, is_empty, and is_not_empty. Mutation is add, set, reserve, and clear. There is no push, pop, insert, or remove_at.

fn main(): void {
    mut xs := [1, 2, 3]
    xs.add(4)
    xs.set(0u32, 10)
    xs.set(99u32, 0)      // out of range: no-op, list unchanged
    println("${xs.len()} ${xs[0] ?? 0} ${xs.is_not_empty()}")
    xs.clear()            // length 0, capacity retained
    println("${xs.len()} of ${xs.capacity()}")
}

Membership uses contains, index_of, and last_index_of; the two position methods return Index?.

fn position(names: []string, wanted: string): int {
    match names.index_of(wanted) {
        Some(i) => return i.to_int()
        None => return -1
    }
}

fn main(): void {
    names := ["ada", "lin", "grace", "ada"]
    println("${position(names, "lin")} ${position(names, "zoe")}")
    println("${names.contains("ada")}")
    match names.last_index_of("ada") {
        Some(i) => println("last ada at ${i.to_int()}")
        None => println("no ada")
    }
}

Mapping and filtering

map produces a list of whatever the closure returns; filter keeps the elements the predicate accepts. Neither touches the receiver.

struct User { id: int, name: string, active: bool }

fn active_names(users: []User): []string {
    return users
        .filter(u => u.active)
        .map(u => u.name)
}

fn main(): void {
    users := [
        User { id: 1, name: "ada", active: true },
        User { id: 2, name: "lin", active: false },
        User { id: 3, name: "grace", active: true },
    ]
    for name in active_names(users) {
        println(name)
    }
}

flat_map maps each element to a list and concatenates the results, which is how you flatten a one-to-many relationship in a single pass. With the identity closure it is also the substitute for flatten(), whose declared signature (fn flatten(self): []T) cannot express the flattening it names.

struct Order { id: int, items: []string }

fn every_item(orders: []Order): []string {
    return orders.flat_map(o => o.items)
}

fn merge(pages: [][]int): []int {
    return pages.flat_map(page => page)
}

fn main(): void {
    orders := [
        Order { id: 1, items: ["bolt", "nut"] },
        Order { id: 2, items: ["nut", "washer"] },
    ]
    println("${every_item(orders).len()}")
    println("${every_item(orders).distinct().sorted().len()}")
    println("${merge([[1, 2], [3]]).len()}")
}

Closures accept either the arrow form or a brace block, which reads better for a multi-line body:

fn expensive(prices: []float): []float {
    return prices.filter { p =>
        adjusted := p * 1.2
        adjusted > 100.0
    }
}

fn main(): void {
    println("${expensive([50.0, 100.0, 200.0]).len()}")
}

Pipelines

Chaining is the point. A filter narrows, a sort orders, a window bounds, and a map renders — each step an ordinary list, each step readable on its own line.

struct User { name: string, active: bool, score: int }

fn join(parts: []string, separator: string): string {
    mut out := ""
    mut first := true
    for part in parts {
        if !first { out.append_string(separator) }
        out.append_string(part)
        first = false
    }
    return out
}

fn leaderboard(users: []User, top: int): string {
    return join(
        users
            .filter(u => u.active)
            .sorted_by_descending(u => u.score)
            .take(top)
            .map(u => "${u.name} (${u.score})"),
        ", ",
    )
}

fn main(): void {
    users := [
        User { name: "ada", active: true, score: 93 },
        User { name: "lin", active: false, score: 99 },
        User { name: "grace", active: true, score: 97 },
    ]
    println(leaderboard(users, 2))    // grace (97), ada (93)
}

That join helper appears throughout this page. join and join_of are declared on List but do not lower, so the separator bookkeeping is written out once and reused.

Folding and scanning

fold and reduce are the same operation under two names: both take an explicit initial accumulator and thread it left to right.

fn total(values: []int): int {
    return values.fold(0, (acc, v) => acc + v)
}

fn longest(names: []string): string {
    return names.reduce("", (best, name) => if name.length() > best.length() { name } else { best })
}

fn main(): void {
    println("${total([1, 2, 3])} ${longest(["a", "abc", "ab"])}")
}

A fold is not limited to scalars — it is the general way to build any summary value in one pass:

struct Stats { count: int, sum: float, max: float }

fn summarize(samples: []float): Stats {
    return samples.fold(
        Stats { count: 0, sum: 0.0, max: 0.0 },
        (s, v) => Stats { count: s.count + 1, sum: s.sum + v, max: s.max.max(v) },
    )
}

fn main(): void {
    s := summarize([1.5, 4.0, 2.25])
    println("${s.count} ${s.sum} ${s.max}")
}

scan returns every intermediate accumulator, with the initial value as its first element — so a scan of n elements has n + 1 results.

fn running_balance(deltas: []int): []int {
    return deltas.scan(0, (balance, d) => balance + d)
}

fn main(): void {
    for v in running_balance([10, -3, 5]) {
        println("${v}")       // 0, 10, 7, 12
    }
}

any, all, and none short-circuit as soon as the answer is known. find returns the first matching element as an Option, find_index returns its position, and count(predicate) is filter(...).len() without the intermediate list.

struct Ticket { name: string, done: bool, priority: int }

fn next_ticket(tickets: []Ticket): string {
    return tickets
        .find(t => !t.done)
        .map(t => t.name)
        ?? "nothing left"
}

fn main(): void {
    tickets := [
        Ticket { name: "deploy", done: false, priority: 9 },
        Ticket { name: "write docs", done: true, priority: 3 },
    ]
    println("${tickets.all(t => t.done)}")                       // false
    println("${tickets.any(t => !t.done && t.priority > 8)}")    // true
    println("${tickets.none(t => t.priority > 100)}")            // true
    println("${tickets.count(t => !t.done)} open")
    println(next_ticket(tickets))
    match tickets.find_index(t => !t.done) {
        Some(i) => println("blocked at ${i.to_int()}")
        None => println("clear")
    }
}

Enumerating

enumerate() and zip_with_index() both pair each element with its Index. In a for loop the pair destructures into two bindings; used as a value it stays a tuple with .0 and .1.

fn numbered(names: []string): []string {
    mut out: []string = []
    for i, name in names.zip_with_index() {
        out.add("${i + 1u32}. ${name}")
    }
    return out
}

fn main(): void {
    for line in numbered(["alpha", "beta"]) {
        println(line)
    }
    for pair in ["alpha", "beta"].enumerate() {
        println("${pair.0} -> ${pair.1}")
    }
}

zip(other) — pairing two different lists positionally — is declared but does not lower. Walk the shorter length by index:

fn scoreboard(names: []string, scores: []int): []string {
    mut out: []string = []
    n := if names.len() < scores.len() { names.len() } else { scores.len() }
    mut i := 0
    for i < n {
        name := names.get(i.to_u32()) ?? ""
        score := scores.get(i.to_u32()) ?? 0
        out.add("${name}: ${score}")
        i = i + 1
    }
    return out
}

fn main(): void {
    for row in scoreboard(["ada", "lin", "grace"], [93, 88]) {
        println(row)     // stops after two rows
    }
}

Aggregates

Method Result Requirement
sum() T T: Numeric
sum_of(f) R R: Numeric
average() float T: Numeric
min(), max() T? T: Comparable[T]
min_by(f), max_by(f) T? key K: Comparable[K]
min_of(f), max_of(f) K? key K: Comparable[K]

min and max are optional because an empty list has neither. The _by variants return the element with the extreme key; the _of variants return the key itself.

struct Product { name: string, price: float, stock: int }

fn inventory_report(products: []Product): string {
    total_value := products.fold(0.0, (acc, p) => acc + p.price * p.stock.to_float())
    mut cheapest := "n/a"
    match products.min_by(p => p.price) {
        Some(p) => cheapest = p.name
        None => {}
    }
    highest := products.max_of(p => p.price) ?? 0.0
    return "value=${total_value} cheapest=${cheapest} max=${highest}"
}

fn main(): void {
    products := [
        Product { name: "bolt", price: 0.25, stock: 400 },
        Product { name: "gear", price: 12.5, stock: 12 },
    ]
    println(inventory_report(products))
    println("${products.count(p => p.stock > 100)} well-stocked")
    println("${products.sum_of(p => p.stock)} units")
}

sum_of over an int projection lowers; over a float projection the backend currently miscompiles it, so use map(f).sum() or a fold for float totals, as above.

Ordering

sorted, sorted_descending, sorted_by, and sorted_by_descending all return new lists; the receiver is never reordered. sorted_by takes a key function, not a comparator.

struct Employee { name: string, dept: string, salary: int }

fn by_salary(staff: []Employee): []Employee {
    return staff.sorted_by_descending(e => e.salary)
}

fn main(): void {
    staff := [
        Employee { name: "lin", dept: "eng", salary: 180 },
        Employee { name: "ada", dept: "eng", salary: 210 },
    ]
    for e in by_salary(staff) {
        println("${e.name} ${e.salary}")
    }
    for e in staff.sorted_by(e => e.name) {
        println(e.name)
    }
}

sorted() and sorted_descending() sort elements directly, which needs Comparable[T]. Numbers and strings already have it; for your own types write the impl — @derive(Comparable) alone does not satisfy the bound.

struct Version { major: int, minor: int }

impl Comparable[Version] for Version {
    fn compare_to(self, other: Version): int {
        if self.major != other.major { return self.major - other.major }
        return self.minor - other.minor
    }
}

fn main(): void {
    vs := [
        Version { major: 1, minor: 4 },
        Version { major: 2, minor: 0 },
        Version { major: 1, minor: 12 },
    ]
    for v in vs.sorted() {
        println("${v.major}.${v.minor}")
    }
    match vs.sorted_descending().first() {
        Some(v) => println("newest ${v.major}.${v.minor}")
        None => println("none")
    }
}

reversed() returns an iterator rather than a list — walk it with for or collect it yourself.

fn countdown(xs: []int): []int {
    mut out: []int = []
    for v in xs.reversed() {
        out.add(v)
    }
    return out
}

fn main(): void {
    for v in countdown([1, 2, 3]) {
        println("${v}")
    }
}

Windows

take, take_last, take_while, skip, skip_last, and skip_while all return owned lists and clamp rather than failing when n exceeds the length.

fn main(): void {
    scores := [4, 9, 1, 7]
    println("${scores.sorted_descending().take(2).len()}")   // 2
    println("${scores.take_last(2).len()}")                  // 2
    println("${scores.skip(1).len()}")                       // 3
    println("${scores.skip_last(1).len()}")                  // 3
    println("${scores.take_while(v => v > 0).len()}")        // 4
    println("${scores.skip_while(v => v > 2).len()}")        // 3
    println("${scores.take(99).len()}")                      // 4 — clamped

    lines := ["# header", "# more", "body"]
    for l in lines.skip_while(l => l.starts_with("#")) {
        println(l)
    }
}

chunked(size) and windowed(size, step) are declared but do not lower. Both are short loops:

fn chunked[T](xs: []T, size: int): [][]T {
    mut out: [][]T = []
    if size <= 0 { return out }
    mut batch: []T = []
    for v in xs {
        batch.add(v)
        if batch.len() == size {
            out.add(batch)
            batch = []
        }
    }
    if batch.is_not_empty() { out.add(batch) }
    return out
}

fn moving_average(samples: []float, width: int): []float {
    mut out: []float = []
    if width <= 0 { return out }
    mut start := 0
    for start + width <= samples.len() {
        mut sum := 0.0
        mut i := start
        for i < start + width {
            sum = sum + (samples.get(i.to_u32()) ?? 0.0)
            i = i + 1
        }
        out.add(sum / width.to_float())
        start = start + 1
    }
    return out
}

fn main(): void {
    for batch in chunked([1, 2, 3, 4, 5], 2) {
        println("${batch.len()}")     // 2, 2, 1
    }
    for v in moving_average([1.0, 2.0, 3.0, 4.0], 2) {
        println("${v}")               // 1.5, 2.5, 3.5
    }
}

Slices

slice(range) returns Slice[T]?, a borrowed half-open view over the parent’s storage. It allocates nothing. Range endpoints are Index, so write them as u32.

fn middle_sum(xs: []int): int {
    match xs.slice(1u32..3u32) {
        None => return 0
        Some(view) => {
            head := view.first() ?? 0
            tail := view.last() ?? 0
            return head + tail + view.len()
        }
    }
}

fn owned_copy(xs: []int): []int {
    match xs.slice(0u32..2u32) {
        None => return []
        Some(view) => return view.to_list()
    }
}

fn main(): void {
    println("${middle_sum([1, 2, 3, 4])}")
    println("${middle_sum([1])}")              // 0 — range past the end
    println("${owned_copy([1, 2, 3]).len()}")
}

slice returns None when start > end or the end lies past the length. A Slice[T] supports get, get_unchecked, first, last, set, set_unchecked, size/len, is_empty, nested slice, and to_list() for an owned copy. It cannot grow.

Grouping and indexing

Operation Result Status
partition(predicate) ([]T, []T) — matching, then non-matching lowers
associate_by(key) Map[K, T] keyed by each element; last duplicate wins lowers
distinct() keeps the first occurrence lowers
to_set() Set[T] lowers
group_by(key) Map[K, []T] declared, no lowering path
associate(pair) Map[K, V] from returned (key, value) tuples declared, no lowering path
distinct_by(key) keeps the first occurrence per key declared, no lowering path

partition returns a tuple that destructures into two bindings:

struct Record { id: int, valid: bool }

fn split(records: []Record): string {
    good, bad := records.partition(r => r.valid)
    return "${good.len()} accepted, ${bad.len()} rejected"
}

fn main(): void {
    println(split([
        Record { id: 1, valid: true },
        Record { id: 2, valid: false },
        Record { id: 3, valid: true },
    ]))
}

associate_by builds an index for repeated lookup. associate — an arbitrary key/value map from one pass — does not lower, so write the loop:

struct Node { id: int, label: string }

fn labels(nodes: []Node): Map[int, string] {
    mut out: Map[int, string] = Map.new()
    for n in nodes {
        out.put(n.id, n.label)
    }
    return out
}

fn main(): void {
    nodes := [Node { id: 7, label: "root" }, Node { id: 9, label: "leaf" }]
    println(labels(nodes).get(7) ?? "missing")
    match nodes.associate_by(n => n.id).get(9) {
        Some(n) => println(n.label)
        None => println("missing")
    }
}

group_by does not lower either. When the group keys are few and the list is small, filtering per distinct key is the clearest substitute:

struct Sale { region: string, amount: float }

fn regional_totals(sales: []Sale): []string {
    mut out: []string = []
    for region in sales.map(s => s.region).distinct().sorted() {
        group := sales.filter(s => s.region == region)
        total := group.map(s => s.amount).sum()
        out.add("${region}: ${total} over ${group.len()} sales")
    }
    return out
}

fn main(): void {
    sales := [
        Sale { region: "emea", amount: 120.0 },
        Sale { region: "amer", amount: 80.0 },
        Sale { region: "emea", amount: 45.5 },
    ]
    for line in regional_totals(sales) {
        println(line)
    }
}

For a large list, accumulate into a Map in one pass instead — the same shape group_by would have produced:

struct Sale { region: string, cents: int }

fn group_by_region(sales: []Sale): Map[string, []Sale] {
    mut grouped: Map[string, []Sale] = Map.new()
    for s in sales {
        mut bucket := grouped.get(s.region) ?? []
        bucket.add(s)
        grouped.put(s.region, bucket)
    }
    return grouped
}

fn main(): void {
    sales := [
        Sale { region: "emea", cents: 12000 },
        Sale { region: "amer", cents: 8000 },
        Sale { region: "emea", cents: 4550 },
    ]
    for region, group in group_by_region(sales) {
        println("${region}: ${group.sum_of(s => s.cents)} over ${group.len()}")
    }
}

Combining lists

plus, plus_all, minus, intersect, union, and subtract all return new lists and leave both operands alone. The set-like three require elements with equality and hashing.

fn main(): void {
    a := [1, 2, 3]
    b := [3, 4]
    println("${a.plus(99).len()}")        // 4
    println("${a.plus_all(b).len()}")     // 5 — duplicates kept
    println("${a.minus(1).len()}")        // 2
    println("${a.intersect(b).len()}")    // 1
    println("${a.union(b).len()}")        // 4 — deduplicated
    println("${a.subtract(b).len()}")     // 2
    println("${[3, 1, 3, 2].to_set().len()}")   // 3 — the bridge to Set
}

union deduplicates; plus_all does not. minus(item) removes elements equal to item.

Mutating in place

for mut v in list writes each rebound value back into the list, which avoids building a second list when the transformation is the point.

fn scale(mut values: []int, factor: int): []int {
    for mut v in values {
        v = v * factor
    }
    return values
}

fn main(): void {
    mut values := [1, 2, 3]
    for v in scale(values, 10) {
        println("${v}")
    }
}

A mut parameter needs a mutable place at the call site — passing a literal directly is rejected. There is no index-based remove or insert, so build a new list with filter when elements have to go:

fn drop_blanks(lines: []string): []string {
    return lines.filter(l => l.trim().is_not_empty())
}

fn main(): void {
    for l in drop_blanks(["a", "   ", "b"]) {
        println(l)
    }
}

A worked example

An order-analysis pipeline: parse, validate, aggregate per key, sort, and render, with every intermediate an ordinary list. Money is held as integer cents, per the advice in Numbers.

struct Order { id: int, customer: string, region: string, cents: int }

error ReportError { NoOrders }

fn join(parts: []string, separator: string): string {
    mut out := ""
    mut first := true
    for part in parts {
        if !first { out.append_string(separator) }
        out.append_string(part)
        first = false
    }
    return out
}

fn parse(rows: []string): []Order {
    mut orders: []Order = []
    for i, row in rows.zip_with_index() {
        fields := row.split(",").map(f => f.trim())
        if fields.len() != 3 { continue }
        match (fields.get(2u32) ?? "").to_int() {
            None => continue
            Some(cents) => orders.add(Order {
                id: i.to_int(),
                customer: fields.get(0u32) ?? "",
                region: fields.get(1u32) ?? "",
                cents: cents,
            })
        }
    }
    return orders
}

fn top_customers(orders: []Order, n: int): []string {
    return orders
        .map(o => o.customer)
        .distinct()
        .map(name => (name, orders.filter(o => o.customer == name).sum_of(o => o.cents)))
        .sorted_by_descending(pair => pair.1)
        .take(n)
        .map(pair => "${pair.0}: ${pair.1} cents")
}

fn regional_share(orders: []Order): []string ! ReportError {
    if orders.is_empty() { error NoOrders }
    grand_total := orders.sum_of(o => o.cents)
    mut out: []string = []
    for region in orders.map(o => o.region).distinct().sorted() {
        region_total := orders.filter(o => o.region == region).sum_of(o => o.cents)
        out.add("${region}: ${100 * region_total / grand_total}%")
    }
    return out
}

fn main(): void {
    rows := [
        "ada, emea, 12000",
        "lin, amer, 8050",
        "ada, emea, 4525",
        "malformed row",
    ]
    orders := parse(rows)
    println(join(top_customers(orders, 2), "\n"))
    match regional_share(orders) {
        Ok(lines) => println(join(lines, "\n"))
        Err(e) => println("no orders")
    }
    match regional_share([]) {
        Ok(lines) => println(join(lines, "\n"))
        Err(e) => println("no orders")
    }
}

Availability

These List signatures are installed for semantic checking but have no lowering path in the wasm backend today. atoll check accepts a call to any of them; atoll build fails with ATOLL2004: builtin method ... has no lowering path.

Signature Working substitute
join(sep), join_of(sep, f) the join loop used throughout this page
group_by(key) filter per distinct key, or accumulate into a Map
associate(f) for loop with Map.put
distinct_by(key) for loop with a Set of seen keys
chunked(size) the chunked loop shown above
windowed(size, step) index-walking loop, as in moving_average
zip(other) index-walking loop, as in scoreboard
flatten() flat_map(x => x) — the declared signature fn flatten(self): []T cannot express flattening anyway
shuffled() no substitute yet — no RNG in the prelude
to_string(), hash_code() on a list of non-scalar elements build the text with a join loop

Two further shapes type-check but hit backend bugs rather than a missing lowering, so avoid them for now:

  • sum_of(f) where f returns a float — use map(f).sum() or fold.
  • average() on a []float — divide a sum() by len().to_float().
  • opt.map(f) where f returns an int, followed by ?? — use match.

Everything else on list.at — add, set, clear, reserve, get, first, last, map, filter, flat_map, fold, reduce, scan, any, all, none, find, find_index, count, enumerate, zip_with_index, sum, sum_of, average, min, max, min_by, max_by, min_of, max_of, sorted*, reversed, take*, skip*, slice, partition, associate_by, distinct, intersect, union, subtract, plus, plus_all, minus, to_set — compiles.

Iteration covers the Iterator and Iterable traits behind for, and Sequences covers the []T / [..]T / [N]T type syntax.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close