Skip to content

Collections

Nomi has four core collection types — List<'T>, Vector<'T>, Map<'K, 'V>, and Set<'T> — plus Range<'T>, an interval over comparable values. Ranges over discrete endpoints, such as Int and Codepoint, also iterate. The collections are all immutable and persistent: every operation returns a new collection, leaving the original alone. The stdlib puts the value being transformed first on every operation, so chains read naturally through pipes.

[a, b, c] is the literal syntax. The std/list module provides the List-specific operations (concat, head, tail, …); generic iteration — map, filter, reduce, and so on — flows through the Iter module, materializing back with Iter.to_list() when you want a List:

fn main(): Int {
  xs = [1, 2, 3, 4, 5]

  xs
  |> Iter.filter(|n| n > 2)
  |> Iter.map(|n| n * n)
  |> Iter.reduce(|acc = 0, n| acc + n)
  |> dbg
}

#[a, b, c] is the vector literal. Vector<'T> is an immutable random-access sequence. Use List<'T> for cons-list pattern matching and cheap prepends; use Vector<'T> when indexed lookup and push-at-end are the natural operations. Lists and vectors both implement Add, so xs + ys concatenates two values of the same collection type; the named functions List.concat and Vector.concat are the explicit equivalents.

fn main() {
  names =
    #["Ada", "Grace"]
    |> Vector.push("Katherine")

  dbg names
  dbg Vector.length(names)
  dbg Vector.at(names, 1)
  Unit
}

{"key" => value, …} is the map literal. Map.get returns Maybe<'V>Some(value) when the key exists, None when it doesn’t:

fn main(): Maybe<String> {
  config = {"host" => "localhost", "port" => "8080"}

  dbg Map.size(config)
  dbg Map.get(config, "host")
  dbg Map.get(config, "user")
}

We’ll see how to unwrap Maybe<'T> properly in Pattern Matching.

#{a, b, c} is the set literal. Set<'T> stores unique values and keeps the first occurrence’s insertion order for iteration. Empty sets need type context: empty: Set<Int> = #{}.

fn main(): Bool {
  s = #{1, 2, 3, 2, 1}

  dbg Set.size(s)
  dbg Set.contains?(s, 2)
  dbg Set.contains?(s, 9)
}

1..5 (half-open) and 1..=5 (inclusive) are range literals. The endpoint type determines the range type: 1..5 is Range<Int>, "a".."m" is Range<String>, and 0.0..=1.0 is Range<Float>.

Plain ranges are iterable when the endpoint type has a discrete successor. That makes integer and codepoint ranges natural replacements for generated lists:

fn main(): List<Int> {
  dbg Iter.to_list(1..5)
  dbg Iter.to_list(1..=5)

  // Unbounded ranges work too; bound them with Iter.take downstream.
  Range.naturals()
  |> Iter.take(3)
  |> Iter.to_list()
  |> dbg
}

Nomi has no separate Char type; single-character text is a String. Codepoint represents a Unicode scalar value, and Codepoint ranges iterate:

fn main(): Maybe<List<String>> {
  a = try Codepoint.from_int(97)
  d = try Codepoint.from_int(100)

  letters =
    a..=d
    |> Iter.map(Codepoint.to_string)
    |> Iter.to_list()
    |> dbg

  Some(letters)
}

Ranges can also be intervals over any comparable type. Non-discrete ranges still support interval queries, but they are not plain iterables because strings and floats have no single successor operation:

fn main(): Bool {
  dbg Range.contains?("a".."m", "h")
  dbg Range.contains?(0.0..=1.0, 1.0)
}

When the caller supplies the step, Range.step_by turns steppable ranges into lazy iterables:

fn main(): List<Decimal> {
  1.0d..=1.3d
  |> Range.step_by(0.1d)
  |> Iter.to_list()
  |> dbg
}

A “modifying” operation returns a new collection; the original binding still holds its original value. There’s no aliasing footgun:

fn main(): Int {
  m1 = {"a" => 1, "b" => 2}
  m2 = Map.put(m1, "c", 3)

  dbg Map.size(m1)
  dbg Map.size(m2)
}

The next chapter — Iteration & Loops — covers Iter.loop for stateful iteration and how break / continue / return behave inside the callbacks you pass to these collection ops.