Collections#

Meta: two collections today — lists and string-keyed maps. Don't sketch a future (no sets yet). Big API surface; group by intent (construct / index / transform / select / aggregate / slice).

Dang has two collection types: lists ([a]) and string-keyed maps (Map[a]). Both are immutable — every operation that "changes" a collection returns a new one — and both carry a single element type, so a [Int!] holds only Int!s and a Map[String!] only String! values.

The examples on this page are live: they share one Dang environment, so later snippets use earlier definitions. Each result is computed and baked in by the docs build — edit a snippet and hit Run ▶ to replay the page in your browser. Blocks that show an error are supposed to fail: the build verifies the failure the same way it verifies the results.

Lists#

A list is comma-separated values in square brackets:

["apple", "banana", "cherry"]
=> [apple, banana, cherry]

Its type is [String!]! — a non-null list of non-null strings. [a] is shorthand for List[a], and the two spellings are interchangeable in annotations. We'll reuse a couple of lists below, so bind them now (a block ending in a declaration prints nothing — it's just setup):

let fruits = ["apple", "banana", "cherry"]
let nums = [1, 2, 3, 4]

+ concatenates two lists; it's associative and works with empties:

[1, 2] + nums
=> [1, 2, 1, 2, 3, 4]

An empty literal can't infer its element type on its own, so it needs an annotation (or a :: [T]! hint — see Types and nullability):

let none: [Int!]! = []
none.isEmpty
=> true

Indexing#

xs[i] reads the element at a zero-based index. An out-of-bounds index yields null, so the result type is T, not T!:

fruits[0]
=> apple
fruits[99]
=> null

The index must be an Int! — anything else is a compile error:

fruits["first"]
Type error: list index must be Int!, got String!

Indexing chains, so a list of lists reads positionally:

[[1, 2], [3, 4]][1][0]
=> 3

Length and emptiness#

[nums.length, fruits.length]
=> [4, 3]
[nums.isEmpty, none.isEmpty]
=> [false, true]

.length and .isEmpty are list methods only — strings do not have them; use the string predicates instead (see Strings).

Transforming#

.map applies a block to every element, returning a new list (see Blocks for the block forms — _ is the implicit parameter):

nums.map { _ * 2 }
=> [2, 4, 6, 8]

The block can take the index as a second parameter:

fruits.map { fruit, i => `${i}: ${fruit}` }
=> [0: apple, 1: banana, 2: cherry]

.filter keeps the elements a predicate accepts; .reject is its inverse:

[nums.filter { _ % 2 == 0 }, nums.reject { _ % 2 == 0 }]
=> [[2, 4], [1, 3]]

.reduce folds the list down to a single value, threading an accumulator from a seed (positional, or named initial:) through each element:

nums.reduce(0) { acc, x => acc + x }
=> 10

.uniq drops duplicates, keeping first-occurrence order. It uses Dang equality, so it works on nested lists too:

[1, 1, 2, 3, 3, 1].uniq
=> [1, 2, 3]

Iterating#

.each runs a block for its side effects and returns the original list, so calls keep chaining. Like .map, it can take the index:

fruits.each { fruit, i => print(`${i} = ${fruit}`) }
0 = apple 1 = banana 2 = cherry
=> [apple, banana, cherry]

Asking questions#

.any and .all test a predicate across the list; .contains tests for a specific value:

[nums.any { _ > 3 }, nums.all { _ > 0 }, fruits.contains("banana")]
=> [true, true, true]

Slicing#

.takeFirst / .takeLast keep elements from an end, .dropFirst / .dropLast discard them. Each takes an optional count (default 1):

[nums.takeFirst(2), nums.dropLast]
=> [[1, 2], [1, 2, 3]]

.takeWhile / .dropWhile cut at the first element that fails a predicate:

[1, 2, 3, 10, 1].takeWhile { _ < 5 }
=> [1, 2, 3]

Joining#

.join concatenates the elements into a String! with a separator; non-string elements are stringified:

nums.join(" + ")
=> 1 + 2 + 3 + 4

Maps#

Maps are immutable, string-keyed collections with a homogeneous value type. Keys are always String!; only the value type is parameterized. A literal pairs keys with values:

let roles = ["alice": "admin", "bob": "user"]
roles
=> ["alice": admin, "bob": user]

The value type is inferred (Map[String!] here), written Map[a] — there is no [a]-style shorthand for maps. Keys may be any String! expression, not just literals. The empty map is [:], distinct from the empty list [], and like an empty list it needs a type hint:

let counts: Map[Int!]! = [:]
counts.isEmpty
=> true

Indexing and lookup#

m["key"] reads a value; a missing key yields null, so the result is T, not T!. .get is the method form of the same lookup:

[roles["alice"], roles.get("carol")]
=> [admin, null]

.has tests for a key:

roles.has("dave")
=> false

.keys and .values return lists in insertion order, and .length counts the entries:

[roles.keys, roles.values]
=> [[alice, bob], [admin, user]]

Deriving new maps#

Maps are immutable, so these return a new map and leave the original untouched. .with sets a key (replacing in place if present, keeping its position), .without removes one, and .merge combines two — the argument's values winning on conflicts:

roles.with("carol", "admin")
=> ["alice": admin, "bob": user, "carol": admin]
roles.merge(["bob": "owner", "dave": "guest"])
=> ["alice": admin, "bob": owner, "dave": guest]

A map's value type is fixed, so a .with of the wrong type is a compile error:

["a": 1].with("b", "two")
Type error: argument "value": cannot use String! as Int!

Transforming and iterating#

.map transforms the values and preserves the keys, and its block receives both; .each iterates in insertion order and returns the original map:

roles.map { name, role => role.toUpper }
=> ["alice": ADMIN, "bob": USER]

Two maps are equal when they hold the same entries, regardless of insertion order:

["a": 1, "b": 2] == ["b": 2, "a": 1]
=> true

Nullable elements and nullable collections#

The ! sigil (see Types and nullability) sits in two independent places on a list type — the list itself, and its elements:

written meaning
[T] nullable list of nullable T
[T]! non-null list of nullable T
[T!] nullable list of non-null T
[T!]! non-null list of non-null T

A list whose elements may be null is the common case for parsed or fetched data. Indexing such a list gives a nullable element, which ?? (see Operators) or a .map can recover:

let scores = [10, null, 30]
scores.map { _ ?? 0 }
=> [10, 0, 30]

When the list itself is null, methods short-circuit and return null rather than raising — .length, .map, and the rest all yield null, the same way null propagates through field access:

let missing = null :: [Int!]
missing.map { _ + 1 }
=> null

Heterogeneous elements#

A list literal whose elements differ in type infers their nearest common type. Cat and Dog here both implement Animal (see Interfaces and unions), so the list is an [Animal!] — and only the shared Animal surface is available on its elements:

interface Animal { name: String! }
type Cat implements Animal { name: String! }
type Dog implements Animal { name: String! }

[Cat(name: "Whiskers"), Dog(name: "Rex")].map { _.name }
=> [Whiskers, Rex]

Mixing null in widens the elements to nullable; elements with no common type at all are rejected:

[1, "two"]
Type error: unify index 1: no common type between String! and Int!

Meta: many list operations are mirrored on strings (split, contains, etc.) and readers will look both places — see Strings. Block-taking list methods relate to Blocks; full signatures live in Standard library reference; element/list nullability follows Types and nullability.