Skip to Content

Tuple

A tuple is a fixed-length, heterogeneous, immutable sequence of values. Literal and type share the same shape: (3, "a") has type (int, string). The component count and each component’s type are part of the type, so (int, string), (int, int), and (int, string, int) are all distinct types.

Literals and Types

func main() -> int { val t = (3, "a"); print(t, t is int); val u: (int, string, float) = (1, "b", 2.5); print(u.Item0, u.Item1, u.Item2); val nest = ((1, 2), "x"); print(nest.Item0.Item1, nest); val p = (1); print(p, p is int); return 0; }
(3, a) False 1 b 2.5 2 ((1, 2), x) 1 True
  • It takes at least two components to make a tuple: (1) is a parenthesized expression and its type is still int.
  • Nesting is legal: an outer component can itself be a tuple, drilled into level by level with .Item.
  • Printing looks like (3, a); string components carry no quotes.

Positional Members: Starting at Item0

Components are accessed through 0-based positional members; t.Item0 is the first component. Components are read-only; a tuple is an immutable value.

The component count lives in the type, so out-of-range access and wrong member names fail at compile time, never waiting for runtime:

func main() -> int { val t = (3, "a"); print(t.Item0, t.Item1); return 0; }
3 a
error MS3281: Tuple '(int32, string)' has no component 'Item2': components are read-only .Item0 .. .Item1 (0-based).

Indexing with t[0] is equivalent to .Item0; it accepts only in-range constant integers — dynamic expressions, out-of-range indices, and multi-argument indexing are rejected outright:

func main() -> int { val w: (int, string) = (3, "c"); print(w[0], w[1]); return 0; }
3 c

Destructuring and Multiple Return Values

The declaration position of val / var supports tuple destructuring, naming several components at once. Components destructured with var can be reassigned.

func DivMod(a: int, b: int) -> (int, int) { return (a / b, a % b); } func Sum(p: (int, int)) -> int { return p.Item0 + p.Item1; } func main() -> int { val (q, r) = DivMod(17, 5); print(q, r); print(Sum((3, 4))); var (a, b) = (1, 2); a = 9; print(a, b); return 0; }
3 2 7 9 2

Returning a tuple from a function is the standard shape for multiple return values; the call site can destructure to catch them. Tuples can also be passed whole as arguments, and for a list whose element static type is a tuple, the for-in iteration variable position can destructure directly:

func main() -> int { val xs: list<(int, string)> = [(1, "a"), (2, "b")]; for (n, s) in xs { print(n, s); } return 0; }
1 a 2 b

list.Zip and map.Entries() also produce lists of tuples; destructuring, annotated assignment, and .Item members all work (see Map).

Equality: Reference Identity

== / != compare instance identity, the same rule as class and containers: two tuples built separately are unequal even with identical contents; only aliases of the same instance are equal. To compare contents, destructure and compare component by component:

func main() -> int { val p = (1, "a"); val q = (1, "a"); val alias = p; print(p == q, p == alias); print(p.Item0 == q.Item0 && p.Item1 == q.Item1); return 0; }
False True True

Nullable tuples join in as usual: nullable against nullable, and mixed nullable / non-nullable pairs are all allowed; identity is compared after unwrapping, and == null checks work as always.

Division of Labor with Containers

Choose a tuple for data of fixed length whose structure is known at compile time, List for dynamic length and homogeneous elements, and let a heterogeneous collection degrade to list<any> plus runtime narrowing. The tuple’s advantage is moving structure up to compile time. Component types take part in function signatures and generic arguments (list<(int, string)>), and there is no add/remove mutation surface.

Notes

  • (1,) and () both fail to parse: a tuple needs at least two components, while a trailing comma with more than two components, (1, 2,), is still legal; empty parentheses are not an expression.
  • A nullable tuple itself null-check narrows as usual: inside an if (pay != null) branch you can destructure directly or take a component with pay[0], but nullable components inside the tuple still do not narrow; destructure the component out first, then check it.
  • Tuple patterns in match and is are covered in Patterns. A tuple value flowing through the any channel can be shape-tested by type shape: when if e is (int, string) p matches, p binds the tuple value; a shape mismatch silently yields False. The untyped destructuring form is (k, v) is not supported.
Last updated on October 11, 2026