Variables

Variables in Zena are declared using let or var. let creates an immutable binding, while var creates a mutable variable.

let and var

let creates an immutable binding. Once initialized, the binding cannot be reassigned:

zena
let x = 42;
x = 43; // Compile error: Cannot reassign immutable variable 'x'

var creates a mutable variable that can be reassigned with the assignment operator = or compound assignment operators:

zena
var count = 0;
count = 10;

Local and module variables

Zena distinguishes between local variables declared inside functions or blocks and module-level variables declared at the top level of a file.

Local variables

Variables declared inside function bodies, blocks ({ ... }), if expressions, and loops are local variables:

zena
let calculate = (a: i32, b: i32): i32 => {
  let sum = a + b;
  return sum * 2;
};

Local variables are compiled to Wasm locals, unless there are captured by a closure, in which case they're compiled to a closure context object that lives on the heap.

Module-level variables

Variables declared at the top level of a .zena file are module-level variables:

zena
let maxRetries = 3;
var currentSessionId = 0;

export let defaultTimeout = 5000;
export var activeConnections = 0;

function getMaxRetries(): i32 {
  return maxRetries;
}

Module-level variables are compiled to WebAssembly globals and initialized in declaration order during module startup. Because module-level variables reside in Wasm globals, top-level function declarations can reference them directly without creating closures or capturing environments.

Top-level variables can be exported using the export keyword so other modules can import them.

Potential change

Module-level initializers currently execute during module startup, though future versions of Zena may restrict them to WebAssembly-compatible constant global initializers. See elematic/zena#125.

Scoping and shadowing

Variables in Zena are lexically scoped to the block in which they are declared. A variable declared in an inner block can shadow a variable of the same name in an outer block:

zena
let limit = 100;

if (limit > 50) {
  let limit = 10; // Shadows the outer 'limit' within this block
  console.log(limit); // 10
}

console.log(limit); // 100

Declaring two variables with the same name in the same lexical scope is a compile-time error.

Type annotations and inference

Variables can include an explicit type annotation after the identifier:

zena
let count: i32 = 42;
var rate: f64 = 3.5;
let name: String = 'Zena';

When an annotation is omitted, the compiler infers the variable's type from its initializing expression:

zena
let count = 42;     // Inferred as i32
let rate = 3.5;     // Inferred as f64
let message = 'hi'; // Inferred as String
let isReady = true; // Inferred as true (literal type)

Literal types and type widening

Numeric and string literals default to their base types (i32, f64, String) unless given an explicit literal type annotation (such as let x: 42 = 42;) or contextual typing from a union.

Boolean literals (true, false) are literal types by default:

  • Immutable bindings (let) preserve boolean literal types:
    zena
    let isEnabled = true; // Type: true
    
  • Mutable bindings (var) widen literal types to their base types (boolean) so the variable can be reassigned:
    zena
    var isEnabled = true; // Type: boolean (widened from true)
    isEnabled = false;    // OK
    

If a mutable variable requires a specific literal or union type, supply an explicit type annotation:

zena
var mode: 'read' | 'write' = 'read';
mode = 'write'; // OK
mode = 'exec';  // Compile error: Type '"exec"' is not assignable to '"read" | "write"'

Destructuring

Variable declarations support destructuring patterns to unpack values from records, tuples, arrays, and classes directly into bindings.

Record destructuring

Record fields are extracted by matching field names inside {}:

zena
let user = { id: 1, name: 'Alice', role: 'admin' };

let { id, name } = user;

To bind a field to a different variable name, use the as keyword:

zena
let { id as userId, name as userName } = user;

Tuple destructuring

Tuple elements are extracted by position using parentheses ():

zena
let point = (10.0, 20.0);
let (x, y) = point;

// Elements can be skipped with commas:
let triple = (1, 2, 3);
let (first, , third) = triple;

Tuple destructuring is commonly used with multi-value returns from standard library functions:

zena
let scores = {'Alice' => 95, 'Bob' => 87};
let (found, score) = scores.get('Alice');

Array destructuring

Arrays (ImmutableArray, FixedArray, GrowableArray, and the Array interface) are unpacked using bracket syntax []:

zena
let numbers = [10, 20, 30, 40];

let [first, second] = numbers;
let [a, , c] = numbers; // Skipping elements

// Rest patterns bind remaining elements as a FixedArray:
let [head, ...tail] = numbers;

Class destructuring

Class instances can be destructured using field patterns:

zena
class Point {
  x: f64;
  y: f64;
  new(this.x, this.y);
}

let p = new Point(1.0, 2.0);
let { x, y } = p;
let { x as px, y as py } = p;

Nested patterns and mutability

Destructuring patterns can be nested to unpack complex data structures in a single declaration:

zena
let config = {
  server: { host: 'localhost', port: 8080 },
  retries: 3,
};

let { server: { host, port }, retries } = config;

Destructuring declarations can also use var to create mutable bindings for each unpacked variable:

zena
var (currentX, currentY) = (0.0, 0.0);
currentX += 5.0;

Next

  • Types — primitive types, references, unions, and aliases
  • Functions — arrow functions, parameters, and closures
  • Control Flowif, match, loops, and expression orientation