Tuples
Tuples are shallowly immutable, ordered collections of fixed length and heterogeneous element types. Zena supports two forms of tuples: boxed tuples, which can be stored on the heap in variables, fields, and collections, and inline tuples, which exist purely on the WebAssembly stack to express zero-allocation multi-value returns.
WARNING
Tuples are in transition Tuples are under active development as Zena moves toward true value types:
- Value-type transition: While regular tuples currently compile to
WebAssembly GC structs, reference identity has already been severed (
===and!==are compile-time errors) so the compiler can sink, explode, or flatten tuples freely. - Immutability checking: Tuples are semantically immutable, but the current
type checker contains a bug where index assignments (such as
t[0] = 10) are not yet rejected. They will be strictly rejected in an upcoming compiler update. - Inline union distribution: Currently, a union of inline tuples requires
repeating
inlineon every arm (inline (true, T) | inline (false, _)). A planned update will allowinlineto distribute across the union:inline (true, T) | (false, _). - Inline tuple indexing: Direct element indexing (
expr[0]) on inline tuples is not yet supported (#155); callers must destructure the return value.
Tuple literals ​
A tuple literal consists of a comma-separated list of two or more expressions enclosed in parentheses:
let pair = (10, "hello");
let coords = (1.5, 2.5, 0.0);
let flagRecord = (true, 42, "status");
Single-element tuples ​
Parentheses surrounding a single expression without a comma are parsed as
ordinary grouping parentheses: (42) evaluates to the i32 value 42.
To create a 1-element tuple, include a trailing comma:
let single = (42,); // 1-element tuple of type (i32,)
let grouped = (42); // i32, not a tuple
Tuple types ​
A tuple type specifies the types of its elements in order, enclosed in parentheses:
type Point2D = (f64, f64);
type Entry = (String, i32, boolean);
Tuple types require two or more element types (or a single element followed by a
comma). A single type in parentheses (T) is parsed as a parenthesized type,
equivalent to T.
Element indexing ​
Access tuple elements by zero-based integer index using square brackets ([]):
let point = (10, "label");
let x = point[0]; // Inferred as i32 (10)
let label = point[1]; // Inferred as String ("label")
Compile-time known indices ​
Because each tuple position may have a distinct type, the compiler requires the index to be a compile-time known integer literal:
let t = (1, "hello", true);
let first = t[0]; // Typed as i32
let second = t[1]; // Typed as String
Accessing an index with a dynamic runtime variable that cannot be resolved at compile time yields a union of all element types:
let getElement = (t: (i32, String), idx: i32) => {
let val = t[idx]; // Typed as i32 | String
};
Accessing an index outside the bounds of the tuple produces a compile-time error:
let t = (1, 2);
let x = t[5];
Error:
Destructuring ​
Tuple destructuring unpacks elements into separate local variables:
let pair = (100, "ok");
let (code, status) = pair;
Skipping elements ​
Use an underscore (_) to ignore elements during destructuring:
let triple = (1, "ignored", true);
let (id, _, active) = triple;
Pattern matching ​
Tuples can be matched and destructured in match expressions:
let classify = (t: (i32, i32)): String => match (t) {
case (0, 0): "origin"
case (0, y): `on y-axis at ${y}`
case (x, 0): `on x-axis at ${x}`
case (x, y): `at (${x}, ${y})`
case _: "unknown"
};
Shallow immutability ​
Tuples are immutable. Reassigning a tuple element is intended to produce a compile-time error:
let t = (1, "hello");
// Intended compile error:
// t[0] = 5;
WARNING
Checker bug: assignments not rejected Immutability checking
currently contains a bug: the type checker does not yet reject direct
assignments to tuple elements (such as t[0] = 5). Code should treat tuple
elements as strictly read-only; element assignments will be rejected in a future
compiler release.
Immutability is shallow: if an element holds a reference to a mutable object
(such as a GrowableArray), the referenced object can still be mutated, but the
tuple element cannot be rebound to point to another object:
let t = (new Array<i32>(), "queue");
t[0].push(10); // Permitted: mutating referenced object
Value equality ​
Tuples have value semantics. The == and != operators compare tuples by their
structural contents:
let a = (1, "test");
let b = (1, "test");
let areEqual = a == b; // true
let areDifferent = a != (2, "test"); // true
Two tuples are equal if they have the same arity and each corresponding element
is equal according to its own == operator.
Identity rejection ​
Because tuples are value types, reference identity is permanently unobservable.
Using the reference identity operators === or !== on tuple operands produces
a compile-time error:
let a = (1, "test");
let b = (1, "test");
let r1 = a === b;
Error:
let r2 = a !== b;
Error:
The rejection of === allows the compiler to sink allocations, explode tuples
into separate arguments, or repack them into flat storage without altering
program semantics. Tuples cannot be keyed by identity in identity maps or weak
references.
Inline tuples ​
An inline tuple is an unboxed tuple that lives exclusively on the WebAssembly stack:
let minMax = (a: i32, b: i32): inline (i32, i32) => {
if (a < b) (a, b) else (b, a)
};
The inline keyword instructs the compiler to lower the tuple directly to
native WebAssembly multi-value returns. Inline tuples incur zero heap allocations
and avoid garbage-collector overhead.
Restrictions on inline tuples ​
Inline tuples exist only in execution registers and on the WebAssembly stack. They cannot be stored as persistent values on the heap:
-
Return positions only: An inline tuple type can appear only in function return type annotations (or in type aliases that expand to return types):
zena// Permitted: let getValues = (): inline (i32, i32) => (1, 2); type PairReturn = inline (i32, i32); // Compile-time errors: type BadParam = (x: inline (i32, i32)) => i32; // @error type BadField = {data: inline (i32, i32)}; // @error type BadArray = Array<inline (i32, i32)>; // @error -
Immediate destructuring: Calling a function that returns an inline tuple requires immediate destructuring at the call site. The returned inline tuple cannot be assigned as a whole to a variable:
zena// Permitted: let (lo, hi) = minMax(10, 20); // Not permitted: let result = minMax(10, 20); // @error -
No direct element indexing: Inline tuples cannot currently be indexed directly with
expr[i]. Extracting a single element requires destructuring with wildcard holes (_) for unused positions:zena// Not currently supported: let lo = minMax(10, 20)[0]; // @error // Workaround: destructure and ignore unused positions let (lo, _) = minMax(10, 20);Direct element indexing on inline tuples is tracked in #155.
Tagged unions and hole literals ​
Inline tuples are frequently combined in unions to model optional or tagged
results without heap allocations. A literal tag (such as true or false) acts
as the discriminant:
let findItem = (id: i32): inline (true, String) | inline (false, _) => {
if (id == 42) {
return (true, "found");
} else {
return (false, _);
}
};
The hole literal _ ​
In a multi-value return union where different branches return different numbers
or types of values, inactive positions are filled using the hole literal
_:
- In
(false, _), the second slot is unused on thefalsebranch. - The compiler emits a zero/dummy value in the WebAssembly return slot to satisfy function signature arity without evaluating an expression.
Consuming tagged inline tuples ​
Tagged inline unions can be consumed using pattern matching (if (let ...) or
match):
if (let (true, item) = findItem(id)) {
console.log(`Found item: ${item}`);
}
The pattern matches only when the discriminant tag is true, safely binding
item only when present.
Alternatives to Option and Result ​
In many languages, optional values and fallible operations require
heap-allocated wrapper types like Option<T> or Result<V, E>. In Zena,
function returns model these as tagged inline tuple unions that compile
directly to WebAssembly multi-value returns with zero heap allocation:
-
Option shape:
inline (true, T) | inline (false, _)Used by standard library APIs such asMap.get(key)andIterator.next(). A literaltruetag indicates presence with payloadT; afalsetag indicates absence with an empty hole_. -
Result shape:
inline (true, T, _) | inline (false, _, E)The standard library definesResult<T, E>inzena:coreas a type alias over this three-lane inline tuple union:zenaimport { Result } from 'zena:core'; let parsePort = (s: String): Result<i32, String> => { let n = parseInt(s); if (n >= 1 && n <= 65535) { return (true, n, _); } return (false, _, "port out of range"); };
Why three lanes rather than two? WebAssembly multi-value returns require each
return position across all union members to have a single, compatible value
type. Sharing a slot between T and E would force dynamic casts or fail when
types have incompatible representations (such as i32 versus a heap reference).
Using three lanes—(tag, ok_payload, error_payload) with crossed holes
_—ensures every lane remains type-homogeneous across branches.
Storable counterparts
Because inline tuples cannot be stored in fields, arrays, or long-lived variables, Zena provides class-based counterparts for storing values beyond return position:
Option<T>(Some<T>andNone) inzena:coreOutcome<T, E>(Ok<T, E>andErr<T, E>) inzena:core
Use the zero-cost inline tuple form (Result<T, E> and (true, T) | (false, _))
for function return types, and convert to the boxed form only when persisting a
value on the heap.
NOTE
Unified Option and Result with value types Once the migration to true
value types is complete, boxing will become an implementation detail rather than
a type boundary. Zena will be able to unify each pair into a single Option<T>
and Result<T, E> definition based on tuples (e.g. (true, T) | (false, _)).
The compiler can then represent them inline on the WebAssembly stack for
function returns, while automatically boxing or laying them out densely when
stored on the heap in fields or collections. This will eliminate the need for
separate storable classes (Outcome and boxed Option).
Nullish coalescing with ?? ​
The nullish coalescing operator
?? is not limited to nullable
references (T | null) and optional record fields (record.field?). It can
also consume inline tuples with a boolean tag as the first element, like a
inline (true, V, ...) | inline (false, ...) union:
// Option-shaped union: unwraps the value or evaluates the fallback
let hit = map.get("cache") ?? "default";
// Result-shaped union: unwraps the ok payload or evaluates the fallback
let port = parsePort(input) ?? 8080;
When applied to an inline tuple union:
- Tag inspection:
??inspects the boolean discriminant tag at index0. - Unwrapping: If the tag is
true,??unwraps and yields the payload at index1. The expression evaluates directly toT, not a tuple. - Lazy default: If the tag is
false,??evaluates the right-hand expression lazily and yields the default value. - Error lane discarded: For
Result<T, E>shapes,??deliberately discards the error payload in slot2. The operator means "give me the payload or fallback, regardless of why it failed". To inspect the error, use pattern matching instead:zenaif (let (false, _, err) = parsePort(input)) { console.log(`Failed to parse port: ${err}`); } - Contextual typing: The fallback expression is typed contextually by the
surrounding expected type, allowing literals to infer their element types:zena
let tags: Array<String> = map.get("tags") ?? [];
For full details on operator precedence and interactions with other types, see Nullish coalescing in Operators.
Planned union distribution Planned ​
Currently, a union of inline tuples requires prefixing each union arm with the
inline keyword:
next(): inline (true, T) | inline (false, _);
A planned enhancement will allow inline to distribute across the entire union,
removing the need to repeat it on each member:
next(): inline (true, T) | (false, _);
Multi-value returns ​
Zena leverages WebAssembly's native multi-value return capability through inline tuples. This enables efficient API patterns across the standard library.
Zero-cost multiple return values ​
Functions can compute and return multiple independent results in registers without allocating a heap container:
let divmod = (numerator: i32, denominator: i32): inline (i32, i32) => {
return (numerator / denominator, numerator % denominator);
};
let (quotient, remainder) = divmod(17, 5); // 3, 2
Iterator protocol ​
The standard Iterator<T> interface in zena:iterator defines next() using
an inline tuple union:
export interface Iterator<T> {
next(): inline (true, T) | inline (false, _);
}
Because next() is called on every loop iteration, using an inline tuple avoids
allocating an object wrapper (such as JavaScript's {value, done}) per element:
while (let (true, item) = iterator.next()) {
process(item);
}
Map lookups ​
Map types use inline tuples to return whether a key is present along with its associated value in a single non-allocating call:
get(key: K): inline (true, V) | inline (false, _);
This pattern avoids performing two lookups (has() followed by get()) and
avoids the heap allocation of wrapping the result in a class-based Option<V>
instance. Callers can consume the result via if (let (true, v) = map.get(k))
or provide a fallback using nullish coalescing: map.get(k) ?? defaultValue.
Representation ​
Zena differentiates boxed tuples from inline tuples at the WebAssembly level:
Boxed tuples ​
When a regular tuple is allocated or stored, the compiler generates a canonicalized WebAssembly GC struct type corresponding to the tuple's element types:
;; Canonicalized struct for (i32, String)
(type $Tuple_i32_String (struct
(field $0 i32)
(field $1 (ref $String))
))
Accessing elements (t[0], t[1]) compiles directly to static WebAssembly
struct.get instructions.
Inline tuples ​
Functions returning inline tuples compile directly to WebAssembly functions with multiple return values:
;; Signature for (): inline (i32, i32)
(func $getCoords (result i32 i32)
i32.const 10
i32.const 20
)
At the call site, the WebAssembly engine leaves the return values on the evaluation stack or in machine registers, where destructuring binds them directly to local variables without heap allocation or garbage collection tracking.
Roadmap to true value types ​
While regular tuples are currently boxed as WebAssembly GC structs, their
identity is already unobservable by design (=== is a compile error). Active
compiler work aims to:
- Sink and explode tuples: Automatically dissolve regular tuples into scalar parameters and locals when they do not escape.
- Dense collection layout: Enable struct-of-arrays (SoA) layout for arrays of tuples, eliminating arrays of individual pointer boxes.