Skip to content

Tuples

Build fixed positional products, index them with `.0`, destructure them, and use them for multi-value returns.

Updated View as Markdown

A tuple is a fixed-size, heterogeneous product with positional fields. The type and the value both use parentheses and commas.

fn f(): int {
    entry: (string, int, bool) = ("port", 8080, true)
    if entry.2 {
        return entry.1
    }
    return 0
}

Tuple identity is structural and ordered: (int, string) and (string, int) are different types, as are tuples of different arity.

fn f(): int {
    t: (int, int) = (1, 2, 3)
    return t.0
}

Indexing

.0, .1, .2 … select an element. The index is syntax checked at compile time, not a runtime lookup.

fn f(): int {
    entry := ("port", 8080, true)
    name := entry.0
    port := entry.1
    return port + name.len()
}

An out-of-range index is rejected:

fn f(): int {
    entry := ("a", 1)
    return entry.5
}

There is no dynamic tuple indexing — entry[i] is not tuple access. When the position is chosen at runtime you want a list or a fixed array, not a tuple.

Destructuring

A tuple pattern binds every position at once. In binding and iteration positions the outer parentheses are optional.

fn f(): int {
    (host, port) := ("localhost", 8080)
    user, id := ("ada", 7)
    return port + host.len() + id + user.len()
}

_ ignores one position — exactly one, never a variable number:

fn f(): int {
    (first, _, third) := (1, 2, 3)
    return first + third
}

Tuple patterns work in match arms too, and compose with literal patterns and guards:

fn quadrant(p: (int, int)): string {
    return match p {
        (0, 0) => "origin"
        (x, y) if x > 0 && y > 0 => "north-east"
        (x, _) if x < 0 => "west"
        _ => "elsewhere"
    }
}

fn f(): string { return quadrant((3, 4)) }

Note that assignment cannot be destructured: a, b = b, a is rejected because , on the left of = is not an assignment target. Use := to bind new names.

Multi-value returns

Tuples give a function several results without a special calling convention. The tuple is the return type.

fn divmod(value: int, divisor: int): (int, int) {
    return (value / divisor, value % divisor)
}

fn f(): string {
    quotient, remainder := divmod(17, 5)
    return "${quotient} r ${remainder}"
}

Parentheses may be dropped in a return when the commas clearly belong to that expression:

fn pair(): (int, string) {
    return 1, "one"
}

fn f(): int { return pair().0 }

Keep them wherever a call or an operator could claim the comma. In particular, call arguments are not a tuple: a function that takes one (int, string) needs an explicit parenthesized argument.

fn take(p: (int, string)): int { return p.0 }

fn f(): int { return take(1, "a") }

Tuple returns combine with ? and catch like any other value:

error ParseError { Bad }

fn parse_endpoint(s: string): (string, int) ! ParseError {
    parts := s.split(":")
    if parts.len() != 2 { error Bad }
    host := parts.get(0) ?? ""
    port := (parts.get(1) ?? "").to_int() ?? 0
    return (host, port)
}

fn render(s: string): string {
    host, port := parse_endpoint(s) catch {
        Bad => return "malformed"
    }
    return "${host} on port ${port}"
}

fn f(): string { return render("localhost:8080") }

Once a result grows past two or three positions, or once the positions stop being obvious, switch to a record or a struct so the names travel with the values.

Tuples in collections

A [](A, B) iterates as tuples, and the loop pattern can destructure them directly.

fn f(): int {
    rows: [](int, string) = [(1, "a"), (2, "bb"), (3, "ccc")]
    mut score := 0
    for id, label in rows {
        score += id * label.len()
    }
    return score
}

The same works for maps, whose iteration yields key/value pairs:

fn total(prices: Map[string, int]): int {
    mut sum := 0
    for name, price in prices {
        sum += price + name.len()
    }
    return sum
}

fn f(): int {
    mut prices: Map[string, int] = Map.new()
    prices.put("apple", 3)
    prices.put("pear", 5)
    return total(prices)
}

Tuples nest inside generics anywhere a type can go:

fn f(): int {
    mut spans: Map[string, (int, bool)] = Map.new()
    spans.put("body", (12, true))
    entry := spans.get("body") ?? (0, false)
    return entry.0
}

Tuples of comparable elements are themselves comparable and equatable, which makes them convenient sort keys:

fn f(): int {
    xs := [(3, "c"), (1, "a"), (2, "b")]
    sorted := xs.sorted()
    return (sorted.get(0) ?? (0, "")).0
}
fn f(): bool {
    return (1, 2) < (1, 3) && (1, "a") == (1, "a")
}

Tuple fields and options

A tuple can be a struct field or an optional value like anything else:

struct Span {
    range: (int, int)
    label: string
}

fn width(s: Span): int {
    return s.range.1 - s.range.0
}

fn f(): int {
    return width(Span { range: (2, 7), label: "header" })
}
fn f(): int {
    maybe: (string, int)? = ("a", 1)
    t := maybe ?? ("", 0)
    return t.1
}

Element coercion

An expected tuple type constrains its elements, but not every scalar coercion is applied per element. An int literal does not widen into a float position:

fn f(): float {
    t: (float, string?) = (1, "one")
    return t.0
}

Write the element in its target type:

fn f(): float {
    t: (float, string?) = (1.0, "one")
    return t.0
}

Lifting a bare value into an optional element, as with "one" into string? above, does work.

Unit

() is the unit value — the result of an expression with nothing useful to report.

fn f(): int {
    ignored := ()
    return 0
}

Function signatures spell “no result” as void (or Unit), or omit the return type entirely. A one-element tuple (x,) is legal but rarely what you want; parentheses around a single expression with no comma are grouping, not a tuple.

A composed example

A word-frequency pass that uses tuples for the pair produced by each stage and a record only where names would help.

fn tally(words: []string): Map[string, int] {
    mut counts: Map[string, int] = Map.new()
    for w in words {
        counts.put(w, (counts.get(w) ?? 0) + 1)
    }
    return counts
}

fn ranked(counts: Map[string, int]): [](int, string) {
    mut rows: [](int, string) = []
    for word, n in counts {
        rows.add((n, word))
    }
    return rows.sorted_descending()
}

fn top(words: []string): (string, int) {
    rows := ranked(tally(words))
    best := rows.get(0) ?? (0, "")
    return (best.1, best.0)
}

fn report(words: []string): string {
    word, count := top(words)
    if count == 0 { return "no words" }
    return "${word} appears ${count} time(s)"
}

fn f(): string {
    return report(["a", "b", "a", "c", "a", "b"])
}

ranked deliberately returns (count, word) rather than (word, count) so that the default tuple ordering sorts by count first — a good example of when positional order is doing real work, and of why top flips the pair back before handing it to a caller who should not have to know.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close