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:

  1. 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.
  2. 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.
  3. Inline union distribution: Currently, a union of inline tuples requires repeating inline on every arm (inline (true, T) | inline (false, _)). A planned update will allow inline to distribute across the union: inline (true, T) | (false, _).
  4. 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:

zena
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:

zena
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:

zena
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 ([]):

zena
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:

zena
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:

zena
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:

zena
let t = (1, 2);
let x = t[5];

Destructuring ​

Tuple destructuring unpacks elements into separate local variables:

zena
let pair = (100, "ok");
let (code, status) = pair;

Skipping elements ​

Use an underscore (_) to ignore elements during destructuring:

zena
let triple = (1, "ignored", true);
let (id, _, active) = triple;

Pattern matching ​

Tuples can be matched and destructured in match expressions:

zena
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:

zena
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:

zena
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:

zena
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:

zena
let a = (1, "test");
let b = (1, "test");

let r1 = a === b;


let r2 = a !== b;

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:

zena
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:

  1. 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
    
  2. 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
    
  3. 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:

zena
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 the false branch.
  • 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):

zena
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 as Map.get(key) and Iterator.next(). A literal true tag indicates presence with payload T; a false tag indicates absence with an empty hole _.

  • Result shape: inline (true, T, _) | inline (false, _, E) The standard library defines Result<T, E> in zena:core as a type alias over this three-lane inline tuple union:

    zena
    import { 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> and None) in zena:core
  • Outcome<T, E> (Ok<T, E> and Err<T, E>) in zena: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:

zena
// 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:

  1. Tag inspection: ?? inspects the boolean discriminant tag at index 0.
  2. Unwrapping: If the tag is true, ?? unwraps and yields the payload at index 1. The expression evaluates directly to T, not a tuple.
  3. Lazy default: If the tag is false, ?? evaluates the right-hand expression lazily and yields the default value.
  4. Error lane discarded: For Result<T, E> shapes, ?? deliberately discards the error payload in slot 2. The operator means "give me the payload or fallback, regardless of why it failed". To inspect the error, use pattern matching instead:
    zena
    if (let (false, _, err) = parsePort(input)) {
      console.log(`Failed to parse port: ${err}`);
    }
    
  5. 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:

zena
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:

zena
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:

zena
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:

zena
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:

zena
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:

zena
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:

wasm
;; 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:

wasm
;; 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:

  1. Sink and explode tuples: Automatically dissolve regular tuples into scalar parameters and locals when they do not escape.
  2. Dense collection layout: Enable struct-of-arrays (SoA) layout for arrays of tuples, eliminating arrays of individual pointer boxes.