On this page

Quick Reference

Zena is a statically typed, object-oriented and functional programming language targeting WebAssembly GC. It combines TypeScript-like syntax with a sound type system and low- or zero-cost abstractions.

Quick Start ​

zena
// hello.zena
export function main() {
  return 42;
}

Note: Zena is not yet released. The CLI examples below assume you've built the Zena tools locally from the GitHub repository.

bash
# Compile for a JavaScript host (Node.js, the browser)
zena build hello.zena -o hello.wasm --target js

# Compile a portable component over WASI 0.3
zena build hello.zena -o hello.wasm --target component

# Run with wasmtime
wasmtime run -S p3=y hello.wasm

Basic Syntax ​

Comments ​

Zena uses C-style comments: // for single-line and /* */ for multi-line. JSDoc-style comments (/** */) are recommended for documenting public APIs.

zena
// Single-line comment

/* Multi-line
   comment */

/** JSDoc-style comment (recommended for public APIs) */

Identifiers ​

Identifiers name variables, functions, classes, and other entities.

  • Must start with a letter, _, or $
  • Can contain letters, digits, _, or $
  • Case-sensitive

Semicolons ​

Semicolons are required after most statements. They're optional after block-ended constructs (if, match, try) when used as standalone statements.

zena
let x = 1;                    // Required
if (x > 0) {
  /* ... */
}                             // Optional
let y = if (x > 0) 1 else 2;  // Required (expression context)

Libraries ​

Every Zena source file is a library. Libraries provide namespacing and control visibility—top-level declarations are private by default.

A library body contains declarations only. A statement there is an error: nothing in a library body runs on its own, and keeping a library free of side effects is what lets the compiler drop an unused one from a build.

zena
let names = new GrowableArray<String>();

names.push('side effect');   // ❌ Error: A module body may only contain
                             //    declarations. Move this statement into
                             //    a function.

Imports ​

Use import to bring declarations from other libraries into scope:

zena
// Named imports
import {Map, Set} from 'zena:collections';

// Renamed import
import {StringBuilder as SB} from 'zena:core';

// Namespace import
import * as regex from 'zena:regex';
// ...then `regex.match(pattern, text)` inside a function.

// Alternative syntax (from ... import)
from 'zena:core' import {String};

Namespace imports (import * as x) define a read-only variable x whose type is a structural RecordType containing all of the value exports of the imported library as properties. They behave as standard record values and can be passed to functions, returned, stored, or destructured.

Exports ​

Mark declarations with export to make them available to other libraries and the host environment:

zena
export let add = (a: i32, b: i32) => a + b;

export class Point {
  x: i32;
  y: i32;
  new(x: i32, y: i32) {
    this.x = x;
    this.y = y;
  }
}

// Private (not exported)
let helper = (x: i32) => x * 2;

Variables ​

Zena uses let and var to declare variables. Both are block-scoped and can appear at library level or in nested scopes. Variables can be shadowed in inner scopes but cannot be redeclared in the same scope. Unlike JavaScript, variables cannot be referenced before their declaration.

zena
let x = 1; // Immutable binding (like const in JS)
var y = 1; // Mutable binding
y = 2; // OK
x = 2; // ❌ Error: cannot reassign immutable binding

Type Annotations ​

Type annotations are optional—the compiler infers types from initializers. Literals are widened to their base type: integer literals become i32, float literals become f32, and string literals become String (not literal types like 1 or "hello").

zena
let x: i32 = 1; // Explicit type
let y = 1; // Inferred as i32
let s: String = 'hello'; // Explicit String type

Primitive Types ​

Zena's primitive types map directly to WebAssembly value types, with no boxing overhead. Integer literals default to i32 and float literals to f64; a contextual type or an as cast selects another numeric type.

Type WASM Type Description
i32 i32 32-bit signed integer (default for integer literals)
i64 i64 64-bit signed integer
u32 i32 32-bit unsigned (uses unsigned WASM operators)
u64 i64 64-bit unsigned (uses unsigned WASM operators)
u8 u16 i32 Narrow unsigned; promote to u32 in any operation
i8 i16 i32 Narrow signed; promote to i32 in any operation
f32 f32 32-bit float
f64 f64 64-bit float (default for float literals)
boolean i32 true or false
v128 v128 SIMD vector; no literals, operators or casts
String (ref $String) Immutable Unicode string
anyref anyref Top type for reference types only
never — Bottom type (e.g., result of throw)

Note: There is no any type, and no implicit boxing. anyref accepts references — objects, arrays, strings, functions, null — and rejects primitives, so allocation is always visible in the source: put a primitive behind a reference with new Box<T>(x).

zena
let i: i32 = 42;
let n: i64 = 100 as i64;
let u: u32 = 255;
let f: f32 = 3.14;
let d: f64 = 3.14 as f64;
let b: boolean = true;
let s: String = 'hello';

// Narrow integers describe exact storage widths. A literal must fit,
// and arithmetic promotes to the 32-bit counterpart.
let byte: u8 = 255;
let sum: u32 = byte + 1;      // 256 — u8 does not survive the addition
let back: u8 = sum as u8;     // 0 — narrowing is explicit and truncates

Built-in Types (Prelude) ​

The following types are automatically available in every Zena library—no import needed. They come from the prelude, which is implicitly imported.

Type Description
String Immutable Unicode string
Error Base class for all errors (thrown with throw)
IndexOutOfBoundsError Thrown on invalid array/string index access
Option<T> Represents an optional value (Some<T> or None)
Some<T> Variant of Option containing a value
None Variant of Option representing no value
Array<T> Universal read-only array interface (length, [i], map)
MutableArray<T> Mutable array interface ([i] = v)
GrowableArray<T> Resizable array (growable([1, 2, 3]), push)
FixedArray<T> Fixed-size mutable array (fixed([1, 2, 3]))
ImmutableArray<T> Immutable array — what a [1, 2, 3] literal is
Map<K, V> Hash map (literal syntax: {"a" => 1})
Box<T> Wraps a primitive for use in a union or anyref
Range Any range; sealed class over the four below (xs[r])
BoundedRange Range with start and end (1..10)
FromRange Range with start only (5..)
ToRange Range with end only (..10)
FullRange Unbounded range (..)
console Console output (console.log(...))

Helper functions some(value) and none() are also available for creating Option values.

Functions ​

Functions are first-class values that can be assigned to variables, passed as arguments, and returned from other functions. They come in two forms: arrow functions, which are expressions, and function declarations, which are top-level statements.

zena
// Expression body
let add = (a: i32, b: i32) => a + b;

// Block body
let greet = (name: String): String => {
  return 'Hello, ' + name;
};

// No parameters
let getAnswer = () => 42;

Function Declarations ​

A function declaration names a function at the top level of a library. It is hoisted, so forward references and mutual recursion just work.

zena
export function main(): i32 {
  return isEven(10);   // declared below
}

function isEven(n: i32): i32 {
  if (n == 0) { return 1; }
  return isOdd(n - 1);
}

function isOdd(n: i32): i32 {
  if (n == 0) { return 0; }
  return isEven(n - 1);
}

The difference from an arrow function is closures: a function can never be one. It may only appear at the top level, so its body sees only its own parameters and locals plus library-level bindings — never a variable from an enclosing scope. When you need to capture, use an arrow function.

zena
let makeAdder = (x: i32) => (y: i32) => x + y;   // arrows capture

function bad(y: i32): i32 {
  return y + x;   // there is no enclosing `x` to capture
}

gen and async work the same as on arrows: gen function items(): Iterator<i32>, async function load(): Future<i32>. Type parameters go after the name: function identity<T>(x: T): T.

Generic Functions ​

Generic functions work with multiple types while preserving type safety. Type parameters are specified in angle brackets before the parameter list.

zena
let identity = <T>(x: T): T => x;

let num = identity<i32>(42); // Explicit type argument
let str = identity('hello'); // Inferred

Constrained Generics ​

Type parameters can be constrained using extends to require certain capabilities.

zena
let print = <T extends Printable>(x: T): void => {
  // Can call methods from Printable
};

Optional Parameters ​

Parameters marked with ? are optional. For reference types, the parameter becomes T? (T | null). Primitive types cannot be optional without a default value (since they can't be null).

zena
// Reference types can be optional (become T?)
let greet = (name: String, greeting?: String) => {
  if (greeting == null) {
    return 'Hello, ' + name;
  }
  return `${greeting}, ${name}`;
};

greet('Alice'); // "Hello, Alice"
greet('Bob', 'Hi'); // "Hi, Bob"

Default Parameters ​

Parameters can have default values. Unlike Python, default expressions are evaluated fresh at each call site—not shared between calls. Default values are used when the argument is omitted entirely—there's no sentinel value like null or undefined that triggers defaults.

zena
let increment = (x: i32, amount: i32 = 1) => x + amount;

increment(10); // 11 (amount defaults to 1)
increment(10, 5); // 15 (amount is 5)

Argument Adaptation ​

Functions with fewer parameters can be passed where more are expected:

zena
let map = (fn: (item: i32, index: i32) => i32) => { ... };

// Pass a function that ignores `index`
map((item) => item * 2);

Closures ​

Functions are closures—they capture variables from their enclosing scope. Captured variables are stored in a heap-allocated environment.

zena
let makeAdder = (x: i32) => {
  return (y: i32) => x + y;
};

let add5 = makeAdder(5);
add5(10); // 15

A closure captures the variable itself, so it sees later assignments. A for header's variable is a fresh variable on each iteration:

zena
var n = 2;
let read = () => n;
n = 5;
read(); // 5

let reads = new GrowableArray<() => i32>();
for (var i = 0; i < 3; i += 1) {
  reads.push(() => i);
}
reads[2](); // 2

Tail Calls ​

tail return f(x); compiles the call to WebAssembly's return_call: the frame is discarded before the callee runs, so a chain of tail calls executes in constant stack space.

zena
function sum(n: i32, acc: i32 = 0): i32 {
  if (n == 0) { return acc; }
  tail return sum(n - 1, acc + n);
}

sum(1000000);  // no stack overflow

The annotation is required. A call in tail position without it compiles to an ordinary call, keeping its frame and its line in every backtrace. tail is a contextual keyword and stays available as an identifier.

The call must be the whole operand and must return exactly the enclosing function's declared return type. tail return is rejected in an async function, a generator or a constructor, and inside a try or while a using binding is live — in each case the frame still has work to do.

Multi-Value Returns ​

Functions can return multiple values using unboxed tuples. Unlike regular tuples (which are heap-allocated), multi-value returns compile directly to WASM's multi-value return feature—values go on the stack, not the heap. This guarantees good performance since stack values are prime candidates for register allocation.

zena
let divide = (a: i32, b: i32): (i32, i32) => {
  return (a / b, a % b);
};

let (quot, rem) = divide(17, 5);  // quot=3, rem=2

Multi-value returns must be immediately destructured at the call site—you cannot store them in a variable.

Use Case: Iterators ​

The Iterator interface uses multi-value returns for efficient iteration:

zena
type Step<T> = inline (0, _, _) | inline (1, T, _);  // Done | Ready(value)

interface Iterator<T> {
  next(): Step<T>;
}

This avoids the two-call pattern common in Java (hasNext() then next()) and the wrapper-object pattern in JavaScript (returning {done, value}). A single call returns both the status and the value with zero allocation overhead.

An AsyncIterator<T> (from zena:async) may also answer (2, _, future), a value that is still coming. for await consumes either kind; a plain for consumes only Iterator<T>/Iterable<T>, or an asynchronous one wrapped in requireSync, which throws when a value is not available yet.

Use Case: Map Lookups ​

Map.get() uses multi-value returns to safely handle missing keys without relying on null:

zena
let map = {"a" => 42, "b" => 100};

let (value, found) = map.get('a');  // (42, true)
let (_, found2) = map.get('c');     // (_, false)

This is especially important for primitive value types like i32 where null isn't an option. Instead of returning i32 | null (which would require boxing), the boolean found flag indicates whether the lookup succeeded, and when found is false, the _ identifier is used to return a never value.

Operators & Expressions ​

Zena provides familiar operators from C-family languages. Operators are strictly typed—you cannot mix i32 and u32 without explicit casting.

Arithmetic ​

zena
let a = 1 + 2; // Addition
let b = 5 - 3; // Subtraction
let c = 2 * 3; // Multiplication
let d = 10 / 3; // Division (float: 3.333...)
let e = 10 % 3; // Modulo (1)
let f = 2 ** 10; // Exponentiation (1024)

Compound Assignment ​

zena
var x = 10;
x += 5; // x is now 15
x -= 3; // x is now 12
x *= 2; // x is now 24
x /= 4; // x is now 6
x %= 4; // x is now 2
x **= 3; // x is now 8

Nullish assignment assigns only when the left side is null:

zena
var name: String? = null;
name ??= 'Anonymous'; // name is now 'Anonymous'

Compound assignment works with variables, class fields, and array indices. There are no ++ or -- operators — use += 1 and -= 1 instead.

Comparison ​

Equality (==) uses value comparison for primitives and strings. On objects it calls the runtime class's operator ==, which is reference comparison unless some class in the hierarchy defines one. Use === to always compare by reference.

zena
a == b; // Equal (value equality for strings)
a != b; // Not equal
a === b; // Strict equal (reference equality)
a !== b; // Strict not equal
a < b; // Less than
a <= b; // Less than or equal
a > b; // Greater than
a >= b; // Greater than or equal

Logical ​

zena
a && b; // Logical AND (short-circuit)
a || b; // Logical OR (short-circuit)
!a; // Logical NOT

Nullish Coalescing ​

The ?? operator returns the right operand when the left is null:

zena
let name: String? = null;
let display = name ?? 'Anonymous'; // 'Anonymous'

?? has the same precedence as ||. The default takes its type from whatever context types the ?? as a whole:

zena
let tags: Array<String> = lookup(name) ?? []; // the empty literal is an Array<String>

Optional Chaining ​

Safe access on potentially null values. Short-circuits to null if the receiver is null.

zena
let name = user?.name;       // Property access
let first = items?[0];       // Index access
let result = callback?(42);  // Call
let display = user?.name ?? 'Anonymous'; // With fallback

Bitwise ​

zena
a & b; // AND
a | b; // OR
a ^ b; // XOR
~a; // NOT
a << b; // Left shift
a >> b; // Right shift (signed)
a >>> b; // Right shift (unsigned)

Type Operators ​

The as operator casts between types (checked at runtime for reference types). The is operator tests types and enables type narrowing.

zena
x as i64      // Type cast (checked at runtime)
x is MyClass  // Type check (returns boolean)

Range Operators ​

Ranges represent sequences of indices, primarily for array slicing and iteration. All ranges are half-open (exclusive end).

zena
1..10     // BoundedRange [1, 10)
5..       // FromRange (5 to end)
..10      // ToRange (start to 10)
..        // FullRange (all elements)

Pipeline Operator ​

The pipeline operator |> enables fluent data transformation by passing the result of one expression as input to the next. The placeholder $ refers to the piped value.

zena
// Without pipeline (inside-out)
let result = validate(transform(parse(data)));

// With pipeline (left-to-right)
let result = data |> parse($) |> transform($) |> validate($);

The $ placeholder can be used multiple times and in any position:

zena
10 |> $ + $               // 20 (use $ twice)
5 |> add($, 10)           // 15 ($ as first arg)
3 |> subtract(10, $)      // 7  ($ as second arg)

Pipelines can be chained and work with method calls:

zena
text |> $.trim() |> $.toUpperCase()
1 |> $ + 1 |> $ * 2 |> $ + 3  // ((1 + 1) * 2) + 3 = 7

$ is only valid inside pipeline expressions:

zena
let x = $; // ❌ Error: '$' can only be used inside a pipeline expression

Operator Precedence (highest to lowest) ​

  1. Unary: !, -, ~
  2. Multiplicative: *, /, %
  3. Additive: +, -
  4. Type cast/check: as, is
  5. Range: ..
  6. Shift: <<, >>, >>>
  7. Relational: <, <=, >, >=
  8. Equality: ==, !=, ===, !==
  9. Bitwise AND: &
  10. Bitwise XOR: ^
  11. Bitwise OR: |
  12. Logical AND: &&
  13. Logical OR / Nullish Coalescing: ||, ??
  14. Pipeline: |>
  15. Assignment: =, +=, -=, *=, /=, %=

Note: as currently binds looser than arithmetic, so a + b as i64 means (a + b) as i64. We may change this in the future to match other languages where as binds tightly.

Control Flow ​

Zena's control flow is expression-oriented—if and match can return values. This enables concise conditional expressions without ternary operators.

If Statement / Expression ​

Like Rust, if can be used as an expression. When used as an expression, the else branch is required and both branches must have compatible types.

zena
// Statement
if (x > 0) {
  // ...
} else if (x < 0) {
  // ...
} else {
  // ...
}

// Expression (else required)
let abs = if (x >= 0) x else -x;

While Loop ​

Standard while loop with a boolean condition.

zena
var i = 0;
while (i < 10) {
  i = i + 1;
}

For Loop (C-style) ​

Traditional C-style for loop with init, condition, and update expressions. Note: use var for the loop variable since it needs to be mutable.

zena
for (var i = 0; i < 10; i = i + 1) {
  // ...
}

For-In Loop ​

Iterate over any collection that implements the iteration protocol.

zena
let arr = [1, 2, 3];
for (let item in arr) {
  // item is 1, 2, 3
}

Break and Continue ​

zena
while (true) {
  if (done) break;
  if (skip) continue;
}

Let-Pattern Conditions ​

Combine pattern matching with conditionals using if let and while let. The condition succeeds if the pattern matches.

zena
// if-let
if (let Some(value) = maybeValue) {
  // value is bound here
}

// while-let
while (let (1, item, _) = iterator.next()) {
  // item is bound here
}

Pattern Matching ​

Zena's match expression provides exhaustive pattern matching. Unlike switch, match is an expression that returns a value, and the compiler ensures all cases are covered.

zena
let result = match (x) {
  case 0: "zero"
  case 1: "one"
  case let n if n < 0: "negative"
  case _: "other"
};

Pattern Types ​

Patterns can match literals, bind variables, destructure data structures, and combine with logical operators.

zena
// Literals
case 42: ...
case "hello": ...
case true: ...
case null: ...

// Binding (`let`/`var` binds the matched value)
case let x: x + 1

// Type pattern (a bare name must name a class; it matches instances of it)
case Circle: ...

// Wildcard (matches anything)
case _: ...

// Tuple destructuring
case let (a, b): a + b

// Record destructuring
case let { x, y }: x + y

// Shorthand fields bind, like `let { x, y } = rec` destructuring.
// `as` binds a field under a different name.
case { x, y }: x + y
case { x as first }: first

// Class destructuring
case let Point { x: 0, y }: "on y-axis"

// Or patterns
case 1 | 2 | 3: "small"

// Guard patterns
case let n if n > 100: "large"

Block Cases ​

zena
match (x) {
  case 1: {
    let doubled = x * 2;
    doubled + 1
  }
  case _: 0
}

Exhaustiveness ​

Match expressions must cover all possible values:

zena
type Status = "ok" | "error";
let s: Status = "ok";

match (s) {
  case "ok": ...
  case "error": ...  // Required for exhaustiveness
}

Strings ​

Strings in Zena are immutable sequences of Unicode text. The internal encoding (WTF-8 or WTF-16) is abstracted away—you work with characters and code points, not bytes.

String literals can be written with single quotes ('...'), double quotes ("..."), or backticks (`...`). Single and double quotes are equivalent. Backticks create template literals that support multi-line content and ${...} interpolation.

zena
let s1 = 'hello'; // Single quotes
let s2 = 'world'; // Double quotes (equivalent)
let s3 = 'line1\nline2'; // Escape sequences

// Template literals (backticks)
let name = 'Alice';
let greeting = `Hello, ${name}!`; // Interpolation

// Multi-line (only with backticks)
let text = `
  This is
  multi-line
`;

Escape Sequences ​

Sequence Meaning
\n Newline
\r Carriage return
\t Tab
\\ Backslash
\" Double quote
\' Single quote
\$ Dollar sign (in templates)
\xNN Hex byte
\uNNNN Unicode code point

String Operations ​

String slicing is O(1) and shares backing storage with the original string—no copying required. Use copy() when you need to release the parent string's memory.

zena
let s = 'hello';
s.length; // Length in code units
s.slice(1, 3); // "el" (O(1), shares backing storage)
s.copy(); // Force a copy (release parent memory)
s + ' world'; // Concatenation

StringBuilder ​

Use StringBuilder for efficient concatenation when building strings in a loop. For simple a + b + c expressions, regular concatenation is fine.

zena
import {StringBuilder} from 'zena:core';

let sb = new StringBuilder();
sb.append('Hello');
sb.append(', ');
sb.append('World!');
let result = sb.toString(); // "Hello, World!"

StringReader ​

Use StringReader for parsing strings. It provides a cursor-based API that safely handles UTF-8 boundaries, making it ideal for tokenizers and parsers.

zena
import {StringReader} from 'zena:core';

let r = new StringReader('hello world');
r.skipWhitespace();
let start = r.mark();
while (!r.isAtEnd && r.peekByte() != 32) {
  // 32 = space
  r.advance();
}
let word = r.sliceFrom(start); // "hello"

Tagged Template Literals ​

Template literals can be tagged with a function that processes the template. The tag function receives the static string parts and interpolated values separately.

zena
let highlight = (
  strings: TemplateStringsArray,
  ...values: Array<any>
): String => {
  let result = new StringBuilder();
  for (var i = 0; i < values.length; i = i + 1) {
    result.append(strings[i]);
    result.append('**');
    result.append(values[i] as String);
    result.append('**');
  }
  result.append(strings[strings.length - 1]);
  return result.toString();
};

let name = 'Zena';
highlight`Hello, ${name}!`; // "Hello, **Zena**!"

The TemplateStringsArray is guaranteed to be referentially stable—the same template literal in source code always produces the same array instance. This enables caching based on array identity.

Raw Strings ​

Tag functions can access unprocessed string content (with escape sequences intact) via the raw property:

zena
let showEscapes = (strings: TemplateStringsArray): String => {
  return strings.raw[0]; // Escape sequences not processed
};

showEscapes`line1\nline2`; // "line1\\nline2" (literal backslash-n)

The regex Tag ​

The regex tag from zena:regex compiles a regular expression at runtime. It uses raw strings, so you don't need to double-escape backslashes:

zena
import {regex} from 'zena:regex';

// Without tagged template: must escape backslashes
let r1 = new Regex('\\d+\\.\\d+');

// With regex tag: write patterns naturally
let r2 = regex`\d+\.\d+`; // Same pattern, easier to read

Type System ​

Zena has a sound type system—if your code compiles, it won't have type errors at runtime. The type system combines nominal typing (for classes) with structural typing (for records and functions).

Type Aliases ​

Type aliases create a new name for a type without creating a new type. Aliases are interchangeable with their underlying type.

zena
type ID = String;
type Point = {x: i32; y: i32};
type Callback = (result: String) => void;
type Container<T> = {value: T};

Distinct Types ​

Create nominally distinct types from existing types:

zena
distinct type Meters = i32;
distinct type Seconds = i32;

let m: Meters = 10 as Meters;
let s: Seconds = 5 as Seconds;

let x = m + s;  // ❌ Error: type mismatch

Union Types ​

Union types represent values that can be one of several types. All types in a union must be distinguishable at runtime—the compiler rejects unions where it can't tell the types apart.

zena
let x: String? = null;  // T? is shorthand for T | null
x = "hello";

// Union narrowing
if (x != null) {
  // x is String here
}

if (x is MyClass) {
  // x is MyClass here
}

Union restrictions:

  • Primitives cannot mix with other types: i32? is not allowed - Literal unions of the same primitive are fine: 1 | 2 | 3 works
  • Extension classes on the same base type cannot be unioned (indistinguishable)
  • Use Box<T> to put primitives in unions with references: Box<i32>?

Literal Types ​

Literal types represent exact values. Combined with unions, they create enumeration-like types with precise type checking.

zena
type Mode = 'read' | 'write';
type Level = 1 | 2 | 3;

let mode: Mode = 'read';
let level: Level = 2;

Function Types ​

zena
type BinaryOp = (a: i32, b: i32) => i32;
type Predicate<T> = (item: T) => boolean;

Type Narrowing ​

The type system narrows types based on control flow:

zena
let process = (x: String?) => {
  if (x == null) {
    return 'empty';
  }
  // x is String here
  return x.length;
};

Narrowing also works for immutable paths (class let fields, record fields, tuple elements):

zena
class Wrapper {
  let inner: Container?;  // Immutable field
  new() : inner = null { }
}

let process = (w: Wrapper): i32 => {
  if (w.inner !== null) {
    return w.inner.value;  // w.inner narrowed to Container
  }
  return 0;
};

Mutable fields (var) cannot be narrowed—another reference could modify the field between the check and use.

Nominal vs Structural ​

  • Nominal: Classes, interfaces, distinct types (identity matters)
  • Structural: Records, tuples, functions (shape matters)
zena
class A {
  x: i32;
}
class B {
  x: i32;
}
// A and B are NOT compatible (nominal)

type R1 = {x: i32};
type R2 = {x: i32};
// R1 and R2 ARE compatible (structural)

Enums ​

Enums define a set of named constants. They're distinct types—you can't accidentally use a raw integer where an enum is expected.

zena
// Integer-backed (default)
enum Color {
  Red, // 0
  Green, // 1
  Blue, // 2
}

// Explicit values
enum Status {
  Ok = 200,
  NotFound = 404,
}

// String-backed
enum Direction {
  Up = 'UP',
  Down = 'DOWN',
}

let c: Color = Color.Red;
let n: i32 = c as i32; // Explicit cast required

Records & Tuples ​

Records and tuples are immutable, structural data types. Two records with the same shape are compatible, regardless of where they're defined.

Records ​

Records are immutable objects with named fields. They support shorthand syntax and spreading.

zena
let p = {x: 1, y: 2};
let x = p.x; // 1

// Shorthand
let x = 1;
let y = 2;
let p = {x, y}; // { x: 1, y: 2 }

// Spread
let p2 = {...p, z: 3}; // { x: 1, y: 2, z: 3 }

An if or match whose arms are records of different shapes has the union of their types. It goes anywhere a record type every member satisfies is expected, a field every member holds (present, required, one type) can be read off it, and destructuring works on the fields the pattern names — one a member lacks is optional there, so it takes a default or becomes a presence test.

zena
type Named = {name: String};
let nameLength = (o: Named): i32 => o.name.length;

// `{name: String, rank: i32} | {name: String}`
let o = if (flag) { {name: 'abc', rank: 1} } else { {name: 'ab'} };
nameLength(o); // ✅
o.name.length; // ✅
let {name} = o; // ✅
let {rank = 9} = o; // ✅ 9 on the member with no rank
if (let {rank} = o) { … } // ✅ matches only the member that has one

Annotating the binding (let o: Named = if ...) builds both arms in that shape instead, which is cheaper — no member to pick at run time.

Tuples ​

Tuples are fixed-length sequences where each position can have a different type.

zena
let t = (1, 'hello');
let n = t[0]; // 1
let s = t[1]; // "hello"

Tuple indices must be compile-time known values:

zena
let t = (1, 'hello', true);

let first = t[0]; // ✅ Literal index
let idx = 1; // let variable with literal initializer
let second = t[idx]; // ✅ Compile-time known

var i = 0;
let x = t[i]; // ❌ var is not compile-time known

Tuple elements support type narrowing since tuples are immutable:

zena
let process = (t: (Container?, i32)): i32 => {
  if (t[0] !== null) {
    return t[0].value; // t[0] narrowed to Container
  }
  return 0;
};

Destructuring ​

Destructuring extracts values from records, tuples, and class instances into separate variables.

zena
// Record
let { x, y } = point;
let { x as a, y as b } = point;  // Rename

// Tuple
let (first, second) = tuple;
let (a, , c) = (1, 2, 3);  // Skip elements

Classes ​

Classes are nominal types with fields, methods, and constructors. They compile to WASM-GC structs, with vtables (WASM tables) generated as needed for virtual dispatch. Classes support single inheritance, can implement multiple interfaces, and can include mixins.

zena
class Point {
  x: i32;
  y: i32;

  new(x: i32, y: i32) {
    this.x = x;
    this.y = y;
  }

  move(dx: i32, dy: i32): void {
    this.x = this.x + dx;
    this.y = this.y + dy;
  }

  distanceFromOrigin(): f32 {
    return sqrt((this.x * this.x + this.y * this.y) as f32);
  }
}

let p = new Point(3, 4);
p.move(1, 1);

Fields ​

Fields are public and immutable by default. Public fields are virtual—they're inherited by subclasses and can be overridden with accessors. This means field access may involve a virtual call.

Tip: Immutable fields work with type narrowing and make code easier to reason about. Use var when mutability is needed. Use private fields (#) or final classes to avoid virtual dispatch overhead. See Field Mutability and Private Fields.

zena
class Rectangle {
  var width: i32;   // Public, virtual, mutable
  var height: i32;  // Public, virtual, mutable
}

class Square extends Rectangle {
  // Override width with an accessor that keeps width == height
  width: i32 {
    get { return this.height; }
    set(v) { this.height = v; }
  }
}

Private Fields ​

Private fields use the # prefix. They're only accessible within the class, are not inherited, and have no virtual dispatch overhead.

zena
class Counter {
  #count: i32; // Private, direct access

  new() {
    this.#count = 0;
  }

  increment(): void {
    this.#count = this.#count + 1;
  }

  get(): i32 {
    return this.#count;
  }
}

Field Mutability ​

Fields are immutable by default. Use var to make a field mutable. The let modifier is accepted but redundant — bare fields are already immutable. Field types can be inferred from their initializer expression.

zena
class User {
  id: i32;                    // Immutable (default)
  let created: i64;           // Immutable (explicit, same as bare)
  var email: String;          // Mutable
  var(#phone) phone: String;  // Mutable with private setter
}
Syntax Getter Setter Mutability
name: Type Public None (constructor only) Immutable
let name: Type Public None (constructor only) Immutable
var name: Type Public Public Mutable
var(#name) name: Type Public Private (#name) Mutable

The var(#name) syntax creates a publicly readable field with a private setter:

zena
class Counter {
  var(#count) count: i32 = 0;

  increment(): void {
    this.#count = this.count + 1;  // Write via #count
  }
}

let c = new Counter();
let n = c.count;  // OK - reading is public
c.count = 5;      // Error - no public setter

Nullable Fields ​

A field of nullable type (Type?, shorthand for Type | null) needs no initializer: it defaults to null when not set in the constructor. Like any nullable type, primitives are excluded—use Box<T> if needed. Fields cannot be marked optional themselves (bio?: String is an error): ? after a name means "may be absent", which is a record property and parameter concept—a constructed object's fields are never absent.

zena
class User {
  name: String;
  bio: String?;  // String or null

  new(name: String) {
    this.name = name;
    // bio defaults to null
  }
}

interface Configurable {
  label: String?;  // Nullable interface field
}

mixin Timestamped {
  updatedAt: String?;  // Nullable mixin field
}

Nullable fields work with abstract and private (#) fields:

zena
abstract class Base {
  abstract metadata: String?; // Subclasses must provide
}

class Cache {
  #lastResult: String?; // Private nullable field
}

Initializer Lists ​

For immutable fields that need constructor parameters, use Dart-style initializer lists. The initializer list appears after : and before the constructor body.

zena
class Point {
  let x: i32;
  let y: i32;

  // Initializer list before the body
  new(x: i32, y: i32) : x = x, y = y { }
}

class Rectangle {
  let width: i32;
  let height: i32;
  let area: i32;

  // Can compute values from parameters
  new(w: i32, h: i32) : width = w, height = h, area = w * h { }
}

Initializer list expressions can reference constructor parameters and earlier fields in the list. They cannot reference this because the object doesn't exist yet.

For derived classes, super() goes at the end of the initializer list:

zena
class Point3D extends Point {
  let z: i32;

  // Initialize z, then call super
  new(x: i32, y: i32, z: i32) : z = z, super(x, y) { }
}

Only actual fields (not setters) can appear in initializer lists.

Named Constructors ​

Classes can define multiple constructors by name. Named constructors are called with new Class.name(...):

zena
class Point {
  x: i32;
  y: i32;

  new(this.x, this.y);

  new origin()
    : x = 0, y = 0;

  new fromX(x: i32)
    : x = x, y = 0;
}

let p1 = new Point.origin();
let p2 = new Point.fromX(10);

Named constructors can also use private names (new #name(...)) or symbol names (new [sym](...)), and subclasses call them via super.name(...). Named constructors share their namespace with static methods, and a class with only named constructors does not receive an implicit default constructor.

Getters and Setters ​

Accessors define computed properties. They can override inherited fields or stand alone. Use get for read-only, or both get and set for read-write.

zena
class Rectangle {
  width: i32;
  height: i32;

  area: i32 {
    get {
      return this.width * this.height;
    }
  }
}

Inheritance ​

Classes can extend one parent class using extends. The child inherits all fields and methods, and can override methods.

zena
class Animal {
  name: String;

  new(name: String) {
    this.name = name;
  }

  speak(): String {
    return '...';
  }
}

class Dog extends Animal {
  new(name: String) {
    super(name);
  }

  speak(): String {
    return 'Woof!';
  }
}

Generic Classes ​

Classes can have type parameters. Zena uses monomorphization—Box<i32> and Box<String> are completely separate types at runtime.

zena
class Box<T> {
  value: T;

  new(value: T) {
    this.value = value;
  }

  map<U>(fn: (val: T) => U): Box<U> {
    return new Box<U>(fn(this.value));
  }
}

let b = new Box<i32>(42);

Static Members ​

Static fields and methods belong to the class itself, not instances. Access them using the class name.

zena
class Math {
  static PI: f32 = 3.14159;

  static max(a: i32, b: i32): i32 {
    return if (a > b) a else b;
  }
}

let pi = Math.PI;
let m = Math.max(3, 5);

A static belongs to the class itself, so a generic class's type parameters are out of scope inside one. A static that needs a type parameter declares its own, solved from the arguments or written at the call like any generic function's.

zena
class Boxed<T> {
  item: T;
  new(this.item);
  static of<A>(v: A): Boxed<A> { return new Boxed<A>(v); }
  static none<A>(): Boxed<A>? { return null; }
}

let b = Boxed.of(11);          // Boxed<i32>, solved from the argument
let e = Boxed.none<String>();  // written: nothing else determines A

Static storage is one cell shared by every use of the class, and there is no way to write a class's type arguments in expression position — a static never varies with them.

Modifiers ​

abstract classes cannot be instantiated and may have abstract methods. final classes cannot be extended, and final methods cannot be overridden.

zena
abstract class Shape {
  abstract area(): f32;
}

final class Circle extends Shape {
  radius: f32;

  new(radius: f32) {
    this.radius = radius;
  }

  area(): f32 {
    return 3.14159 * this.radius * this.radius;
  }
}

Extension Classes ​

Add methods to existing types:

zena
extension class StringExt on String {
  isEmpty(): boolean {
    return this.length == 0;
  }
}

"hello".isEmpty();  // false
"".isEmpty();       // true

An extension class can extend another one declared on the same type. The on clause then comes from the superclass:

zena
extension class TrimmedString extends StringExt {
  trimmed(): String { return this.trim(); }
}

Case Classes ​

A class with a parameter list after its name is a case class—a concise declaration that auto-generates fields, a constructor, operator ==, and hashCode(). Case classes are implicitly final and cannot be extended.

zena
class Point(x: f64, y: f64)

let a = new Point(1.0, 2.0);
let b = new Point(1.0, 2.0);
a == b;    // true (structural equality)
a.x;       // 1.0

Case classes can have a body for additional members, and support extends, with, and implements:

zena
class Point(x: f64, y: f64) {
  distance(): f64 { return sqrt(x * x + y * y); }
}

class Counter(name: String, var count: i32)  // var for mutable fields
class Pair<A, B>(first: A, second: B)        // Generic case classes
class Event(name: String) with Timestamped implements Hashable

A case class that inherits operator == or hashCode() from its base generates neither, so a sealed base can give all its cases one equality.

Sealed Classes ​

Sealed classes restrict which classes can extend them. Only the variants listed in the sealed class body (or explicitly allowed) may be direct subclasses. This enables exhaustive pattern matching—the compiler knows every possible case.

Sealed classes are implicitly abstract and cannot be instantiated directly.

zena
sealed class Expr {
  case Binary(left: Expr, op: String, right: Expr)
  case Literal(value: i32)
  case Unary(op: String, expr: Expr)
}

// Inline variants are case classes—with auto-generated fields, ==, hashCode

let expr = new Binary(new Literal(1), '+', new Literal(2));

// Exhaustive match—compiler ensures all cases are covered
let result = match (expr) {
  case let Binary { left, op, right }: eval(left) + eval(right)
  case let Literal { value }: value
  case let Unary { op, expr }: -eval(expr)
};

Variants can also be declared separately using extends:

zena
sealed class Shape { }

class Circle(radius: f64) extends Shape
class Rectangle(width: f64, height: f64) extends Shape

Operator Overloading ​

Classes can overload operators to provide custom behavior for built-in syntax.

operator == ​

Define custom equality. Called by == and the eq intrinsic.

zena
class Point {
  x: i32;
  y: i32;

  operator ==(other: Point): boolean {
    return this.x == other.x && this.y == other.y;
  }
}

let a = new Point(1, 2);
let b = new Point(1, 2);
a == b;  // true (calls operator ==)
a === b; // false (reference equality)

The call is virtual: a subclass overrides it with operator ==(other: Sub), and the runtime class's operator answers even through a base-typed reference. Objects of different classes are never equal, in either order.

operator [] ​

Define index access for custom collections. Implement get for reading and set for writing.

zena
class Grid {
  #data: FixedArray<i32>;
  #width: i32;

  operator [](x: i32, y: i32): i32 {
    get {
      return this.#data[y * this.#width + x];
    }
    set(value) {
      this.#data[y * this.#width + x] = value;
    }
  }
}

let grid = new Grid(10, 10);
grid[3, 4] = 42;      // calls operator [] set
let v = grid[3, 4];   // calls operator [] get

The index operator can take any number of parameters. For single-index access (like arrays), use one parameter. For multi-dimensional access (like matrices or grids), use multiple parameters.

Method Overloading ​

Classes can have multiple methods with the same name but different parameter types or counts. The compiler resolves the correct overload at compile time based on argument types.

zena
class Printer {
  print(val: i32): void {
    console.log('Integer: ' + val);
  }

  print(val: String): void {
    console.log('String: ' + val);
  }

  print(val: i32, count: i32): void {
    for (var i = 0; i < count; i = i + 1) {
      console.log(val);
    }
  }
}

let p = new Printer();
p.print(42); // Calls print(i32)
p.print('hello'); // Calls print(String)
p.print(7, 3); // Calls print(i32, i32)

Overload resolution is always static—the compiler picks the method based on the declared types at the call site, not the runtime type of arguments. This is different from virtual dispatch, which selects the method implementation at runtime based on the object's actual type.

zena
class Base {
  process(val: i32): i32 {
    return val;
  }
  process(val: f32): i32 {
    return 100;
  }
}

class Child extends Base {
  // Override only the i32 version
  process(val: i32): i32 {
    return val * 2;
  }
  // Inherits the f32 version from Base
}

let c: Base = new Child();
c.process(10); // Overload i32 selected at compile time
// Virtual dispatch calls Child.process(i32) → 20
c.process(3.14); // Overload f32 selected at compile time
// Calls Base.process(f32) → 100

Interfaces ​

Interfaces define contracts that classes must fulfill. A class can implement multiple interfaces. Interface values use "fat pointers" (object + vtable) at runtime.

zena
interface Drawable {
  draw(): void;
}

interface Resizable {
  resize(factor: f32): void;
}

class Circle implements Drawable, Resizable {
  radius: f32;

  new(radius: f32) {
    this.radius = radius;
  }

  draw(): void {
    // ...
  }

  resize(factor: f32): void {
    this.radius = this.radius * factor;
  }
}

Generic Interfaces ​

zena
interface Container<T> {
  get(): T;
  set(value: T): void;
}

Interface Inheritance ​

zena
interface Named {
  name: String { get; }
}

interface Person extends Named {
  age: i32 { get; }
}

Overloads in Interfaces ​

An interface can declare a method or operator more than once with different parameter types. Implementing classes provide every overload, and may return a narrower type than the interface declares.

zena
interface Seq {
  operator [](index: i32): i32;
  operator [](r: BoundedRange): Seq;
}

Mixins ​

Mixins provide reusable chunks of functionality that can be composed into classes. Unlike interfaces, mixins include implementation. A class can include multiple mixins using the with clause.

zena
mixin Timestamped {
  createdAt: i64;

  touch(): void {
    this.createdAt = getCurrentTime();
  }
}

mixin Named {
  name: String;
}

class Document with Timestamped, Named {
  content: String;

  new(name: String, content: String) {
    this.name = name;
    this.content = content;
    this.createdAt = getCurrentTime();
  }
}

Constraints (the on clause) ​

A mixin can restrict which classes it can be applied to by using the on clause. This allows the mixin to access fields and methods on this that are guaranteed by the constrained type:

zena
mixin Syncable on Entity {
  sync(): void {
    this.save();  // OK: save() is defined on Entity
  }
}

Satisfying Constraints and Interface Implementation ​

A target class satisfies a mixin's on constraint if the constraint type is assignable to:

  1. The target class's superclass.
  2. The target class's extension on type (if it is an extension class).
  3. Any interface in the target class's implements list.

This allows classes to implement an interface (e.g. Iterable<T>) via a mixin that is constrained to that same interface (e.g. on Iterable<T>). During type checking, this is resolved cleanly without circular dependency:

  1. The class declares implements Iterable<T>, which immediately satisfies the mixin's on Iterable<T> constraint.
  2. The mixin is applied, injecting the required utility methods into the class.
  3. The compiler validates that the class fully implements all methods in Iterable<T>, which succeeds because the mixin provided the required methods.

For example, HashMap implements Map<K, V> (which extends Iterable) by mixing in IterableUtils (which is on Iterable):

zena
// HashMap satisfies 'on Iterable' via 'implements Map'
export class HashMap<K, V> with IterableUtils<MapEntry<K, V>> implements Map<K, V> {
  // Directly implements the core iterator method
  :Iterable.iterator(): Iterator<MapEntry<K, V>> {
    return new HashMapEntryIterator<K, V>(this.#buckets);
  }

  // The utility methods (contains, all, fold, etc.) required by Iterable
  // are automatically supplied by IterableUtils!
}

Decorators ​

A field decorator replaces a class field with an accessor and adds the storage and state the accessor needs to the class itself. Declare one with decorator, naming a placeholder field (the subject); apply it with @name on a field. The decorated field's type binds the type parameter.

zena
export decorator signal<T>(var value: T) {
  var #value: T;            // storage: takes the decorated field's value
  var #version: i32 = 0;    // extra per-field state, no separate object
  value: T {
    get { return this.#value; }
    set(v) {
      this.#value = v;
      this.#version += 1;
    }
  }
}

class Counter {
  @signal var count: i32 = 0;   // c.count reads call the getter,
  @signal var label: String = ''; // writes call the setter
}

Rules: the body's one public member is the accessor named like the subject (setter exactly when the subject is var); #<subject> is the storage; everything else is private. A var subject applies to var fields, an immutable one to immutable fields. Decorator arguments and dotted names parse but are not applied yet.

Private members are one copy per decorated field; shared ones are one copy per class. on constrains the host so the body can call its API:

zena
decorator property<T>(var value: T) on ReactiveElement {
  var #value: T;
  shared var #changed: i32 = 0;   // one per class, all properties
  value: T {
    get { return this.#value; }
    set(v) {
      this.#value = v;
      this.#changed += 1;
      this.requestUpdate();        // from ReactiveElement
    }
  }
}

Arrays & Collections ​

Zena provides universal interfaces for indexed collections (Array<T> and MutableArray<T>), along with concrete implementations for fixed-size and resizable arrays, plus a hash map. All collections are generic and type-safe.

Array Hierarchy ​

  • Array<T>: The universal indexed read-only interface (length, operator [](i32): T, map, :Iterable.iterator()). Implemented by FixedArray<T>, GrowableArray<T>, and ImmutableArray<T>.
  • MutableArray<T>: The mutable indexed interface extending Array<T> (operator []=(i32, T): void). Implemented by FixedArray<T> and GrowableArray<T>.

Literals ​

An array literal is an ImmutableArray<T> — Wasm's immutable (array T) — unless the context wants mutable elements (a FixedArray, MutableArray, or array<var T> expected type), in which case it builds directly as a FixedArray<T>. The fixed(...) and growable(...) helpers supply that context in expression position:

zena
let nums = [1, 2, 3];                     // ImmutableArray<i32>
let buf: FixedArray<i32> = [1, 2, 3];     // FixedArray via the annotation
let inline = fixed([1, 2, 3]);            // FixedArray, no annotation, no copy
let grow = growable([1, 2, 3]);           // GrowableArray adopting the literal

FixedArray ​

FixedArray<T> has a fixed size set at creation and maps directly to a native WebAssembly GC array of mutable elements (array<var T>).

zena
let nums = fixed([1, 2, 3]); // FixedArray<i32>
let arr = new Array<i32>.fixed(10, 0); // Size 10, initialized to 0
arr[0] = 42;
let len = arr.length; // 10

GrowableArray ​

GrowableArray<T> is a dynamic, resizable array. growable([...]) adopts a literal as its storage without copying; GrowableArray.from(seq) copies from any array; new Array<T>() makes an empty one.

zena
let arr = growable([1, 2, 3]);
arr.push(4); // [1, 2, 3, 4]
let len = arr.length; // 4
let first = arr[0]; // 1
let last = arr.pop(); // 4

let empty = new Array<i32>(); // Empty growable array

ImmutableArray ​

ImmutableArray<T> is the immutable array — a distinct WebAssembly type from FixedArray<T>, with no subtyping or casting between them. Writes are compile-time errors, and the immutability is real at runtime, not a view over mutable storage. It is covariant in its element along the class chain and up to anyref — an ImmutableArray<Cat> is an ImmutableArray<Animal> — where a FixedArray is invariant.

zena
let immutable = [1, 2, 3];  // ImmutableArray<i32>
let first = immutable[0];
immutable[0] = 9;           // error: elements are immutable

Slicing ​

Use range syntax to slice any array, including through Array<T>. Slices are independent copies, and bounds outside the array are clamped to it.

zena
let arr = fixed([1, 2, 3, 4, 5]);
let slice = arr[1..4];    // [2, 3, 4] (a fresh FixedArray)
let rest = arr[2..];      // [3, 4, 5]
let front = arr[..2];     // [1, 2]

let sum = (xs: Array<i32>): i32 => xs.fold(0, (a: i32, x: i32) => a + x);
sum(arr[1..3]);           // 5, through the Array<i32> interface

A GrowableArray slice is a GrowableArray; a slice of a FixedArray or an immutable array (an array literal) is a FixedArray.

Map ​

Map<K, V> is a hash map. Keys must satisfy the Hashable interface (hashCode(): i32 plus operator ==, from Equatable): numeric primitives, boolean, String, enums, distinct types, and case classes satisfy it automatically; other classes must declare implements Hashable and define both hashCode() and operator ==, which must agree.

Maps can be created using literal syntax with =>:

zena
let scores = {"Alice" => 95, "Bob" => 87};  // Map<String, i32>
let lookup = {1 => "one", 2 => "two"};       // Map<i32, String>

Or constructed explicitly:

zena
let map = new Map<String, i32>();
map.set('one', 1);
map['two'] = 2;

Use get() for safe lookups—it returns (value, found):

zena
let(val, found) = map.get('one');
if (found) {
  // use val
}

The index operator map[key] throws KeyNotFoundError if the key doesn't exist.

Iteration ​

Use for-in to iterate over any collection that implements the iteration protocol.

zena
let arr = [1, 2, 3];
for (let item in arr) {
  // item is 1, 2, 3
}

let map = new Map<String, i32>();
for (let (key, value) in map) {
  // iterate over entries
}

Boxing ​

Primitive types (i32, f32, boolean) cannot be used in union types because they have a different memory representation than references. Use Box<T> to wrap primitives when needed.

zena
let maybeNumber: Box<i32>? = new Box(42);

if (maybeNumber != null) {
  let n = maybeNumber.value;
}

Boxing is always explicit ​

There is no any type and no implicit boxing. anyref is the top type for references, and a primitive is not assignable to it — wrap it first.

zena
let boxed: anyref = new Box<i32>(42); // allocation is visible
// let bad: anyref = 42;              // Error: no implicit boxing

if (boxed is Box<i32>) {
  let n = (boxed as Box<i32>).value; // 42
}

Exception Handling ​

Zena uses exceptions for error handling, compiled to WASM exception handling instructions.

Throw ​

The throw expression has type never and can be used anywhere an expression is expected.

zena
throw new Error("Something went wrong");

// throw has type 'never', can be used anywhere
let x: i32 = throw new Error("Boom");

Error Class ​

All thrown values must be Error or a subclass. Create custom error types by extending Error.

zena
class Error {
  message: String;
  new(message: String) {
    this.message = message;
  }
}

// Custom errors
class ValidationError extends Error {
  field: String;
  new(field: String, message: String) {
    super(message);
    this.field = field;
  }
}

The cancel Clause (async only) ​

A try in an async function may add cancel { ... } between the try block and any catch: the block runs when a cancellation unwinds the try, and the unwind then continues — catch (e) never sees a cancellation, and leaving the block with return/break/continue is rejected.

zena
try {
  let rows = await db.query(q);
} cancel {
  metrics.increment('query-abandoned');
} finally {
  releaseBuffers();
}

shielded Blocks (async only) ​

shielded { ... } masks cancellation checkpoints for its extent — cleanup that must await after cancellation has arrived runs to completion, and async work created inside binds to a scope no cancellation reaches. At the closing brace, delivery resumes.

Resource Management ​

using ​

using releases a Disposable when it leaves the enclosing block — on every path out, including return, break/continue, and exception unwind.

zena
import { Disposable } from 'zena:core';

class Lock implements Disposable {
  [Disposable.dispose](): void { release(this.handle); }
}

let update = (): void => {
  using guard = acquire(lock);
  // … use guard …
};   // released here

// Several in one block release in reverse declaration order
using a = open('a');
using b = open('b');   // b first

// A binding is optional
using acquire(lock);

dispose implementations must be idempotent and must not throw — they run on unwind paths. using declares the binding itself — no let or var, since a rebound name would release a stale value and leak the new one.

Host Imports ​

Use declare with @external to import functions from the host environment. These become WASM imports that must be provided by the host (JavaScript, WASI runner, etc.).

zena
@external("env", "log")
declare function log(val: i32): void;

@external("env", "now")
declare function now(): i64;

Intrinsics & Decorators ​

Intrinsics and decorators provide low-level control over code generation and enable standard library implementation.

@intrinsic ​

Intrinsics map to compiler-generated code or direct WASM instructions. Used primarily in the standard library.

zena
@intrinsic('eq')
declare function equals<T>(a: T, b: T): boolean;

@intrinsic('hash')
declare function hash<T>(val: T): i32;

@pure ​

Mark accessors as side-effect free. This enables the compiler to eliminate unused writes during dead code elimination.

zena
class Data {
  @pure
  value: i32 {
    get { return this.#backing; }
    set(v) { this.#backing = v; }
  }
  #backing: i32;
}

Type Casting ​

The as operator performs type casts. Numeric conversions compile to WASM conversion instructions. Reference type casts are checked at runtime and throw if invalid. Unnecessary casts (casting a value to its exact type) can be warned against by enabling the CLI flag --warn-unnecessary-casts.

zena
// Numeric conversions
let n: i64 = 100 as i64;      // i32 to i64
let f: f32 = 10 as f32;       // i32 to f32
let i: i32 = 3.14 as i32;     // f32 to i32 (truncates)

// Distinct types (zero-cost)
distinct type ID = i32;
let id = 42 as ID;

// Reference types (checked at runtime)
let obj: any = getObject();
let p = obj as Point;         // Throws if not a Point

// A cast to a reference type also types its operand
let names = [] as Array<String>;   // the literal's element type comes from the cast

Standard Library ​

Zena's standard library is organized into libraries. Types from the prelude are available without imports; other libraries must be explicitly imported.

zena:math ​

Math functions that map directly to WASM instructions—no runtime overhead.

zena
import {sqrt, floor, ceil, abs, min, max} from 'zena:math';

sqrt(16.0); // 4.0
floor(3.7); // 3.0
ceil(3.2); // 4.0
abs(-5.0); // 5.0
min(3.0, 7.0); // 3.0
max(3.0, 7.0); // 7.0

Also includes bit manipulation: clz (count leading zeros), ctz (count trailing zeros), popcnt (population count).

zena:simd ​

Two layers over WebAssembly SIMD, in one import.

Shaped types give a v128 a lane width and a lane type: I8x16, I16x8, I32x4, I64x2, F32x4, F64x2. They are extension classes over v128 and are erased, so a shape IS a v128 at runtime — every method is one instruction and nothing allocates.

zena
import {F32x4} from 'zena:simd';

let a = F32x4.splat(1.5 as f32);
let b = new F32x4(2.5 as f32);
let c = (a + b).withX(10.0 as f32);
c.x + c.y; // 10.0 + 4.0

Operators are elementwise, as in numpy and Rust: a * b multiplies lane i by lane i, and is not a dot product. Lane-combining operations are named methods. Four- and two-lane shapes name their lanes x/y/z/w with matching withX/withY/… copies; wider shapes use the instruction layer.

Missing on purpose: / on integer shapes (wasm SIMD divides floats only), * on I8x16 (no i8x16.mul), and == (wasm's lane comparison yields a mask, not a boolean). Negation is v.neg(), since Zena dispatches only binary operators to operator methods.

All 236 instructions are also exposed, one function each, lowered to that single instruction. Names transliterate the wasm names: split on . and _, then capitalize every segment but the first, so i32x4.add is i32x4Add and v128.load8x8_s is v128Load8x8S.

zena
import {i32x4Splat, i32x4Add, i32x4ExtractLane} from 'zena:simd';

let sums = i32x4Add(i32x4Splat(5), i32x4Splat(3));
i32x4ExtractLane(sums, 0); // 8

Arguments that wasm encodes as immediates must be literals: lane indices, the four words of v128Const, and the sixteen selectors of i8x16Shuffle. Loads and stores take an address only — the compiler picks the natural alignment. See simd.md.

zena:console ​

Console output for logging and debugging. The console global is automatically available in every library (via the prelude), so you don't need to import it.

zena
console.log('Hello, world!');
console.error('Something went wrong');
console.warn('This is a warning');
console.info('FYI');
console.debug('Debug info');

The console implementation is selected based on the --target flag:

  • --target js: Uses imported JavaScript functions (console.log_string, etc.) that the host must provide
  • --target component and --target zena-cli: Writes to stdout and stderr through wasi:cli@0.3.0's streams

This means the same Zena code works in both browser/Node.js environments and standalone WASI runtimes like wasmtime.

zena:core ​

The prelude's own library, and the home of everything the prelude does not bind: StringBuilder for efficient concatenation in a loop (see StringBuilder), StringReader for cursor-based parsing (see StringReader), ByteBuffer, Box<T>, and the ownership protocol.

zena
import {StringBuilder, StringReader} from 'zena:core';

zena:regex ​

A regular expression engine based on Thompson NFA (similar to RE2 and Go's regexp). Guarantees O(n×m) time complexity—no backtracking, no ReDoS vulnerabilities.

zena
import {Regex, regex} from 'zena:regex';

// Constructor
let r = new Regex('\\d+');

// Template tag (no double-escaping needed)
let r2 = regex`\d+\.\d+`;

// Matching
let m = r.match('abc123def');
if (m != null) {
  m.group(0); // "123"
}

// Flags: (?i) case-insensitive, (?m) multiline, (?s) dot-matches-newline
let r3 = regex`(?i)hello`;

Supported: Literals, alternation (|), character classes ([a-z], \d, \w, \s), quantifiers (*, +, ?, {n,m}), non-greedy (*?, +?), groups (capturing and non-capturing), anchors (^, $, \b).

Not supported (by design): Backreferences and lookahead/lookbehind—these require backtracking which breaks the linear time guarantee.

zena:crypto ​

SHA-256, for content fingerprints. sha256 returns the 32 digest bytes; sha256Hex and sha256HexOfString return them as 64 lowercase hex characters, and toHex renders any ByteArray that way.

zena
import {sha256Hex, sha256HexOfString} from 'zena:crypto';

sha256HexOfString('abc'); // 'ba7816bf8f01cfea...'
sha256Hex(readFileBytes(path));

zena:json ​

JSON parsing with typed accessors and optional comment support.

zena
import {parseJson, JsonObject, JsonArray} from 'zena:json';

let obj = parseJson('{"name": "Zena", "version": 1}') as JsonObject;
obj['name']; // "Zena" (as any)
obj['version']; // 1 (as any, boxed)

// With options
let config = parseJson(text, {
  allowComments: true, // Allow // and /* */ comments
  trackLocations: false, // Track source locations for errors
}) as JsonObject;

JSON values are represented as JsonObject, JsonArray, String, Box<f64>, Box<boolean>, or null.

zena:signals ​

Values that record which computations read them, with the semantics of the TC39 signals proposal, plus effects.

zena
import {State, Computed, effect, batch} from 'zena:signals';

let count = new State(0);
let doubled = new Computed<i32>((): i32 => count.get() * 2);

// Runs now, and again after anything it read changes.
let e = effect((): void => {
  console.log(i32ToString(doubled.get()));
});

count.set(1); // prints 2

// Effects run once the outermost batch finishes.
batch<void>((): void => {
  count.set(2);
  count.set(3);
}); // prints 6

e.dispose();

A Computed runs its function only when it is read after a source changed, and stops the update when its new value equals the old one. Watcher is told synchronously when a watched signal might have changed, for frameworks that schedule their own work. Tracker is a signal with no value, for classes that keep a reactive value in an ordinary field. See signals.md.

zena:test ​

Test framework for writing unit tests. Tests are defined using suite() and test() functions, and you must export a variable named tests.

zena
import {suite, test} from 'zena:test';
import {equal, isTrue} from 'zena:assert';

export let tests = suite('math', () => {
  test('adds numbers', () => {
    equal(1 + 1, 2);
  });

  test('comparisons', () => {
    isTrue(3 > 2);
  });
});

zena:assert ​

Assertion functions for tests. All assertions throw AssertionError on failure.

zena
import {equal, notEqual, isTrue, isFalse, isNull, isNotNull, throws} from 'zena:assert';

equal(actual, expected);           // actual == expected
notEqual(actual, expected);        // actual != expected
same(actual, expected);            // actual === expected (reference equality)
notSame(actual, expected);         // actual !== expected
isTrue(value);                     // value === true
isFalse(value);                    // value === false
isNull(value);                     // value === null
isNotNull(value);                  // value !== null
greater(a, b);                     // a > b
greaterOrEqual(a, b);              // a >= b
less(a, b);                        // a < b
lessOrEqual(a, b);                 // a <= b
throws(() => { ... });             // function throws an exception
doesNotThrow(() => { ... });       // function does not throw
fail('message');                   // always fails

zena:fs ​

File system operations (WASI target only).

zena
import {readFile, writeFile} from 'zena:fs';

let content = readFile('input.txt');
writeFile('output.txt', content);