A tuple groups a fixed number of values of possibly different types. You do not need to give a tuple a name — use it directly where you need to pass or return multiple values together. Access individual elements with .0, .1, etc.
Creating tuples
Write the elements in parentheses, separated by commas. The compiler infers the element types from what you put in. You can add an explicit type annotation when needed.
Elements of different types
The elements do not have to share a type — this is the usual reason to reach for a tuple instead of a vector.
Accessing elements
Use .0, .1, .2, ... to read individual elements. The index is part of the program text, so it is checked when you compile: naming an element the tuple does not have is an error, never a null.
Modifying elements
Tuple elements can be reassigned like ordinary variables.
Tuples as function parameters
Pass a tuple to a function by value (a copy) or by reference. A value parameter gets its own copy — changes inside the function do not affect the caller. A reference parameter (&) gives the function a direct link to the caller's tuple so it can modify individual elements in place, text elements included.
Returning tuples
A function can return a tuple to give back more than one value at once.
Destructuring
Assign a tuple to multiple names in one step using the (a, b) = expr form. This is concise when a function returns a tuple and you need both values.
Comparing tuples
Two tuples of the same shape compare element by element, left to right.
Three or more elements
Tuples can have any number of elements.
fn min_max(lo: integer, hi: integer) -> (integer, integer) {
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
fn swap_ints(pair: &(integer, integer)) {
tmp = pair.0;
pair.0 = pair.1;
pair.1 = tmp;
}
Writes to its own copy. The caller's tuple is a different tuple, so the write below is invisible outside this function.
fn doubled_first(p: (integer, integer)) -> integer {
p.0 = p.0 * 2;
p.0 + p.1
}
A tuple is the natural return type when the two values have different types.
fn label_and_score(n: integer) -> (text, integer) {
if n >= 50 { ("pass", n) } else { ("fail", n) }
}
fn main() {
Creating tuples
t = (10, 20);
assert(t.0 == 10, "t.0: {t.0}");
assert(t.1 == 20, "t.1: {t.1}");
Type annotation
w: (integer, integer) = (3, 4);
assert(w.0 + w.1 == 7, "annotated sum: {w.0+w.1}");
Elements of different types
Each element keeps its own type, and .0 / .1 read them as that type.
entry = (1, "one");
assert(entry.0 == 1, "entry.0: {entry.0}");
assert(entry.1 == "one", "entry.1: {entry.1}");
mixed: (text, float, boolean) = ("pi", 3.5, true);
assert(mixed.0 == "pi", "mixed.0: {mixed.0}");
assert(mixed.1 == 3.5, "mixed.1: {mixed.1}");
assert(mixed.2, "mixed.2: {mixed.2}");
A tuple element may itself be a tuple.
nested = (1, (2, 3));
assert(nested.1.0 == 2 && nested.1.1 == 3, "nested: {nested.1.0},{nested.1.1}");
Modifying elements
v = (1, 2);
v.0 = 10;
assert(v.0 + v.1 == 12, "after assign: {v.0}+{v.1}");
Tuples as function parameters
doubled_first writes to its parameter and returns what it computed. The caller's tuple still reads 10 — the parameter was a copy of it.
p = (10, 20);
inner = doubled_first(p);
assert(inner == 40, "the function saw its own doubled copy: {inner}");
assert(p.0 == 10, "and the caller's tuple is untouched: {p.0}");
Tuples as reference parameters (swap in place)
swap_ints exchanges the caller's two elements through its & parameter.
pair = (3, 7);
swap_ints(pair);
assert(pair.0 == 7 && pair.1 == 3, "after swap: ({pair.0},{pair.1})");
Returning a tuple from a function
bounds = min_max(5, 2);
assert(bounds.0 == 2, "min: {bounds.0}");
assert(bounds.1 == 5, "max: {bounds.1}");
Already in order
bounds2 = min_max(1, 9);
assert(bounds2.0 == 1, "min2: {bounds2.0}");
assert(bounds2.1 == 9, "max2: {bounds2.1}");
A returned tuple with elements of different types works the same way.
graded = label_and_score(72);
assert(graded.0 == "pass" && graded.1 == 72, "graded: {graded.0},{graded.1}");
Destructuring
(lo, hi) = min_max(8, 3);
assert(lo == 3, "destructured lo: {lo}");
assert(hi == 8, "destructured hi: {hi}");
Destructuring works for mixed types too, and for more than two names.
(name, score) = label_and_score(20);
assert(name == "fail" && score == 20, "destructured mixed: {name},{score}");
Comparing tuples
The first element decides; later elements are consulted only while the earlier ones are equal. Text elements compare by value.
assert((1, 9) < (2, 0), "the first element decides");
assert((1, 9) < (1, 10), "it ties, so the second decides");
assert(!((1, 9) < (1, 9)), "identical is not strictly less");
assert((1, 9) <= (1, 9), "but it is less-or-equal");
assert((1, "abc") == (1, "abc"), "text compares by value, not identity");
assert((1, "abc") != (1, "abd"), "and differing text is not equal");
Three or more elements
u = (1, 2, 3);
assert(u.0 + u.1 + u.2 == 6, "triple sum: {u.0}+{u.1}+{u.2}");
triple = (10, 20, 30);
triple.1 = 99;
assert(triple.0 == 10 && triple.1 == 99 && triple.2 == 30,
"triple modified: ({triple.0},{triple.1},{triple.2})");
wide = (1, 2, 3, 4, 5, 6, 7, 8);
assert(wide.7 == 8, "eight elements, last one reachable: {wide.7}");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Tuples
// @TITLE: Grouping values without defining a struct
// A tuple groups a fixed number of values of possibly different types.
// You do not need to give a tuple a name — use it directly where you need
// to pass or return multiple values together.
// Access individual elements with '.0', '.1', etc.
// ## Creating tuples
// Write the elements in parentheses, separated by commas.
// The compiler infers the element types from what you put in.
// You can add an explicit type annotation when needed.
// ## Elements of different types
// The elements do not have to share a type — this is the usual reason to
// reach for a tuple instead of a vector.
// ## Accessing elements
// Use '.0', '.1', '.2', ... to read individual elements.
// The index is part of the program text, so it is checked when you compile:
// naming an element the tuple does not have is an error, never a null.
// ## Modifying elements
// Tuple elements can be reassigned like ordinary variables.
// ## Tuples as function parameters
// Pass a tuple to a function by value (a copy) or by reference.
// A value parameter gets its own copy — changes inside the function do not
// affect the caller.
// A reference parameter ('&') gives the function a direct link to the caller's
// tuple so it can modify individual elements in place, text elements included.
// ## Returning tuples
// A function can return a tuple to give back more than one value at once.
// ## Destructuring
// Assign a tuple to multiple names in one step using the '(a, b) = expr' form.
// This is concise when a function returns a tuple and you need both values.
// ## Comparing tuples
// Two tuples of the same shape compare element by element, left to right.
// ## Three or more elements
// Tuples can have any number of elements.
fn min_max(lo: integer, hi: integer) -> (integer, integer) {
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
fn swap_ints(pair: &(integer, integer)) {
tmp = pair.0;
pair.0 = pair.1;
pair.1 = tmp;
}
// Writes to its own copy. The caller's tuple is a different tuple, so the
// write below is invisible outside this function.
fn doubled_first(p: (integer, integer)) -> integer {
p.0 = p.0 * 2;
p.0 + p.1
}
// A tuple is the natural return type when the two values have different types.
fn label_and_score(n: integer) -> (text, integer) {
if n >= 50 { ("pass", n) } else { ("fail", n) }
}
fn main() {
// ## Creating tuples
t = (10, 20);
assert(t.0 == 10, "t.0: {t.0}");
assert(t.1 == 20, "t.1: {t.1}");
// Type annotation
w: (integer, integer) = (3, 4);
assert(w.0 + w.1 == 7, "annotated sum: {w.0+w.1}");
// ## Elements of different types
// Each element keeps its own type, and '.0' / '.1' read them as that type.
entry = (1, "one");
assert(entry.0 == 1, "entry.0: {entry.0}");
assert(entry.1 == "one", "entry.1: {entry.1}");
mixed: (text, float, boolean) = ("pi", 3.5, true);
assert(mixed.0 == "pi", "mixed.0: {mixed.0}");
assert(mixed.1 == 3.5, "mixed.1: {mixed.1}");
assert(mixed.2, "mixed.2: {mixed.2}");
// A tuple element may itself be a tuple.
nested = (1, (2, 3));
assert(nested.1.0 == 2 && nested.1.1 == 3, "nested: {nested.1.0},{nested.1.1}");
// ## Modifying elements
v = (1, 2);
v.0 = 10;
assert(v.0 + v.1 == 12, "after assign: {v.0}+{v.1}");
// ## Tuples as function parameters
// 'doubled_first' writes to its parameter and returns what it computed.
// The caller's tuple still reads 10 — the parameter was a copy of it.
p = (10, 20);
inner = doubled_first(p);
assert(inner == 40, "the function saw its own doubled copy: {inner}");
assert(p.0 == 10, "and the caller's tuple is untouched: {p.0}");
// ## Tuples as reference parameters (swap in place)
// 'swap_ints' exchanges the caller's two elements through its '&' parameter.
pair = (3, 7);
swap_ints(pair);
assert(pair.0 == 7 && pair.1 == 3, "after swap: ({pair.0},{pair.1})");
// ## Returning a tuple from a function
bounds = min_max(5, 2);
assert(bounds.0 == 2, "min: {bounds.0}");
assert(bounds.1 == 5, "max: {bounds.1}");
// Already in order
bounds2 = min_max(1, 9);
assert(bounds2.0 == 1, "min2: {bounds2.0}");
assert(bounds2.1 == 9, "max2: {bounds2.1}");
// A returned tuple with elements of different types works the same way.
graded = label_and_score(72);
assert(graded.0 == "pass" && graded.1 == 72, "graded: {graded.0},{graded.1}");
// ## Destructuring
(lo, hi) = min_max(8, 3);
assert(lo == 3, "destructured lo: {lo}");
assert(hi == 8, "destructured hi: {hi}");
// Destructuring works for mixed types too, and for more than two names.
(name, score) = label_and_score(20);
assert(name == "fail" && score == 20, "destructured mixed: {name},{score}");
// ## Comparing tuples
// The first element decides; later elements are consulted only while the
// earlier ones are equal. Text elements compare by value.
assert((1, 9) < (2, 0), "the first element decides");
assert((1, 9) < (1, 10), "it ties, so the second decides");
assert(!((1, 9) < (1, 9)), "identical is not strictly less");
assert((1, 9) <= (1, 9), "but it is less-or-equal");
assert((1, "abc") == (1, "abc"), "text compares by value, not identity");
assert((1, "abc") != (1, "abd"), "and differing text is not equal");
// ## Three or more elements
u = (1, 2, 3);
assert(u.0 + u.1 + u.2 == 6, "triple sum: {u.0}+{u.1}+{u.2}");
triple = (10, 20, 30);
triple.1 = 99;
assert(triple.0 == 10 && triple.1 == 99 && triple.2 == 30,
"triple modified: ({triple.0},{triple.1},{triple.2})");
wide = (1, 2, 3, 4, 5, 6, 7, 8);
assert(wide.7 == 8, "eight elements, last one reachable: {wide.7}");
}