Skip to specification

Wax language
specification

The complete reference for a deterministic, statically typed language built to make execution reproducible and the past inspectable.

Overview#

Wax is a statically typed, embeddable language built for perfect reproducibility. Given the same initial state and inputs, a Wax program will always follow the exact same execution path. That makes possible what traditional languages cannot do: time-travel debugging, cross-machine session replay, and production failures that can be reproduced locally, line by line.

Wax is designed to embed into host applications for game development and UI frameworks, providing a safe, high-performance execution environment where every run is deterministic and every recorded frame is reconstructable.


What Wax Makes Possible#

Record and Replay

Record a session on one machine and replay it perfectly on another. When a user encounters a bug, you don’t get a stack trace and a vague description. You get the exact recorded execution that caused it. Load that recording on your development machine and you’re debugging their session, not a reproduction attempt.

Time-Travel Debugging

Step forward and backward through execution. Made a change and want to see how the program got to a certain state? Rewind. Missed the moment a value changed? Go back. The entire execution history is navigable because every frame’s state can be reconstructed.

Low-Cost Recording

Recording adds work: the runtime saves required inputs and periodically creates recording keyframes. Wax keeps that cost low by using frame boundaries as natural keyframe points and excluding short-lived nursery objects from keyframes. Creating a keyframe piggybacks on work the garbage collector already performs at frame end, so it is mostly a bulk copy of the retained heap and runtime side tables rather than a second object trace. If 90% of a frame’s allocations do not survive promotion, none of those objects enter the keyframe. Supported native applications can also move keyframe processing onto a background thread. Recording cost is concentrated at frame boundaries and scales with retained state and recorded input.

Safe Embedding

Wax is built to live inside other programs. The host boundary is explicit and controlled: no raw pointers cross the boundary, no callbacks from Wax into the host, all data is copied or passed through typed opaque handles. The host controls frame boundaries, object lifetimes, and resource limits. Embedding Wax doesn’t mean trusting Wax.


How Wax Achieves This#

The capabilities above aren’t features bolted onto an existing language design. They emerge from constraints built into the language itself:

Deterministic Execution

Wax keeps host-facing execution single-thread-affine and eliminates sources of non-determinism. Restricted parallel kernels may execute on runtime workers only when their resources and effects are statically isolated; their observable result is independent of worker count and scheduling. There are no data races, no platform-dependent behavior, and no hidden state. The same inputs always produce the same observable result.

Frame-Based Memory Model

Execution is organized into discrete frames defined by the host (a render frame, an HTTP request, a simulation tick). Memory is managed by a hybrid garbage collector with nursery promotion, so short-lived allocations are reclaimed cheaply at frame boundaries without manual annotation.

Explicit Host Boundary

All communication between Wax and the host is declared: host fn for functions the host provides, api fn for Wax functionality exposed to the host. Data crossing the boundary is copied or serialized. The host never gets raw pointers to Wax memory, and Wax never holds raw pointers to host memory.


Design Principles#

Determinism Over Convenience

Where other languages might allow flexible memory models or platform-dependent behavior for ergonomics, Wax chooses predictability. Every language feature is evaluated against the question: “Does this preserve deterministic execution and replayability?”

Write What Runs

Code should be straightforward to reason about at a glance. No implicit type conversions, no method overloading with complex resolution rules, no hidden allocations. What you read is what executes.

Sharp Knives, No Footguns

Wax gives you low-level memory control and direct host integration, but with guardrails. You can write high-performance code without the classes of bugs (use-after-free, null dereference, data races) that make low-level programming dangerous.

Zero-Cost Abstractions

High-level features like generics, constraints, and iterator pipelines compile down to code as efficient as hand-written loops. You don’t pay runtime cost for expressiveness.


Use Cases#

Wax is designed for host-driven environments where precision and reproducibility are critical:

Game Development

Deterministic execution enables lockstep networking, perfect replays, and session recording. Time-travel debugging lets you step through gameplay frame by frame. When a player reports a bug, you can debug their exact session.

UI Frameworks

The state system is built for UI: declare per-call-site state with lifecycle hooks. Frame-based execution maps naturally to render passes. Snapshot and replay capabilities enable visual regression testing and state inspection.

Simulations

Scientific and engineering simulations require reproducibility. Wax guarantees that a simulation run today will produce identical results to the same run next year, on different hardware, with the same inputs.

Sandboxed Extensibility

When your application needs user-provided logic (mods, plugins, automation scripts), Wax provides a safe execution environment with controlled resource access and no ability to escape the sandbox.


Performance Target#

Wax targets approximately 75% of native C performance for compute-bound code. The combination of static typing, nursery-promoting GC, and zero-cost abstractions makes this achievable while maintaining safety and determinism.


Language Tour: The Basics#

Welcome to Wax! This tour will walk you through the fundamental building blocks of the language. It assumes you’ve read the Overview and are ready to see what Wax code looks like.

The examples in this chapter assume these ordinary stdlib imports:

Wax
import Debug from Wax;
import List from Wax;

Hello, World!#

Like any good tour, we’ll start with the simplest program: printing a message.

Wax
api fn Main() {
    Debug.Log("Hello, World!");
}

This example defines a function named Main that, when called, prints the string “Hello, World!”. We mark it api so that the host application can see it and invoke it.

Declaration Documentation#

A documentation block begins with /** and supplies plain text for the named declaration immediately following it. Indentation and a single line break may separate the block from the declaration; a blank line or another comment leaves the block unattached. Unattached blocks are ignored.

Wax
/**
 * Starts a new game for the selected player.
 */
api fn StartGame(string player) { }

The optional leading * decoration and common indentation are removed from the stored text. Documentation is plain text: @ words and Markdown punctuation have no special meaning. /**/ and /*** ... */ banners remain ordinary block comments; use /** */ for an empty documentation block.

Variables, Constants, and Types#

Wax is a statically-typed language, meaning every variable has a known type at compile time.

You can declare variables using an explicit type annotation or let the compiler infer the type using the var keyword.

Wax
// Explicitly typed variable
string message = "This is a string.";

// Type is inferred from the assigned value (string)
var inferredMessage = "This is also a string.";

// Variables are mutable by default
int counter = 0;
counter = counter + 1; // counter is now 1

For values that should not change, you can declare them as constants using the const keyword. Constants must be assigned a value when declared and cannot be changed later.

Wax
const float Pi = 3.14159f;
// Pi = 3.0; // This would cause a compile error

Primitive Types & Literals#

Wax comes with a standard set of primitive types for handling numbers, text, and logic.

  • Integers: For whole numbers. Wax supports various sizes and their unsigned counterparts. We support exact type sizes or more traditional integer type names.
Wax
// Signed integers
sbyte   // 8-bit signed  (alias: int8)
short   // 16-bit signed (alias: int16)
int     // 32-bit signed (alias: int32)
long    // 64-bit signed (alias: int64)

// Unsigned integers  
byte    // 8-bit unsigned  (alias: uint8)
ushort  // 16-bit unsigned (alias: uint16)
uint    // 32-bit unsigned (alias: uint32)
ulong   // 64-bit unsigned (alias: uint64)

Example:

Wax
int decimal = 42;
long bigNumber = 900_000_000L; // underscores can be used for visual separation
uint positiveOnly = 100u;
int32 hexValues = 0xff;
int32 binaryValues = 0b10_010101;
  • Floating-Point Numbers: For numbers with fractional parts.

    Wax
    float singlePrecision = 3.14f;       // 32-bit
    double doublePrecision = 3.14159265; // 64-bit
    
  • Booleans: For true/false logic.

    Wax
    bool isComplete = true;
    bool hasErrors = false;
    
  • Strings and Characters: For text. Strings are UTF-8 encoded and support interpolation, while characters represent a single UTF32 code point.

    Wax
    char letter = 'A';
    char emoji = '😀';
    
    string greeting = "Hello";
    string name = "Wax";
    string interpolated = "You can write $name code like this: ${1 + 1}"; // "You can write Wax code like this: 2"
    

Basic Operators#

Wax uses familiar operators for common operations.

  • Arithmetic: +, -, *, /, % (modulo)

    Wax
    int sum = 10 + 5;      // 15
    int difference = 10 - 5; // 5
    int product = 10 * 5;    // 50
    int quotient = 10 / 5;   // 2
    

    Note: Wax widens implicitly only when no data can be lost, meaning a smaller integer to a larger one (int to long), or float to double. Crossing between the integer and floating-point domains, or narrowing, always requires an explicit cast.

  • Comparison: == (equals), != (not equals), <, >, <=, >=

    Wax
    bool areEqual = (5 == 5); // true
    bool isGreater = (10 > 5); // true
    
  • Logical: && (and), || (or), ! (not)

    Wax
    bool result = (isComplete && !hasErrors); // true
    

Control Flow#

You can control the execution path of your code with conditional statements and loops.

  • if-else Statements

    Wax
    int score = 85;
    if (score > 90) {
        Debug.Log("Grade: A");
    } 
    else if (score > 80) {
        Debug.Log("Grade: B");
    }
    else {
        Debug.Log("Grade: C or lower");
    }
    
  • for Loops

    Wax
    // A traditional C-style for loop
    for (int i = 0; i < 5; i++) {
        Debug.Log("Loop iteration: $i");
    }
    
  • foreach Loops

    The foreach loop is the idiomatic way to iterate over collections. It uses a special “payload” syntax (|item|) to access each element.

    Wax
    List<string> names = new List<string>();
    names.Add("Alice");
    names.Add("Bob");
    names.Add("Charlie");
    
    foreach (names) |name| {
        Debug.Log("Hello, $name!");
    }
    
    // You can also get the index
    foreach (names) |name, index| {
        Debug.Log("$index: $name");
    }
    
  • Deterministic parallel Kernels

    A parallel statement applies a restricted, allocation-free kernel to a half-open index range. Its aligned resources are preflighted before any iteration begins, and the statement joins synchronously.

    Wax
    parallel (0, destination.size) : (
        readonly int32 value from source,
        out int32 result from destination,
    ) |index| {
        result = value + index;
    }
    

Functions#

Functions are reusable blocks of code that perform a specific task. You can define them with parameters and a return value.

Wax
// A function that takes two integers and returns their sum.
// The return type is specified after the colon.
fn Add(int a, int b) : int {
    return a + b;
}

// A function with no return value (implicitly returns void).
fn Greet(string name) {
    Debug.Log("Welcome, $name!");
}

// Calling the functions
int result = Add(10, 20); // result is 30
Greet("developer");       // Prints "Welcome, developer!"

Primitives and Literals#

Before you can build complex structs and classes, you need to understand the fundamental building blocks provided by the language. Primitive types are the simplest types, representing raw data like numbers, boolean values, and characters. A literal is how you write a fixed value directly in your source code.


Boolean (bool)#

The bool type represents a logical value and has only two possible literals: true and false.

Wax
bool isComplete = true;
bool hasErrors = false;

if (isComplete && !hasErrors) {
    // ...
}

Numeric Types and Literals#

Wax provides a standard set of integral and floating-point types for numeric data.

Integer Types#

Integer types represent whole numbers. They can be signed (positive or negative) or unsigned (positive only).

Type Size Range
sbyte 8-bit -128 to 127
byte 8-bit 0 to 255
short 16-bit -32,768 to 32,767
ushort 16-bit 0 to 65,535
int 32-bit -2,147,483,648 to 2,147,483,647
uint 32-bit 0 to 4,294,967,295
long 64-bit -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807
ulong 64-bit 0 to 18,446,744,073,709,551,615

Integer Literals You can write integer literals in several formats. By default, a numeric literal is treated as an int. You can use suffixes to specify a different type.

  • Decimal: 101
  • Hexadecimal: 0x65 (prefix 0x)
  • Binary: 0b01100101 (prefix 0b)

For readability, you can use an underscore _ as a separator anywhere within a numeric literal.

Wax
// Using suffixes to specify type
uint unsignedInt = 101u;
long longInt = 101l;
ulong unsignedLong = 101ul;

// Underscores can be placed anywhere for readability.
long bigNumber = 1_000_000_000l;
int hexValue = 0xDE_AD_BE_EF;
int binaryValue = 0b0101_0101;
Floating-Point Types#

Floating-point types represent numbers with fractional parts.

Type Size Precision
float 32-bit ~6-9 digits
double 64-bit ~15-17 digits

Floating-Point Literals Literals with a decimal point are treated as double by default. Use the f suffix for a float.

Wax
double pi_d = 3.14159; // Defaults to double
float  pi_f = 3.14159f; // Suffix 'f' makes it a float

double scientific = 1.23e4; // Scientific notation (1.23 * 10^4)
Type Aliases#

For clarity and compatibility with other systems, Wax provides explicit size-based aliases for the primitive numeric types. These can be used interchangeably with the standard names.

Alias Keyword
int8 sbyte
uint8 byte
int16 short
uint16 ushort
int32 int
uint32 uint
int64 long
uint64 ulong
Wax
// These declarations are equivalent.
int a = 10;
int32 b = 10;
Numeric Promotion and Casting#

To prevent accidental data loss, Wax treats numeric conversions differently for literals and variables.

  • Literal Promotion (Implicit): A numeric literal without a type suffix (e.g., 123) can be implicitly assigned to a larger numeric type if the value fits without any loss of data. The compiler can verify this at compile time.

  • Implicit Widening: When a value is used in a context that expects a larger type within the same domain, the compiler automatically promotes it. Integer-to-wider-integer (int to long, byte to int, etc.) and float to double are always safe, because no data can be lost, so no cast is required. This applies to assignments, binary operations, and function arguments.

  • Explicit Casting (Cross-Domain or Narrowing): Converting between different numeric domains (integer ↔ floating-point) or narrowing to a smaller type always requires an explicit cast. Wax provides three operators for this, each with a different safety guarantee.

    • as (Truncating Numeric Cast): Integer narrowing keeps the destination-width low bits. Floating-point to integer conversion first truncates the fractional part toward zero, then saturates to the destination range: values above or below the range become its maximum or minimum, and NaN becomes zero. The conversion never traps.
    • as? (Safe, Nullable Cast): Performs a safe conversion. If the conversion would result in data loss (e.g., the value is out of range), it returns null. This is the ideal operator for safely validating or handling data from external sources.
    • as! (Safe, Panicking Cast): Performs a safe conversion. If the conversion would result in data loss, the program will panic. Use this when you are certain a value should fit, and a failure represents a critical bug that should halt execution.
Wax
// --- Literal Promotion ---
long myLong = 123; // OK: Implicitly promotes 'int' literal.

// --- Variable Casting Examples ---
long bigValue = 3_000_000_000l;
float pi = 3.14f;

// Truncating 'as'
int truncated = bigValue as int; // Value wraps around, result is -1294967296
int truncatedFloat = pi as int;  // Value is truncated, result is 3
int saturatedFloat = 1.0e30 as int; // Value saturates, result is 2147483647

// Safe 'as?' - Returns null on data loss
int? noFit = bigValue as? int; // noFit is null
int? alsoNoFit = pi as? int;   // alsoNoFit is null
int? fits = 100l as? int;      // fits is an int? with value 100

// Safe 'as!' - Panics on data loss
// int panics = bigValue as! int; // PANIC! Value is out of range for int.
int mustFit = 100l as! int;    // OK: mustFit is 100
Type Safety in Operations#

Wax allows implicit widening within the same numeric domain. An int can be freely used alongside a long because widening an integer to a larger integer can never lose data. The same applies to float with double. The compiler inserts the promotion automatically.

However, conversions between integers and floating-point types always require an explicit cast, because these cross-domain conversions can change the representation of a value in subtle ways.

Wax
int a = 10;
long b = 20l;

// OK: 'a' is implicitly widened to long.
long c = a + b;

// Cross-domain requires explicit cast:
float f = 2.5f;
// double d = a + f;          // ERROR: int and float are different domains
double d = (a as double) + f; // OK: explicit cast to double

Characters (char)#

The char type represents a single 32-bit UTF-32 Unicode scalar value. Character literals are enclosed in single quotes.

A key safety feature of Wax is that a char is guaranteed to always be a valid Unicode value. The compiler will not allow you to create a char with an invalid or surrogate code point.

Wax
char letterA = 'A';

// Examples of Unicode escape sequences
char omega = '\u03A9';        // 4-digit hex, for Basic Multilingual Plane
char grinningFace = '\u{1F600}'; // Variable-length hex, for full Unicode range
char musicalG = '\U0001D11E';   // 8-digit hex, for full Unicode range
Escape Sequences#

Wax supports the following escape sequences within char and string literals:

Sequence Description
\' Single quote
\" Double quote (string only)
\\ Backslash
\n Newline (Line Feed)
\r Carriage Return
\t Horizontal Tab
\0 Null character
\uXXXX 4-digit Unicode escape
\UXXXXXXXX 8-digit Unicode escape
\u{...} 1-6 digit Unicode escape

Strings (string)#

The string type is a reference type that represents a sequence of characters.

String Literals#

A standard string literal is enclosed in double quotes. It cannot contain unescaped newlines. They support all the same escape sequences as char literals.

Wax
string greeting = "Hello, World!";
string empty = "";

// Using escape sequences in strings.
string path = "C:\\Users\\Default\\";
string multiline = "Line 1\nLine 2";
string withOmega = "The symbol for Omega is \u03A9.";
String Interpolation#

String literals support interpolation, so expressions can be embedded directly in the string.

  • $identifier: Embeds the value of a variable.
  • ${expression}: Embeds the result of a more complex expression.

Interpolation renders user-defined values through ToStringBuffer and renders a null nullable value as the literal null.

To include a literal $ character, escape it with a backslash: \$.

Wax
string user = "Alex";
int score = 120;

// Simple interpolation
string message = "User: $user, Score: $score"; // "User: Alex, Score: 120"

// Expression interpolation
string bonus = "Next level at ${score + 80} points."; // "Next level at 200 points."

string? missing = null;
string absent = "Value: $missing"; // "Value: null"

// Escaping the dollar sign
string price = "The cost is \$${score}."; // "The cost is $120."
Raw and Multiline Strings#

For strings that contain newlines or many special characters, you can use a raw string literal. A raw string is enclosed in three or more double quotes.

Raw strings can contain newlines and do not process most escape sequences (like \n or \\). However, they still support string interpolation.

Wax
// A simple multiline string
string poem = """
    The rose is red,
    The violet is blue,
    Wax is awesome,
    And so are you.
    """;

// Interpolation works the same way.
string report = """
    Report for: $user
    -------------------
    Final Score: $score
    """;

// You can use more quotes to allow """ inside the string.
string doc = """"
    This string can contain """, which is useful for examples.
    """";

Null Safety#

Wax is a null-safe language, which means the compiler helps you prevent errors that arise from trying to use a null value. Any type that is not explicitly marked as nullable cannot hold the null value.

To declare a nullable type, you append a ? to the type name.

  • Nullable reference types (string?) have no size overhead at runtime.
  • Nullable value types (int?, Point?) will be larger, as they must store an additional boolean flag.
Wax
string requiredText = "This can't be null.";
string? optionalText = null; // This is allowed.

int requiredInt = 10;
int? optionalInt = null; // This is also allowed.
Nullable Equality#

Two nullable values of the same type can be compared with == and != when the underlying type is a numeric primitive, bool, an enum, opaque, string, or a reference type. Nullable equality currently requires the operands to have the same nullable type exactly, including for reference types; for example, Base? and Derived? are not comparable with each other.

Nullable equality first compares the null state. Two null values are equal, and a null value is not equal to a non-null value. If both operands have values, Wax compares primitive, enum, opaque, and string values using the underlying type’s normal equality rule. For other reference types, it compares references.

Wax
int? a = null;
int? b = null;
int? c = 42;
int? d = 42;

bool bothNull = a == b;      // true
bool oneNull = a == c;       // false
bool sameValue = c == d;     // true
Safely Accessing Nullable Values#

Because a nullable variable might be null, Wax requires you to safely handle that possibility before you can use its value.

A direct comparison of a nullable local or value parameter with null also refines reads on the proven non-null path. The declaration remains nullable and assignments still target its original storage; assigning it ends the refinement. The fact can continue after an if when the null path exits, including through return, throw, panic, or unreachable.

Wax
fn Length(string? text) : int32 {
    if (text == null) {
        return 0;
    }
    return text.length; // 'text' is known non-null here
}

This refinement is intentionally limited to direct local == null and local != null checks. It does not apply to fields or other access paths, to ref/out or captured storage, or across assignment and loop joins where the value may have changed. A refined nullable value type is a read-only snapshot: its fields and elements can be read, but member writes and method calls require an explicit payload binding or ! unwrap so mutation cannot silently target a compiler-owned copy.

Safe Unwrapping with if#

The most common way is with the if payload syntax, which unwraps the value into a new, non-nullable variable that is only available inside the if block.

Wax
string? maybeName = GetUserName();

if (maybeName) |name| {
    // Inside this block, 'name' is a non-nullable string.
    Debug.Log("Hello, $name!");
}
else {
    // This block runs if maybeName was null.
    Debug.Log("Hello, guest!");
}
Conditional Unwrapping with Ternary#

For a more concise conditional, you can use the ternary expression with payload capture. It unwraps the nullable value and binds it for the “true” branch of the expression.

Wax
string? maybeName = GetUserName();

string greeting = maybeName ? |name|
    "Hello, $name!"
    : "Hello, guest!";
Providing a Default with ??#

You can provide a default value using the null-coalescing operator ??.

Wax
// If maybeName is null, use "guest" instead.
string name = maybeName ?? "guest";
Safe Chaining with ?.#

The null-conditional operator ?. safely accesses members, indexes elements, or invokes delegates on a nullable variable. If the variable is null, the expression short-circuits and returns null.

Wax
string? text = GetSomeText();

// Member access: ?.field
int32? length = text?.length;

// Chained calls: if any part is null, the result is null.
char? firstChar = text?.ToUpper()?.FirstChar();

// Element access: ?.[index]
int32[]? items = GetItems();
int32? first = items?.[0];

// Invocation: ?.(args)
fn(string) : int? parser = GetParser();
int? parsed = parser?.("42");
Forcing an Unwrap with !#

If you are certain that a nullable variable is not null, you can use the null-forgiving operator ! to access its value directly. Be careful: if the value is null at runtime, this will cause a panic.

Wax
string? maybeValue = "I am definitely not null";

// This is safe because we know the value is not null.
string value = maybeValue!;

// This would cause a panic at runtime.
string? nullValue = null;
string badAccess = nullValue!; // PANIC!

Other Fundamental Concepts#

Beyond simple literals, several other keywords and types are fundamental to the language.

Type Inference with var#

You can use the var keyword to declare a variable without explicitly stating its type. The compiler will infer the type from the value assigned to it. This is particularly useful for keeping code concise when the type is obvious.

Wax
var i = 10;          // Inferred as int
var pi = 3.14;       // Inferred as double
var name = "Alex";   // Inferred as string
var isDone = true;   // Inferred as bool
Compile-Time Constants with const#

Use the const keyword to declare a value that is fixed at compile time. The value of a constant cannot be changed at runtime, and the compiler will substitute the value wherever it is used.

The expression used to define a const must itself be resolvable at compile time. This includes:

  • Literals (123, "hello", true).
  • Other const values.
  • Enum members.
  • sizeof(T) expressions.
  • Expressions combining the above, such as mathematical operations or string interpolation.
Wax
const int MaxUsers = 100;
const int MaxAdmins = 10;
const int TotalAccounts = MaxUsers + MaxAdmins; // Math on consts

const string AppName = "My Awesome App";
const string Version = "1.0";
const string AppTitle = "${AppName} v${Version}"; // String interpolation

flags Permissions { case Read=1; case Write=2; }
const Permissions ReadWrite = Permissions.Read | Permissions.Write; // Enum ops
The object Type#

object is the universal base type for all reference types. This means any instance of a class, string, or other reference type can be assigned to a variable of type object. It is the root of the class hierarchy.

Wax
object obj1 = new MyClass();
object obj2 = "a string is a reference type";
The void Type#

The void type is a special type that indicates the absence of a value. You cannot declare a variable of type void, but it is used as the return type for functions that do not return a value.

Wax
fn LogMessage(string message) : void {
    // This function performs an action but does not return a value.
}
The json Type#

The json type is a first-class dynamic data type for working with structured data. JSON values can represent nulls, booleans, numbers, strings, arrays, and objects. The json type is covered in detail in its own chapter.

Wax
json config = { name: "MyApp", version: 1, debug: true };
json items = [1, 2, 3];
string name = config.name!;
The opaque Type#

The opaque type represents an unvalidated handle to a resource managed by the host application. Wax can store and pass around opaque values but cannot inspect or manipulate them. This is the standard way to reference host-side resources like window handles, file handles, or graphics resources across the host boundary.

Wax
host fn CreateWindow(int32 width, int32 height) : opaque;
host fn DestroyWindow(opaque handle);
Compiler-Known Algebraic Types#

Wax provides compiler-known value types for common graphics, math, and simulation domains. Lowercase type spellings are reserved, import-free built-ins. Every user-declared type must begin with an uppercase letter. Uppercase nominal type names use ordinary lookup and must be declared in the current source or imported. Uppercase declarations that back lowercase built-ins inside the compiler’s built-in module are implementation details and cannot be imported by application modules.

Vector types: float2, float3, float4 represent 2D, 3D, and 4D floating-point vectors.

Matrix types: float3x2, float3x3, float4x4 represent common transformation matrices.

Color types: color8, color16, color32 are reserved, import-free built-ins representing colors at different bit depths (8-bit per channel RGBA, 16-bit per channel, and 32-bit float per channel).

Rotation shape type: quaternion is a reserved lowercase algebraic keyword representing 3D rotations.

Domain types: timespan, angle, timepoint are reserved, import-free built-ins representing durations, angular values, and monotonic clock samples.

SIMD types: the width-explicit vector families such as int8x16, uint32x4, and float64x2 are reserved lowercase algebraic keywords.

Import the uppercase stdlib nominal type before use:

Wax
import StringBuilder from Wax;

Lowercase stringbuilder has no special type meaning and is available as a normal identifier.

Parameterless Construction#

Primitive types expose a public parameterless constructor through their capitalized Wax wrapper declaration. The constructor is defined in the standard library and produces the primitive’s zero value, so generic code can construct a primitive through an ordinary constructor constraint:

Wax
int32 direct = new int32(); // 0

constraint Constructable {
    constructor();
}

fn Create<T>() : T where T is Constructable {
    return new T();
}

int32 fromGeneric = Create<int32>(); // 0

This is constructor invocation and is distinct from default: default never invokes user code, while new T() is permitted only when the selected type has a matching parameterless constructor.

The default Keyword#

The default keyword is an expression that returns the type’s canonical zero value. It never allocates an object, invokes user code, or supplies arguments to a constructor. Its behavior is strictly defined to ensure type safety.

  • For numeric types: Returns 0.
  • For bool: Returns false.
  • For char: Returns \0.
  • For nullable types (T?): Returns null.
  • For json: Returns the JSON null value.
  • For enums: If a case is explicitly marked with default, returns that case. Otherwise, returns the case whose value is 0, if one exists. If neither exists, the enum type is not compatible with default, and using it is a compile-time error.
    Wax
    enum Color {
        default case Black; // This case is the default
        case Red;
        case Blue;
    }
    Color c = default; // c is Color.Black
    
  • For value-type structs: Returns an all-zero instance when that bit pattern is a valid value for every instance field. A struct containing a non-nullable reference, or an enum without a valid zero/default case, is not compatible with default.
  • For reference types: A nullable reference returns null. A non-nullable reference is not compatible with default; default does not invoke a parameterless constructor. Use new MyClass() when construction is intended.
Wax
// --- Valid uses of default ---
int i = default;         // i is 0
string? s = default;     // s is null

struct Point { float x; float y; }
Point p = default;       // p is a Point where p.x=0.0f, p.y=0.0f

enum State { case Idle = 0; case Running = 1; }
State state = default;   // state is State.Idle

class Player { constructor() { /* ... */ } }
Player player = new Player(); // Invokes the parameterless constructor

// --- Invalid uses of default ---
// string name = default; // ERROR: string is a non-nullable reference.

// enum Status { case Ok = 1; case Error = 2; }
// Status s = default; // ERROR: Status has neither a default case nor a zero-valued case.

// struct User { string name; }
// User u = default; // ERROR: User has no valid all-zero value.

Detailed Numeric Parsing Errors#

Integer and floating-point types provide ParseDetailed when a caller needs to distinguish failures. It throws ParseError, whose readonly kind and position fields identify the failure and its UTF-8 byte offset. The public ParseErrorKind cases are Empty, NoDigits, InvalidCharacter, NegativeUnsigned, OutOfRange, and InvalidRadix. Overflow is always reported as OutOfRange; internal accumulator details are not exposed. Its message includes both the byte position and the public kind name.

The nullable Parse convenience remains appropriate when the reason does not matter.

Regular Expressions (Regex)#

Wax provides built-in regular expression support through the regex() operator. It creates a Regex object and has two forms depending on how the pattern is provided.

Regex, RegexOptions, and RegexError are ordinary uppercase stdlib types and require imports. The examples in this section assume:

Wax
import Regex from Wax;
import RegexOptions from Wax;
import RegexError from Wax;
Regex Literals#

When the pattern is known at compile time, use a /pattern/ literal inside regex(). The compiler validates the pattern during compilation, so an invalid pattern is a compile-time error rather than a runtime failure.

Wax
Regex digits = regex(/\d+/);
Regex email = regex(/[a-zA-Z]+@[a-zA-Z]+\.[a-zA-Z]+/);

// With flags
Regex caseInsensitive = regex(/hello/, RegexOptions.IgnoreCase);

// Invalid patterns are compile errors
// Regex bad = regex(/[unclosed/);  // ERROR: missing terminating ] for character class

Because the pattern is validated at compile time, no error handling is needed, and the Regex object is guaranteed valid.

Patterns inside / delimiters are raw, so backslashes are literal and \d means “digit” rather than an escape sequence. This avoids the double-escaping required in string literals.

String Literal Patterns#

You can also pass a string literal to regex(). The compiler validates it at compile time, just like a / literal:

Wax
Regex r = regex("\\d+");  // validated at compile time (note: double backslash in strings)
Dynamic Patterns#

When the pattern comes from a variable (user input, configuration, etc.), the compiler cannot validate it at compile time. In this case, regex() can fail at runtime, and you must use try/catch:

Wax
fn CreateRegex(string pattern) : Regex {
    return try regex(pattern) catch |e| {
        yield regex(/./);  // fallback
    };
}

Calling regex() with a dynamic string without try is a compile-time error. On failure it throws RegexError; message describes the syntax problem and the readonly position field is the UTF-8 byte offset in the original pattern.

Flags#

Pass RegexOptions as a second argument to regex():

Wax
Regex r = regex(/hello/, RegexOptions.IgnoreCase);

Inline modifier groups such as (?i) are not supported; flags are passed as the second argument only. The group forms the pattern syntax does accept are the non-capturing (?:...) and the lookarounds (?=...) / (?!...).

Available flags: IgnoreCase, Multiline (^/$ match line boundaries), Singleline (. matches newlines), FreeSpacing (ignore whitespace and # comments in pattern).


Operators in Wax#

Wax provides a familiar set of operators for performing arithmetic, logical, and other common operations. The language is designed to be predictable, so operator behavior is consistent and straightforward.

The examples in this chapter assume these ordinary stdlib imports:

Wax
import Debug from Wax;
import Dictionary from Wax;
import Error from Wax;

Operator Precedence#

Wax follows a standard operator precedence, similar to other languages in the C family. For example, multiplicative operators (*, /, %) have higher precedence than additive operators (+, -). Parentheses () can always be used to explicitly control the order of evaluation.

Standard Operators#

The following table lists the standard operators available in Wax, grouped by category.

Category Operator Description
Unary +, - Unary Plus / Minus (Negation)
++, -- Pre/Post-Increment and Decrement
!, ~ Logical NOT, Bitwise NOT
Arithmetic *, /, % Multiplication, Division, Modulo
+, - Addition, Subtraction
Bitwise Shift <<, >> Left Shift, Right Shift
Comparison <, >, <=, >= Relational
==, != Equality
Bitwise & Bitwise AND
^ Bitwise XOR
| Bitwise OR
Logical && Logical AND (short-circuiting)
|| Logical OR (short-circuiting)
Assignment =, +=, -=, *=, /=, %= Assignment and Compound Assignment
&=, |=, ^=, <<=, >>= Compound Bitwise Assignment

Shift-Count Masking#

The shift operators << and >> are total: the shift count is masked to the operand’s bit width before shifting, so x << n and x >> n use n & 31 for a 32-bit operand and n & 63 for a 64-bit operand. Every count is well-defined, including counts greater than or equal to the width and negative counts. For example, x << 32 on a 32-bit value is x << 0, which is x. This behavior is identical on every backend.

Wax includes special operators for safely and concisely working with nullable types (T?).

Null-Conditional Operator: ?.#

The null-conditional operator accesses members, calls methods, or indexes elements on a nullable variable. If the variable is null, the expression short-circuits and evaluates to null instead of causing a panic.

The ?. operator supports three forms:

Wax
string? name = GetPlayerName(); // This might return null

// Member access: ?.field
int32? length = name?.length;

// Element access: ?.[index]
int32[]? numbers = GetNumbers();
int32? first = numbers?.[0];

// Invocation: ?.(args)
fn<(int32) : string>? formatter = GetFormatter();
string? result = formatter?.(42);

Note: A standalone ? is always the ternary operator (cond ? trueExpr : falseExpr). All null-conditional forms use ?. as the operator.

A ?. chain that ends in a call is also valid as a statement, where it means call if non-null, otherwise do nothing:

Wax
Logger? logger = GetLogger();
logger?.Write("started");        // void method: runs only when logger is non-null
logger?.Flush();                 // a non-void result is discarded, like `Flush();`

fn<()>? onTick = GetTickHandler();
onTick?.();                      // delegate form

In statement position a void callee is allowed, which is the only way to invoke one conditionally, and a non-void result is discarded exactly as it is for a plain foo();. A ?. chain that does not end in a call (logger?.name;) is rejected, because it computes a value nobody consumes. When the receiver is statically non-nullable the ?. is pointless and the compiler warns.

Null-Coalescing Operator: ??#

The null-coalescing operator provides a default value for a nullable type. It returns the left-hand operand if it’s not null; otherwise, it returns the right-hand operand.

Wax
string? playerName = GetConfiguredName(); // Might be null

// Provide a default value if playerName is null.
string displayName = playerName ?? "Guest";

The right-hand side may instead be a diverging bail term (throw, panic, or unreachable), which yields no value. The result is then the left operand’s non-null type, and the null branch bails out instead of producing a fallback. This is the single-value, expression-position counterpart to guard:

Wax
Player p = playerName ?? throw new Error("no name");   // in a `throws` fn
Player p = playerName ?? panic("no name");

Null-Forcing (Panic) Operator: !#

The postfix ! operator is used to assert that a nullable value is not null. It unwraps the value, returning the non-nullable type. If the value is null at runtime, the program will panic. This should only be used when you are absolutely certain the value cannot be null.

Wax
string? requiredValue = GetValueFromDatabase();

// I am certain this value exists. If it doesn't, it's a critical error.
string unwrappedValue = requiredValue!; // Panics if requiredValue is null.

Wax provides several operators for testing and converting types.

Type Testing Operator: is#

The is operator is a purely boolean operator. It performs a runtime check to see if an object is of a certain type and returns true or false.

Wax
object shape = GetShape();
bool isCircle = (shape is Circle); // isCircle is true or false

if (isCircle) {
    // We know the shape is a Circle, but we still need to cast it.
    var c = shape as! Circle;
    Debug.Log("It's a circle with radius ${c.radius}");
}

Type Casting Operators: as, as?, as!#

These operators handle converting an object from one type to another.

  • as (Compile-Time Safe Cast / Truncating Numeric Cast): For reference types, as performs conversions that the compiler can guarantee are safe, such as upcasting to a base class or interface. Integer narrowing keeps the destination-width low bits. Floating-point to integer conversion truncates toward zero and then saturates to the destination range, with NaN becoming zero; it never traps. See the Numeric Promotion and Casting section for details.

    Wax
    Dog myDog = new Dog();
    Animal myAnimal = myDog as Animal; // Safe upcast
    
  • as? (Safe Dynamic Cast): This performs a runtime check. If the cast is successful, it returns the re-typed object. If it fails, it returns null. This is the recommended operator for downcasting when the result of the cast is needed as a value.

    Wax
    fn Process(Animal animal) {
        Dog? maybeDog = animal as? Dog;
        if (maybeDog) |d| {
            // Safely use 'd' as a Dog
        }
    }
    
  • as! (Forced Dynamic Cast): This performs a runtime check. If the cast is successful, it returns the re-typed object. If it fails, the program will panic. This should only be used when you are certain the cast will succeed and a failure represents a critical program error.

    Wax
    Animal animal = GetDogFromDatabase(); // We are certain this is a Dog
    Dog myDog = animal as! Dog; // Panics if it's not a Dog
    

Operator Overloading and Indexers#

A core design principle of Wax is simplicity and predictability. For this reason, custom operator overloading is not supported for user-defined types. You cannot define custom behavior for operators like +, -, ==, etc. on your own class or struct.

Built-in Vector and Matrix Overloads#

The only exception to this rule is for the built-in mathematical vector and matrix types (e.g., float2, float3, float4, float4x4). These types have standard operator overloads for common mathematical operations.

Wax
var a = new float2(1, 2);
var b = new float2(3, 4);

// This works because float2 has a built-in operator overload for '+'.
var c = a + b; // c is (4, 6)

Custom Indexers []#

While you cannot overload most operators, Wax does allow user-defined types to implement custom indexers. An indexer defines what myObject[index] means for your type, and is the closest Wax comes to custom operator overloading. A type can define at most one indexer, which can have a getter, a setter, or both, following the same rules as properties.

Indexers are declared with get this(...) and set this(...), using the same syntax as properties. The getter’s parameters define the index signature, and its return type defines the element type; the setter takes the same index parameters plus a trailing value parameter.

Wax
class Scoreboard {
    private Dictionary<string, int> scores;

    constructor() { this.scores = new Dictionary<string, int>(); }

    // Custom indexer implementation
    public get this(string playerName) : int {
        return try this.scores[playerName] catch { yield 0; };
    }

    public set this(string playerName, int value) {
        this.scores[playerName] = value;
    }
}

var board = new Scoreboard();
board["Alice"] = 100; // Uses the 'set' indexer
int score = board["Alice"]; // Uses the 'get' indexer

Control Flow#

Control flow statements allow you to direct the execution path of your program based on conditions and loops. Wax provides a standard set of control flow structures that will be familiar to developers coming from languages like C#, Java, or C++.

Conditional Execution: if and else#

The if statement executes a block of code only if a specified condition is true. It can optionally be followed by else if and else blocks to handle other cases.

Wax
int score = 85;

if (score >= 90) {
    Debug.Log("Grade: A");
} 
else if (score >= 80) {
    Debug.Log("Grade: B");
}
else {
    Debug.Log("Grade: C or lower");
}
if with Payload Unwrapping#

The if statement can also be used to safely unwrap nullable types or perform type tests. If the condition is successful, the unwrapped value is available inside the if block in a new, non-nullable variable.

Wax
string? maybeName = GetUserName();

// The 'if' statement checks if maybeName is not null.
// If it isn't, the value is unwrapped into the 'name' variable.
if (maybeName) |name| {
    Debug.Log("Hello, $name!"); // 'name' is a non-nullable string here.
}

// This also works for type testing with reference types.
object data = GetSomeAnimal();
if (data is Dog) |dog| {
    // 'dog' is a non-nullable variable of type Dog here.
    Debug.Log("The dog's name is ${dog.name}.");
}

Several conditions can be combined in one if. The comma-separated list behaves like && (left-to-right, short-circuit): the body runs only when every condition holds, and a later condition is not evaluated once an earlier one fails. Payload slots map to the conditions positionally. A plain boolean condition gates without a slot, and _ discards a slot so a later condition can still be bound.

Wax
// Both must be non-null; a0 and b0 are bound in the body.
if (firstUser, secondUser) |a0, b0| {
    Debug.Log("$a0 and $b0");
}

// A bool guard just gates (no slot); the nullable is unwrapped.
if (maybeName, isReady) |name| {
    Debug.Log("Hello, $name!");
}

// `_` steps past a condition to bind a later one.
if (isReady, maybeName) |_, name| {
    Debug.Log("Hello, $name!");
}

Naming a non-nullable (e.g. a bool) slot is an error, because there is nothing to unwrap. Each condition is evaluated in the enclosing scope, so a condition cannot reference a binding introduced by an earlier condition (keep those nested).

guard: capture-or-bail#

guard shares the if condition-list and payload syntax, but with two differences: the payload binds downward into the enclosing scope (so the unwrapped value is live after the statement, flat, with no nesting), and the mandatory else runs when the gate fails and must diverge (return, break, continue, throw, panic, unreachable, yield, or a block / if-else composed of those; yield is diverging inside a catch/pipeline). There is no if-style body, because the success continuation is the enclosing scope itself.

Wax
guard (maybeName) |name| else return;
Debug.Log("Hello, $name!");                 // `name` is non-null here

guard (firstUser, secondUser) |a, b| else { return; }   // both bindings escape
guard (hp > 10, target) |_, t| else return;             // `_` gates a bool; `t` binds
guard (hp > 10) else return;                             // boolean-only precondition

The binding is a snapshot (an unwrapped local copy), which is sound regardless of later mutation of the source. If the else can fall through, as in else { } or else { Log(); }, it is an error: the else must diverge.

The switch Statement#

The switch statement provides a clean way to compare a single value against a list of possible cases. In Wax, the value being switched on must be an integer type, char, enum, or string. More complex pattern matching is not supported.

Each case must be enclosed in curly braces {}, which creates a new scope.

Wax
enum Status {
    case Pending;
    case Running;
    case Completed;
    case Failed;
}

Status currentStatus = GetStatus();

switch (currentStatus) {
    case Status.Pending: {
        Debug.Log("The task is waiting to start.");
    }
    case Status.Running: {
        Debug.Log("The task is in progress.");
        if (NeedsImmediateAttention()) {
            Debug.Log("Breaking out of case block early.");
            break; // Optional: exits the current case block.
        }
        // ... more processing ...
    }
    case Status.Completed: {
        Debug.Log("The task finished successfully.");
    }
    default: {
        Debug.Log("Unknown status.");
    }
}

Note: Each case must be enclosed in curly braces {}, which prevents accidental “fall-through.” The break statement is optional and can be used to exit a case block early.

Advanced switch Features#

The switch statement supports more advanced patterns for greater flexibility.

  • Multiple Cases: You can specify multiple case expressions for a single block, separated by commas.
  • when Clause: You can add a when clause to a case to provide an additional boolean condition.
Wax
char input = GetInputChar();

switch (input) {
    // Multiple cases for one block.
    case 'y', 'Y': {
        Debug.Log("Affirmative.");
    }

    case 'n', 'N': {
        Debug.Log("Negative.");
    }

    // A case with an additional 'when' condition.
    case 'd': when (IsDebugModeEnabled()) {
        Debug.Log("Entering debug mode.");
    }

    default: {
        Debug.Log("Unknown command.");
    }
}

Ternary Expression#

For simple conditional assignments, you can use the ternary expression (? :) as a compact alternative to an if-else block. It evaluates a condition and returns one of two expressions.

Wax
int score = 75;
string result = (score > 50) ? "Pass" : "Fail";
// result is "Pass"

// Like 'if', the ternary expression also supports payload unwrapping.
string? maybeName = GetName();
string displayName = maybeName ? |name| name : "Guest";

// Either arm may instead be a diverging bail term (`throw` / `panic` /
// `unreachable`) that yields no value, and the result takes the other arm's type.
int port = config.HasPort ? config.Port : throw new ConfigError("no port");

Loops#

Loops are used to execute a block of code repeatedly.

The for Loop#

The for loop is ideal when you know how many times you want to iterate. It consists of an initializer, a condition, and an iterator.

Wax
for (int i = 0; i < 5; i++) {
    Debug.Log("Current number is: $i");
}
The while Loop#

The while loop executes as long as a specified condition remains true. The condition is checked before each iteration. Like the if statement, while also supports payload syntax to safely unwrap nullable values.

Wax
// A simple while loop
int countdown = 3;
while (countdown > 0) {
    Debug.Log(countdown);
    countdown = countdown - 1;
}
Debug.Log("Liftoff!");

// A while loop with payload unwrapping, useful for linked lists.
Node? currentNode = GetLinkedListHead();
while (currentNode) |node| {
    ProcessNode(node);
    currentNode = node.next; // Loop continues as long as .next is not null.
}
The do-while Loop#

The do-while loop is similar to the while loop, but the condition is checked after the block executes. This guarantees that the loop body will run at least once.

Wax
// This loop will execute once, even though the condition is false.
int i = 10;
do {
    Debug.Log("This will print once.");
    i++;
} while (i < 5);
The foreach Loop#

The foreach loop is the most convenient way to iterate over all elements in a collection. It uses a special “payload” syntax (|item|) to access each element.

Wax
import List from Wax;

var names = new List<string>();
names.Add("Alice");
names.Add("Bob");
names.Add("Charlie");

// Iterate through each name in the list.
foreach (names) |name| {
    Debug.Log("Hello, $name!");
}

// You can also get the index of the current element.
foreach (names) |name, index| {
    Debug.Log("User $index is $name.");
}
Keyed Iteration with key#

In a state foreach loop (see the State System chapter), each iteration maintains its own persistent state. By default, state is keyed by index, but if items can be reordered, you should provide an explicit key expression with the key clause. This ensures state follows the identity of each element rather than its position.

The key clause goes after the payload capture, before the body. The expression is evaluated per iteration with the payload variable(s) in scope, and it can be a field access, method call, external function call, or any inline expression producing a Hashable value.

Wax
state foreach (users) |user| key user.id {
    state bool isExpanded = false;
    // Each user's isExpanded state is tied to their id, not their position.
}

The key clause is only valid on state foreach loops. See the State System chapter for full details.

Deterministic Parallel Kernels#

A parallel statement applies one restricted kernel to each logical index in the half-open interval [start, end). The optional payload binds the current index.

Wax
parallel (0, destination.size) : (
    readonly int32 value from source,
    out int32 result from destination,
) |index| {
    result = value * scale + index;
}

Each binding has the form mode Type name from source, where mode is:

  • readonly: read the element at the current index;
  • ref: read and then write back the current element;
  • out: assign and write back the current element.

Registered sources are arrays, List<T>, Span<T>, and ReadOnlySpan<T> when their mutability permits the selected mode. The source is a path, not an arbitrary producer expression. The collection itself is not visible inside the body; only the aligned element and registered read-only span capabilities are.

Before the first kernel invocation, the runtime validates the range, verifies that every aligned source covers end, and checks the complete alias set. Writable ranges must be disjoint from every distinct writable or read-only range; read-only ranges may overlap each other. Any failure panics before an iteration runs, so preflight never leaves a partially updated result.

The compiler admits only kernels whose effects are independent across logical indices. In particular, a kernel cannot allocate, read or write statics, call a host or API boundary, use dynamic/delegate dispatch, nest parallel, escape an error, or publish reference-bearing output. Captures are immutable data values. break and a return from the enclosing function cannot cross the kernel boundary. A continue at kernel-body level ends the current logical invocation; definite assignment requires every out binding to be assigned first. A continue inside a nested loop retains its ordinary loop meaning.

The statement is synchronous and joins all iterations before control continues. An implementation may execute the fixed kernel serially or on runtime workers; worker count, chunking, scheduling, and completion order do not change its observable result. Wasm executes the same semantics serially. Instrumented and profiled native builds also use the serial path so debugger and profiler state remain app-thread-owned.

Loop Control Statements#

You can alter the execution of a loop using the break and continue statements.

  • break: Immediately terminates the innermost loop.
  • continue: Skips the remainder of the current iteration and proceeds to the next one.
Wax
// An example using both break and continue.
for (int i = 0; i < 10; i++) {
    if (i % 2 == 0) {
        // Skip even numbers.
        continue;
    }

    if (i > 7) {
        // Stop the loop entirely if the number is greater than 7.
        break;
    }

    Debug.Log("Processing odd number: $i");
}
// Output:
// Processing odd number: 1
// Processing odd number: 3
// Processing odd number: 5
// Processing odd number: 7

Functions in Wax#

Functions are the primary building blocks for executable code in Wax. They can be declared at the top level of a file, as methods within a type, or defined anonymously as lambdas.

Functions are first-class values in Wax. They can be stored in variables, passed as arguments, and returned from other functions.

Function Declaration#

All functions are declared using the fn keyword, followed by the function’s name, a parameter list in parentheses (), an optional return type, and a body enclosed in curly braces {}.

Wax
// A simple function with no parameters that returns nothing (void is implicit).
fn DoSomething() {
    // ...
}

// A function with parameters and an explicit return type.
fn Add(int a, int b) : int {
    return a + b;
}

A Core Principle: No Overloading#

A fundamental design choice in Wax is that it does not support function overloading. This means that within a given scope (e.g., at the top level, or within a single type), you cannot declare multiple functions with the same name, even if they have different parameter lists.

This principle simplifies the language significantly by eliminating complex method resolution rules. A call to MyFunction(...) is therefore never ambiguous. To provide the flexibility that overloading offers in other languages, Wax relies on features like:

  • Default Parameter Values: To simulate overloads with fewer arguments.
  • Named Arguments: To provide clarity when calling functions with many parameters.
  • Named Constructors: To provide multiple ways to initialize an object.

Function Parameters and Arguments#

Function signatures can be modified with several keywords and feature syntaxes to control how data is passed.

Default Parameter Values#

Parameters can be made optional by providing a default value. The default value itself can be any valid expression (including constants, function calls, or new expressions), which is evaluated only if the argument is not provided at the call site. When the argument is omitted, a fresh evaluation of the default expression is inlined at that call site, so a default such as new Point(0, 0) constructs a new object on each defaulted call rather than a single shared instance.

The default expression is type-checked once, at the declaration, in the function’s own scope (its using directives, imports, and enclosing generic type parameters), not in the scope of each caller. This has three consequences:

  • An invalid default is reported exactly once, at the declaration, even if the function is never called or every call supplies the argument explicitly.
  • The default resolves against the callee’s visibility, so it may name types the caller has not imported.
  • The default is a standalone expression evaluated before parameters are bound, so it cannot reference other parameters or this.

A default may name the enclosing type’s or method’s generic type parameters; each instantiation substitutes them concretely:

Wax
struct Box<T> {
    public int32 tag;
    public constructor Tagged() { tag = 7; }
}

// `new Box<T>()` in the default resolves T per instantiation.
fn Pick<T>(int32 a, Box<T> b = new Box<T>.Tagged()) : int32 {
    return a + b.tag;
}

Pick<int32>(10);  // default builds a Box<int32>
Pick<string>(20); // default builds a Box<string>

As a general rule, once a parameter is given a default value, all subsequent parameters must also have default values.

Wax
// Valid: 'enabled' has a default, and it's the last parameter.
fn CreateEntity(string name, Point position = new Point(0, 0), bool enabled = true) {
    // ... function body
}

// Invalid: 'priority' is required but comes after 'retry', which has a default.
fn Configure(string id, bool retry = true, int priority) { // Error!
    // ...
}

However, there is one important exception to support common API patterns with trailing closures: a single, required function type parameter may appear as the last parameter in a list, even after parameters with default values.

Wax
// This is VALID due to the exception for a trailing function type.
fn PostNotification(string name, bool urgent = false, fn<() : void> onComplete) {
    // ... implementation ...
    onComplete();
}

// This allows for a very clean call-site using the trailing closure syntax.
PostNotification("UpdateComplete", .urgent = true) {
    Log("Notification posted successfully.");
};

Named Arguments#

To improve clarity, especially for functions with many parameters, you can call functions using named arguments. Arguments may then be given in any order, and the call site reads more clearly. You can mix positional and named arguments, but all positional arguments must come before any named arguments.

Wax
fn Configure(string id, int timeout, bool retry, float priority) {
    // ... function body
}

// Call-site examples:
Configure(.id = "task-123", .priority = 0.9, .retry = true, .timeout = 5000);
Configure("task-456", .retry = false, .priority = 0.5, .timeout = 10000); // Mixed
// Configure(.id = "task-789", 5000, .retry = false); // Error: Positional arg after named arg

Parameter Modifiers (ref, out)#

These keywords control how parameters are passed. ref and out are mutually exclusive.

ref: Passes the parameter by reference, allowing the function to modify the original variable. The argument must be initialized before the call.

out: Passes the parameter by reference. The function must assign a value to the parameter before it returns. The argument does not need to be initialized.

Wax
// Definition combining modifiers
fn ProcessData(ref int value, out string result) {
    value *= 2;        // Modifies the original caller's variable
    result = "Processed"; // Assigns to the caller's variable
}

// Call-site
int myValue = 10;
string myResult; // Does not need to be initialized for 'out'
ProcessData(ref myValue, out myResult);
// After the call, myValue is 20 and myResult is "Processed"

ref Returns#

Functions can return a reference to a value, allowing for direct modification of the original data source. This is an advanced feature for performance-critical scenarios.

Wax
class GameState {
    private int score;
    public fn GetScoreRef() : ref int {
        return ref this.score;
    }
}

var gameState = new GameState();
ref int currentScore = gameState.GetScoreRef();
currentScore += 100; // Modifies the score inside the 'gameState' object directly.

Contexts for Functions#

Top-Level Functions#

Functions declared outside of any type are known as top-level functions. They do not have access to an instance this and are useful for creating global helper routines.

Wax
internal fn CalculateDistance(Point a, Point b) : float {
    float dx = a.x - b.x;
    float dy = a.y - b.y;
    return Math.Sqrt(dx * dx + dy * dy);
}

Instance and Static Methods#

Functions declared inside a type (like a class, struct, or enum) are called methods.

Instance Methods: The default type of method. They have access to the this keyword, which refers to the specific object instance the method was called on.

Static Methods: Declared with the static keyword. They belong to the type itself, not an instance, and therefore do not have access to this. Top-level functions are conceptually similar to static methods as they are not associated with any instance.

Wax
class Calculator {
    // Instance method
    fn Add(int a, int b) : int { return a + b; }

    // Static method
    static fn GetHelp() : string {
        return "This is a calculator class.";
    }
}

// Calling an instance method requires an object.
var calc = new Calculator();
int sum = calc.Add(2, 3);

// Calling a static method is done on the type.
string help = Calculator.GetHelp();

Methods and Inheritance#

When working with classes, methods can participate in inheritance using special modifiers.

virtual: Marks a method in a base class, allowing it to be replaced by a derived class.

override: Provides a new implementation for a virtual member from the base class.

sealed: Placed on an override member, it prevents derived classes from overriding it further. It is only valid together with override.

abstract: Declares a method in an abstract class without an implementation. Any non-abstract derived class must provide an override.

Wax
abstract class Animal {
    // An abstract method that derived classes MUST implement.
    public abstract fn MakeSound() : string;
}

class Dog extends Animal {
    // We MUST provide an implementation for the abstract method.
    public override fn MakeSound() : string {
        return "Woof!";
    }
}

Generic Functions#

Functions in Wax can be generic, allowing them to operate on a variety of types in a type-safe way. Generic type parameters are declared in angle brackets <...> after the function name.

Wax
// A generic function that swaps two values of the same type.
fn Swap<T>(ref T a, ref T b) {
    var temp = a;
    a = b;
    b = temp;
}

// The compiler infers 'T' as 'int' at the call-site.
int x = 10;
int y = 20;
Swap(ref x, ref y); // x is now 20, y is now 10

Generic functions can also have constraints to require that a type parameter supports certain operations. For more details, see the Generics and Constraints section.

First-Class Functions and Lambdas#

Function Types#

A function’s signature, meaning its parameters and return type, defines its type. The primary way to define a function type is with the inline fn<...> syntax. The syntax inside the angle brackets (<>) is identical to the syntax for declaring a named function’s signature (its parameters, return type, and throws clause).

Wax
// A function that accepts a validator as a parameter.
fn ProcessInput(string data, fn<(string) : bool> validator) {
    if (validator(data)) { /* ... */ }
}

Lambda Expressions#

A lambda expression is a concise way to define an anonymous (unnamed) function using the => operator. In most cases, the compiler can infer the parameter types from the context.

Wax
// The compiler infers that 'a' and 'b' are ints from the 'add' variable's type.
fn<(int, int) : int> add = (a, b) => a + b;

// The compiler infers 'n' from the parameter the lambda is passed to.
fn KeepIf(int[] items, fn<(int) : bool> predicate) : int { /* ... */ }
int kept = KeepIf(numbers, (n) => n % 2 == 0);

Iterator pipelines are a separate surface and do not take inline lambdas. They use payload syntax instead: numbers->Filter(|n| => n % 2 == 0). See the Iterators chapter.

Closures#

A closure is a function that “closes over” and captures variables from its surrounding scope. When a lambda references a local variable from the function where it was created, it becomes a closure.

Wax
fn CreateGreeter(string greeting) : fn<(string) : string> {
    // This lambda captures the 'greeting' variable.
    return (name) => "$greeting, $name!";
}

var helloGreeter = CreateGreeter("Hello");
string message = helloGreeter("Wax"); // Returns "Hello, Wax!"

Working with Named Functions#

Any named function, including instance methods, can be assigned to a variable of a matching function type. When an instance method is assigned, it automatically creates a closure that captures the this instance.

Wax
class Calculator {
    private int factor;
    constructor(int factor) { this.factor = factor; }
    fn Multiply(int value) : int { return value * this.factor; }
}

var calc5 = new Calculator(5);
// This creates a closure that captures the 'calc5' instance.
fn<(int) : int> multiplyBy5 = calc5.Multiply;
int result = multiplyBy5(3); // returns 15

Advanced Function Topics#

Trailing Closures#

If the last parameter of a function is a function type, you can provide that argument after the function call’s parentheses using a block. This creates a syntax that looks like a custom control flow block and is the idiomatic way to handle resource management.

Wax
// A function that takes a simple action with no parameters.
fn OnButtonClick(fn<() : void> action) { /* ... */ }

// The | | is not required if the closure has no parameters.
OnButtonClick() {
    Log("Button was clicked!");
};
Payload Parameters#

If the closure accepts parameters, you must declare them in a payload block |...| before the main body {...}. The parameters listed in the payload block map directly to the parameters of the function type, in the same order.

  • You can omit parameters you don’t need, starting from the end.
  • You can use an underscore _ to explicitly ignore a parameter in any position.
  • If you don’t need to access any parameters, or if the closure takes no parameters, the entire |...| block can be omitted.
Wax
fn ForEach<T>(T[] items, fn<(T, int) : void> action) { /* ... */ }
string[] names = [] { "Alice", "Bob", };

// Using all parameters
ForEach(names) |name, index| {
    Debug.Log("${index}: ${name}");
};

// Omitting the last parameter (index)
ForEach(names) |name| {
    Debug.Log(name);
};

// Ignoring the first parameter (name)
ForEach(names) |_, index| {
    Debug.Log("Processing item at index ${index}");
};

// Ignoring all parameters, the |...| block is omitted entirely.
ForEach(names) {
    Log("Processed an item.");
};

Lambda Modifiers#

Lambdas can be marked with special modifiers to control their behavior.

static: Guarantees the lambda does not capture any variables, preventing a closure allocation.

state: A lambda becomes stateful by containing state declarations.

Error Handling#

When a function type can throw an error, the throws declaration is part of its type. The try keyword must be used where the function is invoked, not where it is defined.

Wax
// This function takes a callback that might throw.
fn ExecuteIfValid(string data, fn<() : void throws ValidationError> action) {
    if (data.length > 0) {
        // The error propagates from this invocation point.
        action();
    }
}

fn Process() throws ValidationError {
    // The 'try' is required here at the call-site.
    try ExecuteIfValid("someData", () => {
        throw new ValidationError("Failed!");
    });
}

Arrays and Collections in Wax#

Wax provides a rich set of collection types for storing and manipulating groups of data. These range from high-level, dynamic collections to low-level, performance-oriented arrays and memory views.

This document provides an overview of the primary collection types. For more advanced topics, see the detailed documentation on Iterators, Array Initializer Lists, and Memory and Spans.

Standard Collection Types#

These are the most common, general-purpose collections for everyday use.

Single-Dimensional Arrays: T[]#

This is the primary array type in Wax. It is a reference type that points to a contiguous, fixed-size block of memory on the heap.

  • Behavior: Reference semantics (the reference is copied on assignment, not the data).
  • Allocation: Heap-allocated and managed by the garbage collector.
  • Properties: .size provides the number of elements in the array.
  • Use Case: The default choice for storing a known number of elements.
Wax
// Create a heap-allocated array of 3 integers.
var scores = new int[3];
scores[0] = 95;
scores[1] = 87;
scores[2] = 100;

new T[n] requires a valid zero. It zero-fills its n slots, so it is legal only when zero is a real value of T: primitives, enums with a valid zero, nullable T? (whose zero is null), and blittable structs. For a non-null reference element (string, a class, another T[]), zero would be a null hole, so new T[n] is a compile error; build a dense array with an initializer list, the generator form new T[n] |i| { yield … }, or a lateinit T[] you fill and promote (see Late-Initialized Arrays below). new T[0] is always legal (it fills nothing).

Allocation-size arithmetic is exact. For a heap allocation new T[n], Wax replays the local +, -, and * computation that produced n in signed 64-bit arithmetic immediately before allocation. If that computation overflows int64, or its final value is outside int32, allocation panics with integer_overflow at the new statement. Parameters, loads, casts, and call results are the already-observed values, so they are never evaluated again. This applies to lateinit and jagged heap allocations too; ordinary integer arithmetic and stack allocations keep their normal wrapping behavior.

Jagged Arrays (T[][])#

A jagged array is an array of arrays, where each inner array can have a different size. Under the hood, int[][] is Array<Array<int>?>, so the inner element type is implicitly nullable and inner arrays can be null.

Pre-allocated Jagged Arrays#

When all inner arrays have the same size, you can specify all dimensions at once. Sizes are provided left-to-right; you can stop at any point but cannot skip dimensions.

Wax
// All inner arrays pre-allocated with size 5.
var grid = new int[3][5];
grid[0][2] = 42; // inner arrays are pre-allocated, so indexing works immediately

// Three-level jagged, fully pre-allocated.
var cube = new int[2][3][4];

// Partial pre-allocation: only outer array allocated, inner arrays are null.
var ragged = new int[3][];

// Partial: outer and middle allocated, innermost null.
var partial = new int[2][3][];
Manual Inner Allocation#

When inner arrays have different sizes, allocate the outer array first, then assign each inner array individually.

Wax
var grid = new int[3][];
grid[0] = new int[2]; // 2 columns in the first row
grid[1] = new int[4]; // 4 columns in the second row
grid[2] = new int[3]; // 3 columns in the third row

// Accessing elements uses sequential brackets.
grid[0][1] = 10;

List<T>#

The List<T> is the primary general-purpose, resizable collection. It automatically manages an internal array, growing its capacity as needed when new elements are added.

  • Properties: .size (the number of elements in the list) and .capacity (the size of the internal storage).
  • Common Operations: Adding (.Add), removing (.Remove, .RemoveAt), and clearing (.Clear) elements.
Wax
import List from Wax;

var names = new List<string>();
names.Add("Alice");
names.Add("Bob");
Log("Size: ${names.size}"); // Outputs: Size: 2

Dictionary<K, V>#

A Dictionary<K, V> stores a collection of key-value pairs, providing fast lookups based on the key.

  • Properties: .size (the number of key-value pairs).
  • Common Operations: Checking for a key (.Has), exact lookup (.TryGetValue), ergonomic nullable lookup (.GetValue), and removing a key-value pair (.Remove).
Wax
import Dictionary from Wax;

var playerScores = new Dictionary<string, int>();

// Add or update items using index assignment.
playerScores["Alice"] = 100;
playerScores["Bob"] = 95;

// Retrieve an item. The indexer throws on missing keys, so this needs try.
// A bare `try` propagates, so the enclosing function must declare `throws`;
// otherwise attach a catch, as in the indexer examples below.
int aliceScore = try playerScores["Alice"];

// Safe access with TryGetValue.
int? score;
if (playerScores.TryGetValue("Alice", out score)) {
    Log("Alice's score: ${score!}");
}

// The out value is nullable because V may not have a default value. The bool
// distinguishes a missing key from a present key whose stored value is null.

// When missing and a stored null are equivalent, GetValue supports payload syntax.
if (playerScores.GetValue("Bob")) |bobScore| {
    Log("Bob's score: ${bobScore}");
}

Set<T>#

A Set<T> stores a collection of unique elements. It provides high-performance set operations. T must satisfy the Hashable constraint.

  • Properties: .size (the number of unique elements).
  • Common Operations: Adding (.Add), removing (.Remove), and checking for containment (.Has).
Wax
import Set from Wax;

var tags = new Set<string>();
tags.Add("ui");
tags.Add("player");
tags.Add("ui"); // This is a duplicate and will be ignored.

Log(tags.size); // Outputs: 2
bool hasPlayerTag = tags.Has("player"); // true

Stack<T> and Queue<T>#

For more specific access patterns, Wax provides Stack<T> and Queue<T>.

  • Stack<T>: A last-in, first-out (LIFO) collection for pushing and popping elements.
  • Queue<T>: A first-in, first-out (FIFO) collection for enqueuing and dequeuing elements.

Indexers#

Indexers allow types to support [] bracket access syntax, just like arrays. They are declared using get this(...) and set this(...), following the same pattern as property getters and setters.

Using Indexers#

The built-in collection types support bracket access through indexers:

Wax
import List from Wax;
import Dictionary from Wax;
import Span from Wax;

// List<T> supports integer indexing
var list = new List<int32>();
list.Add(10);
list.Add(20);
int32 first = list[0];   // read via indexer getter
list[1] = 99;            // write via indexer setter

// Dictionary<K, V> supports key-based indexing
var scores = new Dictionary<string, int32>();
scores["Alice"] = 100;   // write via indexer setter

// The Dictionary getter throws on missing keys, so this needs try
int32 s = try scores["Alice"] catch |e| { yield 0; };

// Span<T> supports integer indexing
Span<int32> span = list.ToSpan();
int32 val = span[0];
span[0] = 42;

Declaring Indexers#

Indexers are declared with get this and set this using the same syntax as properties. The getter’s parameters define the index signature, and its return type defines the element type. The setter takes the same index parameters plus a trailing value parameter.

Wax
class Grid {
    private int32[] data;
    private int32 width;

    constructor(int32 w, int32 h) {
        width = w;
        data = new int32[w * h];
    }

    public get this(int32 x, int32 y) : int32 {
        return data[y * width + x];
    }

    public set this(int32 x, int32 y, int32 value) {
        data[y * width + x] = value;
    }
}

var grid = new Grid(10, 10);
grid[3, 4] = 42;
int32 cell = grid[3, 4];

Indexers can have any number of parameters and any parameter types. The getter and setter must agree on parameter types, and the getter’s return type must match the setter’s value (last) parameter type.

Throwing Indexers#

Unlike regular properties, indexers can declare throws. This is used by Dictionary<K, V> whose getter throws ItemNotFoundError when a key is missing:

Wax
// Getter that throws
public get this(K key) : V throws ItemNotFoundError;

// Setter (does not throw, because setting always succeeds)
public set this(K key, V value);

Callers must use try when reading from a throwing indexer:

Wax
// Propagate the error
int32 val = try dict["key"];

// Handle with catch
int32 val = try dict["key"] catch |e| { yield 0; };

// Throwing setters use statement-level try
try map[0] = 42;

Indexers in Interfaces and Constraints#

Indexers can be declared in interfaces and constraints, allowing generic code to work with any indexable type:

Wax
interface IReadable {
    get this(int32 index) : int32;
}

constraint Indexable {
    get this(int32 index) : int32;
}

fn Sum<T>(T source, int32 count) : int32 where T is Indexable {
    int32 total = 0;
    int32 i = 0;
    while (i < count) {
        total = total + source[i];
        i = i + 1;
    }
    return total;
}

Virtual and Abstract Indexers#

Indexers support virtual, abstract, and override, following the same rules as methods and properties:

Wax
abstract class Base {
    public abstract get this(int32 index) : int32;
    public abstract set this(int32 index, int32 value);
}

class Derived extends Base {
    private int32[] data;

    public override get this(int32 index) : int32 {
        return data[index];
    }

    public override set this(int32 index, int32 value) {
        data[index] = value;
    }
}

Within a derived class, base[index] calls the base class indexer, and this[index] calls the current class indexer.

Restrictions#

  • A type can have at most one indexer.
  • Indexer parameters cannot be out or ref.
  • Compound assignment (list[i] += 1) is not supported on indexers.

Collection Iteration#

Iteration with foreach#

The standard way to iterate over a collection is with a foreach loop. It uses the payload syntax |...| to declare the loop variable.

Wax
foreach (names) |name| {
    Log("Hello, ${name}!");
}

For transforming collections, Wax provides a zero-cost Iterator system using the -> operator.

Wax
// Get the scores of all players with names longer than 3 characters.
var highScores = playerScores
    ->Filter(|kvp| kvp.key.length > 3)
    ->Map(|kvp| kvp.value)
    ->ToList();

A Note on Collection Interfaces#

Wax provides collection interfaces (e.g., IList<T>, IStack<T>). However, their use in API signatures is generally discouraged. For performance reasons, it is almost always better to use constraints to write generic functions that operate on collections. This allows the compiler to generate specialized code and avoid the runtime overhead of virtual method calls that come with interfaces.

Advanced Collection Types#

These types serve specific needs and are not intended for general-purpose programming.

Memory Views with Spans#

For high-performance scenarios, Wax provides Span<T>, a safe mutable view into a contiguous region of memory owned by another collection (like a T[] or List<T>). A span is a scoped struct containing a backing address and an element count. It borrows rather than owns or retains the backing storage, so it cannot be stored where it could outlive that storage. Collections that own contiguous memory provide methods to create spans from their data.

Mutating through a span marks the owner dirty so the runtime’s frame-end diff sees the change, and panics if the owner is currently pinned by an iterator.

A span composes with ? like any other type: Span<T>? is a stack value that holds the span inline beside a presence flag, so a present-but-empty view is distinct from an absent one. What stays illegal is putting a span into heap storage: Span<T>[], Span<T>?[], and a span field in a non-scoped struct are all rejected, with or without the ?.

For a complete guide, see the Memory and Spans documentation.

Wax
var numbers = new int[] { 10, 20, 30, 40, 50, };

// Create a span that views the entire array.
scoped Span<int> fullSlice = numbers.ToSpan();

// Create a span that views a portion of the array.
scoped Span<int> middleSlice = numbers.Slice(1, 3); // Views { 20, 30, 40 }

middleSlice[0] = 99; // This modifies the original 'numbers' array.
// 'numbers' is now { 10, 99, 30, 40, 50 }

Late-Initialized Arrays: lateinit T[]#

Because new T[n] is rejected for a non-null reference element (it would leave null holes), Wax provides lateinit T[]: a checked array that starts with every slot unassigned and is filled slot-by-slot. It is the sanctioned way to build a non-null-element array incrementally without a nullable stand-in.

  • The monotone law: a slot is never un-assigned once written, so a proof that a region is hole-free stays valid for the array’s lifetime.
  • Prefix modifier: lateinit is valid only on an array type, and binds the outermost level. It cannot combine with stackalloc, and an initializer list or generator (which fill every slot, yielding a dense array) contradict it.
  • Single dimension only: new lateinit T[n] cannot be jagged. A jagged construction is refused by name, so lateinit T[] is the only shape you can actually build.
  • Construction: new lateinit T[n] mints n unassigned slots. It is the one allocation form exempt from the valid-zero gate above.

Every read is guarded: touching an unassigned slot panics. The per-slot surface needs no proof:

Wax
lateinit string[] names = new lateinit string[3];  // 3 unassigned slots
names[0] = "ann";                       // checked write
bool has0 = names.IsAssigned(0);        // per-slot test (never panics) -> true
bool all  = names.AllAssigned;          // whole-array test -> false (1 of 3 set)
names.Fill("?");                        // writes every slot AND records hole-freeness

Viewing the buffer as contiguous values first proves the viewed region is hole-free (a non-null read must never observe a hole):

Wax
string[] dense       = names.AsArray();          // verify [0,size) -> re-view SAME buffer as dense T[] (no copy)
Span<string> window  = names.Slice(0, 2);        // verify just [0,2) -> readable Span<T>
WriteOnlySpan<string> w = names.ToWriteOnlySpan(); // write-only view; needs no proof, records none

AsArray proves the whole array and Slice proves only its window (an array with holes elsewhere can still be sliced over a dense region); both panic on the first hole they find. The hole-observing read algorithms (Sort, Contains, foreach-over-values, …) do not live on lateinit T[]. Promote via AsArray() and use the dense T[] surface.

Conversions: a dense T[] widens implicitly to lateinit T[]; the reverse requires AsArray(). The boolean membership test late is T[] consults the runtime hole-freeness bit, so it is true exactly when the array has been proven dense; it does not promote or retype the value. Array as casts are rejected; use AsArray() for the checked promotion. The widening test dense is lateinit T[] is statically always true and reads no bit. lateinit arrays cannot cross the host boundary, so host-facing arrays are dense T[] or T?[].


The Wax Type System#

The Wax type system is built on two fundamental concepts: value types and reference types. The difference decides how your data is stored, copied, and compared.

  • Value Types: These types are stored directly, either on the execution stack or inline within a containing object. When you assign a value type or pass it to a function, you are creating a complete copy of the data. They are ideal for small, self-contained data structures.
    • Stored on stack or inline.
    • Copied by value on assignment.
    • Cannot inherit from classes or implement interfaces.
    • Every assignment, argument, and return copies the whole value, so size is a performance concern rather than a hard limit.
  • Reference Types: These types are stored on the memory heap, and your code interacts with them through a reference (or pointer). When you assign a reference type or pass it to a function, you are only copying the reference, not the underlying object. Both variables then point to the same object in memory.
    • Stored on the heap and accessed through pointers.
    • Passed by pointer (reference is copied).
    • Support inheritance and polymorphism.
    • Can implement interfaces.

A Note on Boxing: Some languages can implicitly wrap a value type in a reference type, a process called “boxing” which can cause hidden performance costs. Wax does not support boxing, ensuring that the performance characteristics of your types are always explicit and predictable.

Reference Identity#

Use object.ReferenceEquals(left, right) to compare whether two reference values point at the same heap object. It accepts reference types and nullable reference types, including string and string?, through implicit conversion to object?. It does not accept value types or nullable value types because Wax does not box values.

ReferenceEquals compares identity, not contents. For example, string == string uses string value equality, while object.ReferenceEquals(stringA, stringB) only returns true when both variables refer to the same string object. Two null nullable reference values compare as the same reference.


Composite Type Members#

Both structs and classes are composite types, meaning they group data and behavior together using members. The primary members are fields, properties, methods, and constructors.

Fields#

Fields are variables that store data directly within an instance of the type.

Wax
struct Point {
    float x; // field
    float y; // field
}
Methods#

Methods are functions that define the behavior of a type and can operate on its data.

Wax
struct Point {
    float x;
    float y;

    fn Distance(Point other) : float { /*...*/ } // method
}
Constructors#

Constructors are special methods responsible for creating and initializing a new instance of a type.

Wax
struct Point {
    float x;
    float y;
    
    constructor(float x, float y) { /*...*/ } // constructor
}
Compact Field Headers#

A struct or a class whose shape is its fields can declare them in the header, directly after the type name:

Wax
struct Point(float x, float y);
class User(string name, int32 id = 0);
struct Pair<T>(T first, T second) where T is struct;

Each entry declares one public, writable instance field, in header order, and the header as a whole declares the type’s single unnamed constructor, which assigns every entry to its field. struct Point(float x, float y); means exactly:

Wax
struct Point {
    public float x;
    public float y;

    public constructor(float x, float y) {
        this.x = x;
        this.y = y;
    }
}

The generated members are ordinary members: header order is field-layout order and positional-argument order, entry names are the field names and the named-argument names, and an entry’s default is an ordinary constructor default rather than a field initializer.

A body may follow the header instead of the ;, and carries whatever members the type needs:

Wax
struct Vec(float x, float y) {
    fn LengthSquared() : float { return x * x + y * y; }
}

Members written in the body meet the generated ones under the ordinary duplicate rules, so a body field or a second unnamed constructor that collides with the header is an error. ref, out, and scoped are refused on an entry: it is stored, not borrowed.

A compact class may implement an interface, satisfying it through its members like any other class, and may extend a base:

Wax
class Player(string name, int32 points) implements IHasScore {
    public get Score() : int32 { return points; }
}

class Enemy(int32 health) extends Actor;

The generated constructor takes the implicit : base() that any constructor written without a base call takes, so the base must have a parameterless constructor. A base whose constructor requires arguments is the ordinary missing-base-call error: there is nowhere in a header to spell : base(...), so write that constructor out in full.

is is refused alongside a header — a constraint declaration carries neither fields nor a constructor.

Properties#

Properties provide controlled access to a type’s data through get and set accessors. They look like methods when declared, but are used like fields, without parentheses.

Wax
class Character {
    private int _health;

    // 'Health' is a property that controls access to the private '_health' field.
    public get Health() : int {
        return this._health;
    }
    public set Health(int value) {
        // The setter can contain logic, like validation.
        this._health = value < 0 ? 0 : value;
    }
}

// --- Usage Example ---
var hero = new Character();

// The 'set' accessor is called using a simple assignment.
hero.Health = 90;

// The 'get' accessor is called by accessing the value.
int currentHealth = hero.Health; // currentHealth is now 90

// The setter's validation logic is automatically used.
hero.Health = -10;
int newHealth = hero.Health; // newHealth is now 0, not -10.

Member Modifiers#

Member declarations can be prefixed with keywords that modify their behavior and visibility.

Visibility Modifiers#

These keywords control where a member can be accessed from.

  • public (Default): The member can be accessed from anywhere.
  • protected: The member can only be accessed by the containing class and its derived classes.
  • internal: The member can only be accessed from the same module.
  • private: The member can only be accessed by the containing type.
Wax
class Example {
    public int a;    // Accessible everywhere
    private int b;   // Accessible only within Example
    protected int c; // Accessible within Example and its subclasses
    internal int d;  // Accessible within the same module
}
The static Modifier#

A static member belongs to the type itself, not to any specific instance. You access it through the type name.

  • Static Fields: A variable shared by all instances of the type.
  • Static Properties: A property that is not tied to a specific instance’s state.
  • Static Methods: A function that does not operate on an instance (this is not available).
Wax
class Game {
    // A static field to track the total number of players across all games.
    public static int totalPlayers = 0;

    // A static property to get the name of the game engine.
    static get EngineName() : string {
        return "WaxEngine";
    }

    // A static method to provide help text.
    static fn GetHelpText() : string {
        return "This is a generic help message for all games.";
    }
}

// Accessing static members
int players = Game.totalPlayers;
string engine = Game.EngineName;
string help = Game.GetHelpText();

Immutability with readonly#

The readonly keyword enforces immutability, preventing data from being changed after initialization.

Readonly Fields#

A readonly field can only be assigned a value at its declaration or within a constructor of the same type.

Wax
class UserProfile {
    readonly int userId; // This field is immutable.
    string displayName;

    constructor(int id, string name) {
        this.userId = id; // VALID: Assignment in a constructor.
        this.displayName = name;
    }

    fn ChangeId(int newId) {
        // this.userId = newId; // ERROR: Cannot assign to a readonly field.
    }
}

Structs: Composite Value Types#

structs are the way you define custom composite value types in Wax. Along with enums (which define a set of named constants), they are one of the two kinds of user-defined value types. They are lightweight, efficient, and ideal for representing simple data structures.

Wax
// This struct groups two floating-point numbers into a single logical unit,
// using fields, a constructor, and a method.
struct Point {
    float x;
    float y;
    
    constructor(float x, float y) {
        this.x = x;
        this.y = y;
    }
    
    fn Distance(Point other) : float {
        float dx = this.x - other.x;
        float dy = this.y - other.y;
        return Math.Sqrt(dx * dx + dy * dy);
    }
}

Classes: The Primary Reference Type#

Classes are used for more complex objects, especially those that need to manage state or participate in inheritance hierarchies. They are the foundation of Object-Oriented Programming in Wax.

Wax
// A base class that defines the core features of an Animal.
class Animal {
    string name;
    
    constructor(string name) {
        this.name = name;
    }
    
    // A 'virtual' method can be replaced by derived classes.
    virtual fn MakeSound() : string {
        return "Some generic animal sound";
    }
}

Choosing Between Structs and Classes#

The most important difference is how they behave when assigned.

  • Structs (Value Types) are copied.
  • Classes (Reference Types) share the same instance.
Wax
// Structs are copied on assignment.
var p1 = new Point(10, 20);
var p2 = p1; // p2 is a complete copy of p1.
p2.x = 30;   // This only changes p2. p1.x is still 10.

// Classes share the same instance.
var animal1 = new Animal("Fido");
var animal2 = animal1; // animal2 refers to the *same object* as animal1.
animal2.name = "Max";  // This changes the name for both animal1 and animal2.
                       // animal1.name is now "Max".
Use a struct when: Use a class when:
The type represents a single, simple value (e.g., coordinates, colors). The type represents a complex entity with its own identity and state.
You want value-copying behavior. You want reference-sharing behavior.
Performance is critical and you want to avoid heap allocations. You need inheritance and polymorphism.
The data is small enough that copying it is cheap. The data is large, or copying it would be wasteful.

sizeof(T)#

The sizeof expression returns the size in bytes of a type as a compile-time constant int32. It can be used anywhere an expression is expected, including in const declarations.

Wax
const int32 kIntSize = sizeof(int32);     // 4
const int32 kDoubleSize = sizeof(double); // 8

struct Vec3 { float x; float y; float z; }
const int32 kVec3Size = sizeof(Vec3);     // 12

For value types (primitives, enums, structs), sizeof returns the laid-out size including alignment padding. For classes and other reference types, sizeof returns the full heap object size (including the 8-byte type header).

sizeof can be used with generic type parameters. In this case, the size is resolved after generic instantiation:

Wax
fn GetSize<T>(T value) : int32 {
    return sizeof(T);
}

Using sizeof on a recursive struct (a struct that contains itself as a field) produces a compile-time error.


Enumerations#

Enumerations (or enums) are a value type defining a set of named constants. They are ideal for representing concepts that have a limited number of distinct states, such as a status, a category, or a set of options.

Wax provides two kinds of enumerations: normal enums for distinct values, and flags for values that can be combined as bitwise flags.


Normal Enumerations#

A normal enum defines a set of unique named values. By default, the underlying type of an enum is int, and the values are automatically assigned, starting from 0 and incrementing by one.

Wax
// A simple enum for tracking game states.
enum GameState {
    case MainMenu;  // 0
    case Playing;   // 1
    case Paused;    // 2
    case GameOver;  // 3
}

// Using the enum
GameState currentState = GameState.Playing;
if (currentState == GameState.Playing) {
    // ...
}
Backing Types and Explicit Values#

You can specify a different integral type for an enum (like byte, short, or uint) and assign explicit values to its members. If you assign an explicit value, subsequent members will auto-increment from that point.

Wax
// An enum with an explicit backing type and values.
enum StatusCode extends short {
    case Success = 200;
    case NotFound = 404;
    case ServerError = 500;
}

// An enum with mixed explicit and auto-incrementing values.
enum Priority extends byte {
    case Low;          // 0
    case Medium = 5;   // 5
    case High;         // 6 (increments from Medium)
    case Critical = 10; // 10
}

Flags#

A flags type is designed for bitwise operations, so multiple values can be combined into a single instance. It is declared with the flags keyword, which is a standalone type declaration rather than a modifier on enum.

  • Flags must have an unsigned integral backing type (e.g., uint, ushort, byte). The default is uint.
  • If you don’t assign a value, the compiler automatically assigns the smallest unused power of two (1, 2, 4, 8, …).
  • You can create composite members by combining other members with the bitwise OR | operator.
Wax
flags Permissions extends uint {
    case Read;         // 1 (auto-assigned)
    case Write;        // 2 (auto-assigned)
    case Execute;      // 4 (auto-assigned)
    case Delete = 8;   // 8 (explicitly assigned)

    // Composite members are useful shortcuts.
    case ReadWrite = Read | Write; // 3
    case All = Read | Write | Execute | Delete; // 15
}
Flag Decomposition Rule#

A key safety feature of flags is that the compiler validates them at compile time. Any member with a multi-bit value (like ReadWrite = 3) must be composed entirely of bits that correspond to other single-bit members defined in the same flags type.

Wax
flags Example extends uint {
    case A = 1;        // Single bit (power of 2)
    case B = 2;        // Single bit
    case C = 4;        // Single bit
    case AB = 3;       // VALID: Decomposes to A | B
    case AC = 5;       // VALID: Decomposes to A | C
    case BC = 6;       // VALID: Decomposes to B | C
    case ABC = 7;      // VALID: Decomposes to A | B | C

    // ERROR: This is invalid because the value 15 contains the bit for '8',
    // but there is no single-bit member defined with the value 8.
    // case Invalid = 15
}

This rule also applies when casting from an integer to a flags type.

Using Flags#

You can manipulate flags using bitwise operators (|, &, ^) to add, check for, or toggle values.

Wax
// Start with Read and Write permissions.
Permissions userPerms = Permissions.Read | Permissions.Write;

// Add Execute permission.
userPerms |= Permissions.Execute;

// Check if a specific permission exists.
if ((userPerms & Permissions.Read) != 0) {
    // User has read permission.
}

// Remove Write permission.
userPerms &= ~Permissions.Write;
Flag Helper Methods#

For convenience, Wax automatically provides helper methods on all flags types:

  • HasValue(mask): Returns true if any of the bits in the given mask are set.
  • HasAll(mask): Returns true if all of the bits in the given mask are set.
  • Add(mask): Returns a new value with the given flags added.
  • Remove(mask): Returns a new value with the given flags removed.
  • Toggle(mask): Returns a new value with the given flags flipped.
Wax
Permissions userPerms = Permissions.ReadWrite;

bool canRead = userPerms.HasValue(Permissions.Read); // true
bool canExecute = userPerms.HasValue(Permissions.Execute); // false

Permissions newPerms = userPerms.Add(Permissions.Execute); // now has Read, Write, Execute

Default Enum Case#

You can explicitly designate one member of an enum as its default value. This makes the enum type compatible with the default keyword.

To mark a case as the default, prefix it with the default keyword. Only one case can be marked as the default. If no member is marked, the enum type is not compatible with the default keyword, and using default(MyEnum) will result in a compile-time error. Both enum and flags behave the same way.

Wax
enum Color {
    default case Black; // This case is the default
    case Red;
    case Blue;
}

// Now you can use the default keyword with this enum.
Color c = default; // c is Color.Black

Enum Members#

Enums and flags can have methods and properties, but they cannot have fields or constructors. Behavior therefore lives on the type itself.

Wax
enum Color {
    case Red;
    case Green;
    case Blue;

    public get IsPrimary() : bool {
        return true; // All are primary in this model
    }

    public fn GetHexCode() : string {
        switch (this) {
            case Red: { return "#FF0000"; }
            case Green: { return "#00FF00"; }
            default: { return "#0000FF"; }
        }
    }
}

// Usage
string hex = Color.Green.GetHexCode(); // "#00FF00"

Type Conversion#

You must use an explicit cast to convert between an enum/flags and its underlying integral type.

  • as!: Performs a forced cast. If the integral value does not correspond to a defined member (or cannot be decomposed for a flags type), the program will panic.
  • as?: Performs a safe cast. If the cast fails, it returns null.

The check is a test of a numeric value against the declared cases, so the other side of the cast must be a value the check can run on: a numeric type, or another enum. Casting an enum to or from a string, a json, or a reference type is a compile error. Convert through the backing integer instead. Both sides must also be non-nullable; unwrap first.

Wax
Color color = Color.Blue;
int value = color as! int; // 2

StatusCode status = 200 as! StatusCode; // StatusCode.Success

// Panicking cast
// StatusCode invalid = 999 as! StatusCode; // PANIC! 999 is not a valid value.

// Safe cast
StatusCode? maybeStatus = 999 as? StatusCode; // null
if (maybeStatus) {
    // ...
}

Object-Oriented Programming#

Inheritance#

Inheritance allows a new class (the derived class) to be based on an existing class (the base class). The derived class inherits the members of the base class and can add new members or modify the inherited behavior.

A core principle in Wax is that all member names (fields, properties, and methods) must be unique within an entire type hierarchy. This eliminates ambiguity and makes method resolution simple and predictable. The only exception is when explicitly overriding a virtual or abstract member. Constructors are also exempt from this rule.

virtual, override, and sealed#

These keywords manage how methods and properties can be modified in a derived class.

  • virtual: Marks a method or property in a base class, allowing it to be replaced by a derived class.
  • override: Provides a new implementation for a virtual member from the base class.
  • sealed: Placed on a class, it prevents any other class from extending it. Placed on an override member, it ends that member’s override chain: the member still dispatches virtually, but no further derived class may override it again. sealed is only valid together with override: a member that overrides nothing is already un-overridable unless it is virtual.
Wax
class Dog extends Animal {
    // Subclasses of Dog inherit this, but cannot replace it.
    public sealed override fn MakeSound() : string {
        return "Woof";
    }
}

class Puppy extends Dog {
    // ERROR: Dog.MakeSound is sealed.
    // public override fn MakeSound() : string { return "Yip"; }
}
Wax
// --- Example with Methods ---
class Animal {
    // This method can be replaced by subclasses.
    public virtual fn MakeSound() : string {
        return "Some generic animal sound";
    }
}

class Dog extends Animal {
    // This provides a specific version of the method for Dogs.
    public override fn MakeSound() : string {
        return "Woof!";
    }
}

// --- Example with Properties ---
class BaseValue {
    // Base class provides a read-only virtual property.
    public virtual get Value() : int { return 0; }
}

class SettableValue extends BaseValue {
    private int storedValue;

    // Override the getter.
    public override get Value() : int {
        return this.storedValue;
    }

    // Add a setter, making the property read-write in the derived class.
    public set Value(int value) {
        this.storedValue = value;
    }
}
abstract Classes and Members#

An abstract class is a special kind of base class that cannot be instantiated on its own. It serves as a template that other classes must build upon.

An abstract member (method or property) has no implementation and must be overridden in any concrete (non-abstract) derived class.

Wax
// 'Shape' cannot be instantiated directly.
abstract class Shape {
    // An abstract method that derived classes MUST implement.
    public abstract fn GetArea() : float;

    // A regular virtual method that can optionally be overridden.
    public virtual fn GetName() : string {
        return "A generic shape";
    }
}

class Circle extends Shape {
    public float radius;

    // We MUST provide an implementation for the abstract GetArea method.
    public override fn GetArea() : float {
        return 3.14f * this.radius * this.radius;
    }
}

// var s = new Shape(); // ERROR: Cannot create an instance of an abstract class.
var c = new Circle();
c.radius = 10.0f;
float area = c.GetArea(); // 314.0f

Constructors in Inheritance#

Constructors have special rules to ensure that both base and derived classes are initialized correctly.

Default Constructors#

If you do not declare any constructors in a class, the compiler may provide a public, parameterless default constructor automatically. However, this only happens if it is safe to do so. A default constructor will not be generated if:

  1. Any other constructor is explicitly declared.

    Wax
    class Example {
        // The presence of this constructor prevents the default one.
        constructor(int value) { }
    }
    // var ex = new Example(); // ERROR: No parameterless constructor exists.
    
  2. The base class does not have an accessible parameterless constructor.

    Wax
    class Base {
        constructor(int value) { } // No parameterless constructor.
    }
    class Derived extends Base {
        // ERROR: No default constructor is generated because it cannot
        // implicitly call a non-existent base() constructor.
    }
    
  3. The class has uninitialized non-nullable reference type fields. Value types (like int) and nullable reference types (like string?) have safe default values (0, false, null). A non-nullable reference type does not, so an empty constructor would violate Wax’s definite assignment rule.

    Wax
    class User {
        string name; // ERROR: Non-nullable reference type is not initialized.
        
        int score;          // OK: Value types default to 0.
        string? avatarUrl;  // OK: Nullable types default to null.
    
        // A default constructor cannot be generated because
        // it would not initialize the 'name' field.
    }
    
Explicit Constructor Chaining#

A constructor in a derived class is required to call a constructor from its base class. If you do not specify one, the compiler implicitly tries to call base().

  • Use : base(...) to explicitly call a constructor on the base class.
  • Use : this(...) to call another constructor in the same class (which must eventually chain to a base constructor).
Wax
class Base {
    public int value;
    constructor(int value) {
        this.value = value;
    }
}

class Derived extends Base {
    public string name;

    // This constructor explicitly calls the base constructor.
    constructor(int val, string name) : base(val) {
        this.name = name;
    }

    // This constructor chains to another constructor in THIS class.
    constructor(string name) : this(0, name) {
        // The call to :this(0, name) handles the base initialization.
    }
}
Named Constructors#

Because Wax does not support method overloading, you can create multiple constructors by giving them unique names. These can also be used in constructor chaining.

Wax
class Base {
    constructor() { /*...*/ }
    constructor FromValue(int value) { /* ... */ }
}

class Derived extends Base {
    // Calls the NAMED constructor in the base class.
    constructor(string name) : base.FromValue(name.length) {
        // ...
    }
}
Object Initializers#

A new expression can set members directly with a brace list, using .member = value. Keys may name fields or properties; a property key runs its setter.

Wax
class Point {
    public int x;
    public int y;
}

Point p = new Point { .x = 1, .y = 2 };

When the type is already known from context, it may be left off entirely. The keys drop their leading dot in this form:

Wax
Point p = new { x = 1, y = 2 };          // target: the declared local type

The type comes from whatever the expression is being assigned or passed to: a declared variable, a parameter, a return type, or the field or property on the left of an assignment.

Wax
fn Apply(Config c) : int { /* ... */ }

fn Make() : Point {
    return new { x = 5, y = 6 };         // target: the return type
}

Apply(new { width = 3, height = 4 });    // target: the parameter type
o.inner = new { v = 9 };                 // target: the field type

A nullable target constructs its underlying type, so Point? p = new { x = 1, y = 2 }; builds a Point and wraps it, so the result is never null.

If nothing supplies a type, the form is an error. var declares no type of its own, so var p = new { x = 1 }; is rejected. Write new Point { .x = 1 } there instead.

In both spellings a key must name a field or a property with a setter. A key that resolves to anything else, such as a method, a const, or a nested type, is rejected rather than ignored.

The brace list runs after the constructor, so each entry is an ordinary write and obeys the same rules as x.f = v. A readonly field is therefore not settable this way. Assign it in a constructor instead:

Wax
class Foo {
    public readonly int x;
    constructor(int v) { x = v; }
}

Foo a = new Foo(1);          // ok
Foo b = new Foo { .x = 1 };  // rejected: `x` is readonly
Construction Safety#

Wax’s null-safety guarantee requires that no code can observe a non-nullable field before it is assigned. Inside a constructor, the compiler enforces this with definite assignment (every field must be assigned on every path before the constructor exits) plus rules that keep the partially-constructed this from leaking into code that could read unassigned fields:

  1. Overridable dispatch on this is rejected. Calling a virtual, override, or abstract member (including virtual property accessors) on this inside a constructor could land in a derived class’s override, which would see the derived class’s still-unassigned fields.

  2. this cannot escape. Passing this as an argument, storing it, returning it from a helper, or capturing it as a value hands out a reference to an object whose derived fields are not yet assigned.

  3. Non-virtual helpers compose. Calling a non-overridable method on this is allowed when the method is construction-clean: its body (transitively) performs no overridable dispatch on this and never leaks this. The compiler infers which fields the helper reads and assigns: a helper’s reads must already be assigned at the call site, and its assignments count toward the constructor’s definite-assignment obligation, so field-initializing helpers work naturally:

Wax
class Session {
    string token;

    constructor() {
        Init();  // OK: Init assigns 'token', satisfying definite assignment.
    }

    fn Init() {
        this.token = "fresh";
    }
}
  1. base.Method() binds statically, so it is allowed when the base method is construction-clean; the base class’s own fields are already assigned because the base constructor runs first.

  2. sealed classes (and structs) are exempt from rules 1 and 2 once all fields are assigned. A sealed type has no derived classes, so a fully-assigned this is a complete object, and it may call its virtual members and escape freely. This makes sealed the escape valve for registration patterns:

Wax
sealed class Node {
    int32 id;

    constructor(int32 id) {
        this.id = id;
        Registry.Track(this);  // OK: sealed + fully assigned.
    }
}

These rules apply to class, struct, and error constructors alike.


Member Access Control#

Fields, methods, properties, constants, and constructors carry a visibility modifier. public is the default when none is written.

  • public: reachable from anywhere.
  • internal: reachable from code in the same module.
  • protected: reachable from the declaring type and its subclasses.
  • private: reachable from the declaring type only.

private is type-private, not instance-private. Accessibility is decided by where the access is written, never by which object it is written against: any code lexically inside the declaring type’s body may reach a private member of that type on any instance: this, a parameter, a local, a field, or a value returned from a call.

Wax
class Account {
    private int32 balance;
    public constructor(int32 balance) { this.balance = balance; }

    private fn Rate() : int32 { return 3; }

    // A second instance of the same type is reachable, in every direction.
    fn Absorb(Account other) : int32 {
        balance = balance + other.balance;   // read another instance's private field
        other.balance = 0;                   // ...and write it
        return other.Rate();                 // ...and call its private method
    }
}

A lambda written inside a method body keeps that method’s access, so a nested closure reaches the same private members its enclosing method can.

Two rules bound this:

  • private is not protected. A subclass cannot reach a private member of its base; only the declaring type itself can. Use protected to admit subclasses.
  • Generic instantiations share one accessibility domain. A generic type is one declaration, so its body reaches private members of any instantiation of itself: code in Box<T> may touch a Box<int32>'s private field. Code in an unrelated type still cannot, whichever instantiation it names.
Wax
struct Box<T> {
    private int32 tag;
    public constructor() { this.tag = 0; }

    fn Peek(Box<int32> other) : int32 {
        return other.tag;                    // OK: same declaration, different instantiation
    }
}

class Outsider {
    fn Peek(Box<int32> b) : int32 {
        return b.tag;                        // ERROR: private to Box
    }
}

The visibility override (obj.private.member) is the deliberate escape hatch from these rules; see the modules and visibility section.


Interfaces#

An interface defines a contract: a set of public methods and properties that a type must implement. In Wax, only classes (reference types) can implement interfaces; structs (value types) cannot. This allows classes to participate in polymorphism by promising to provide a specific set of behaviors.

An interface itself cannot contain state (fields) or implementation details (constructors, method bodies).

Wax
// An interface defines a capability.
interface IDamageable {
    get Health() : int;
    fn TakeDamage(int amount);
}

// Classes can promise to provide that capability.
class Player implements IDamageable { /* ... */ }
class Barrel implements IDamageable { /* ... */ }

// struct Boulder implements IDamageable { /* ... */ } // ERROR: Structs cannot implement interfaces.
Using Interfaces as Types#

Interfaces earn their keep when used as types for fields, properties, and parameters. A field of type IDamageable can hold an instance of Player, Barrel, or any other class that implements the interface, so the code holding it never names a concrete type.

Wax
interface ILogger {
    fn Log(string message);
}

class ConsoleLogger implements ILogger {
    public fn Log(string message) { /* ... */ }
}

class FileLogger implements ILogger {
    public fn Log(string message) { /* ... */ }
}

// This class is decoupled from any specific logger implementation.
class App {
    private ILogger logger;

    constructor(ILogger logger) {
        this.logger = logger; // Store any object that can log.
    }

    fn DoWork() {
        this.logger.Log("Doing some work...");
    }
}
Interface Inheritance and Conflict Resolution#

Interfaces can inherit from other interfaces to compose larger contracts.

  • Same Name, Same Signature: If multiple interfaces define a member with the exact same signature, a single implementation in a class will satisfy all of them.
  • Same Name, Different Signature: If a class needs to implement two interfaces with conflicting members, or if an interface member conflicts with an existing member on the class itself, a public implementation is not possible. This is where explicit implementation is required.
Explicit Interface Implementation#

When a name conflict arises, or when you want to implement an interface member without making it a part of the class’s API, you can use explicit interface implementation.

An explicitly implemented member is not part of the class’s API and can only be accessed when the object is viewed through the lens of the interface (i.e., cast to the interface type).

Wax
// --- Scenario 1: Conflict between two interfaces ---
interface IWriter { fn Write(string data); }
interface IArchiver { fn Write(byte[] data); }

class DataHandler implements IWriter, IArchiver {
    public fn Write(string data) { /* ... */ }
    fn IArchiver.Write(byte[] data) { /* ... */ } // Explicit implementation
}

// --- Scenario 2: Conflict between a class and an interface ---
interface IAction {
    fn Execute();
}

class Task implements IAction {
    // This public method has a different signature and purpose.
    public fn Execute(bool force) {
        // ...
    }

    // Explicit implementation is required to satisfy IAction without a name collision.
    fn IAction.Execute() {
        this.Execute(false); // Can call the public method internally
    }
}

Explicitly implemented members can also be virtual and override, following the same inheritance rules as public members.


Type Casting#

Wax provides operators to safely work with types in an inheritance hierarchy.

Implicit Upcasting (Safe)#

You can implicitly convert an instance of a class to any of its base classes or to any interface it implements. This is called an upcast, and it is always safe because the derived class is guaranteed to contain all the members of its base types. The compiler can verify this relationship, so no explicit cast is needed.

Wax
class Task implements IAction { /* ... */ }
var myTask = new Task();

// Implicit upcast from Task to IAction. This is always safe.
IAction myAction = myTask; 

// Implicit upcast from Dog to its base class Animal.
Animal myPet = new Dog(); 
Explicit Downcasting with as (Potentially Unsafe)#

Converting from a base type or an interface back to a more specific derived type is called a downcast. This operation is not guaranteed to be safe at runtime, as the object might be a different derived type. Therefore, downcasts must always be explicit using the as! or as? operators.

  • as?: Performs a safe cast. If the cast succeeds, it returns the re-typed object. If it fails, it returns null. This is the recommended way to perform a downcast.
  • as!: Performs a forced cast. If the cast fails at runtime, the program will panic. Use this only when you are absolutely certain of an object’s type and a failed cast should be considered a critical, program-ending error.
Wax
fn ProcessAction(IAction action) {
    // The recommended way to downcast is 'as?' plus a payload capture, which
    // unwraps the result into a non-nullable binding.
    Task? maybeTask = action as? Task;
    if (maybeTask) |task| {
        // The cast succeeded, we can now use Task-specific members.
        task.Execute(true);
    } else {
        // The action was not a Task, handle accordingly.
    }
}

Generics#

Generics let you write one implementation that stays type-safe across many types. You can define classes, structs, interfaces, constraints, errors, and methods that work with any data type, deferring the choice of that type until the code is used.

The examples in this chapter assume these ordinary stdlib imports:

Wax
import Error from Wax;
import List from Wax;

The core problem generics solve is code duplication. Without them, you would need to write separate implementations of a class for each data type you want to support (e.g., IntList, StringList, etc.). Generics allow you to write a single List<T> and let the compiler handle the rest.

Generic Types and Methods#

You can create generic types and methods by adding a type parameter list, enclosed in angle brackets (<>), after the name. A type or method can have up to 32 type parameters.

In Wax, a type’s name is its unique identifier. You cannot define multiple types that share the same name but have a different number of generic parameters. For example, Thing, Thing<T>, and Thing<T, U> would be considered a name collision and must be given unique names.

Wax
// A generic class that acts as a simple container for any type.
public class Container<T> {
    private T? item;

    public fn Put(T item) {
        this.item = item;
    }

    public fn Take() : T? {
        var temp = this.item;
        this.item = null;
        return temp;
    }
}

// A generic utility method. The compiler can often infer the type.
class Utility {
    public static fn Swap<T>(ref T a, ref T b) {
        T temp = a;
        a = b;
        b = temp;
    }
}
Type Argument Inference#

A call that supplies no explicit type arguments infers them from its arguments. Every argument that is not a lambda is unified against its parameter type first. Then each lambda whose parameter types are fully determined by those bindings has its body checked with them, and the body’s type is unified against the delegate’s declared return type, binding whatever it still leaves open; this repeats until no lambda can be checked, so one lambda’s result may fix the parameter types of the next. A body of type X against a declared U? binds U = X; a body of type X? against a declared U binds U = X?; a bare null fixes nothing. Explicit type arguments always take precedence, and a type parameter that no argument fixes is reported by name.

Wax
class Lane<T> {
    fn Select<U>(fn<(T) : U> sel) : Lane<U> { ... }
}

Lane<int32> hp = robots.Select((r) => r.health);   // U inferred as int32 from the body
Referring to the Type Being Declared#

Inside a generic type’s own body, naming that type with its own parameters denotes the current instantiation, the same type this has. Box<T> written inside Box<T> is not a new or “open” type; it is this one. That holds for local declarations, return types, parameters, fields, type-test targets, new expressions, and nested generic arguments.

Wax
struct Box<T> {
    T value;
    public constructor(T value) { this.value = value; }

    fn Self() : Box<T> { return this; }        // return type

    fn Hold() : bool {
        Box<T> copy = this;                    // local declaration
        List<Box<T>> all = new List<Box<T>>(); // nested generic argument
        all.Add(copy);
        return all.size == 1;
    }
}

Naming a different argument is an ordinary instantiation and is constraint-checked as usual, so Box<U> inside Box<T> means Box of U and must satisfy Box’s constraints.

The Constraint System#

By default, a generic type parameter T is completely unknown. To make generics useful, you must apply constraints, which define the required “shape” (members, properties, etc.) and characteristics a type must have to be used as a type argument.

Declaring Constraints#

You can define a reusable set of requirements using the constraint keyword. This is useful for complex requirements that are used in multiple places.

Wax
// A constraint for types that have a unique identifier.
constraint Identifiable {
    fn GetId() : ulong;
}
Using Constraints: The where Clause#

Constraints are applied to generic parameters using a where clause, which appears after the return type (for methods) or after the type header (for types). The where clause uses the is keyword followed by a list of requirements combined with the & operator. The compiler ensures the list of requirements is valid and non-conflicting.

Angle brackets (<>) only introduce type parameter names. All constraints go in where.

A requirement can be one of the following:

  • A base type (class or error type name).
  • An interface name.
  • A named constraint.
  • A constraint qualifier (e.g., numeric, struct).
  • A callable signature (callable<...>) for delegate-like invocation.
Wax
// A base class for game objects.
class GameObject { /* ... */ }

// An interface for things that can be saved.
interface ISaveable { fn Save(); }

// A named constraint for things that can be reset.
constraint Resettable { fn Reset(); }

// This function's type parameter 'T' has three requirements.
fn ProcessObject<T>(T obj) where T is GameObject & ISaveable & Resettable {
    // ...
}

// You can also use qualifiers directly as a requirement.
// This function will only accept floating-point types (float, double).
fn ProcessRealNumber<T>(T number) : void where T is real {
    // ...
}
Callable Constraints#

callable<...> is a contextual requirement that uses the same signature syntax as a function type, minus the leading fn. It proves that values of a generic type parameter can be invoked with normal call syntax inside the constrained scope.

Wax
fn Apply<T>(T f, int32 x) : int32
    where T is callable<(int32) : int32> {
    return f(x);
}

fn TryApply<T>(T f, ref int32 x, out string s) : bool throws Error
    where T is callable<(ref int32, out string) : bool throws Error> {
    return f(ref x, out s);
}

The current rule is exact delegate compatibility: a concrete fn<...> delegate type satisfies callable<...> only when the parameter count, parameter types, ref/out modes, return type, and throws type all match. Callable constraints do not imply construction, and new T() remains invalid unless another rule explicitly permits construction.

Constructor Constraints#

A named constraint can require a constructor with an exact name and parameter count. The unnamed constructor() form requires an accessible parameterless constructor. The constraint declaration’s parameter types are used to type-check calls made through the constraint:

Wax
constraint Constructable {
    constructor();
}

fn Create<T>() : T where T is Constructable {
    return new T();
}

class Widget {
    public constructor() { }
}

Widget widget = Create<Widget>();

The same rule applies to value types and primitives. For example, the standard library’s Wax-defined constructor makes int32 satisfy Constructable, so Create<int32>() is valid. A constructor constraint does not make default invoke a constructor; default remains the canonical zero-value expression.

Note: When a list of requirements becomes long or is used in multiple places, it’s good practice to combine them into a single, reusable named constraint. For example:

Wax
// verbose constraint list
fn ProcessObject<T>(T obj) where T is GameObject & ISaveable & Resettable { }

// is rewritable as a simple (and reusable) constraint list:

constraint ProcessableObject implements GameObject, ISaveable, Resettable {}

fn ProcessObject<T>(T obj) where T is ProcessableObject {
    // ...
}

This improves readability and makes the code easier to maintain.

Constraint Qualifiers#

Qualifiers are keywords that restrict the fundamental kind of a type. They can be used in two ways:

  1. When declaring a named constraint, to limit what kinds of types can satisfy it.
  2. Directly as a requirement for a generic parameter.
Qualifier Description
struct Restricts to value types.
class Restricts to reference types.
numeric Restricts to any numeric type (integral or real).
integral / integer Restricts to any signed or unsigned integer type. integer is an accepted alias.
signed Restricts to signed integer types (sbyte, short, int, long).
unsigned Restricts to unsigned integer types (byte, ushort, uint, ulong).
real Restricts to floating-point types (float, double).
enum Restricts to any enumeration type.
flags Restricts to flag enumerations.
blittable Restricts to types with a C-compatible memory layout.

The numeric qualifiers form an implication lattice: signed and unsigned imply integral, and both integral and real imply numeric. A generic parameter constrained to one of the stronger qualifiers therefore also satisfies a weaker requirement, so a where T is signed function may forward T to a helper declared where U is integral, and a where T is real function may forward T to a helper declared where U is numeric. The implication is one-directional: numeric does not satisfy the narrower real or integral, integral does not satisfy either sign qualifier, and real does not satisfy integral.

Static If Type Predicates#

static if is a branch statement for open generic code. It selects a branch during generic specialization based on type predicates over generic parameters; it is not a general compile-time expression evaluator.

Wax
fn DescribeNumber<T>(T value) : int32 {
    static if (T is float) {
        return 1;
    }
    else if (T is double) {
        return 2;
    }
    else if (T is integer || T is unsigned || T is signed) {
        return 3;
    }
    else {
        return 0;
    }
}

A static if condition may contain only predicates of the form T is X, combined with &&, ||, !, and parentheses. T must name a generic type parameter visible in the current generic scope. X may be a concrete type, an interface, a named constraint, or any constraint qualifier (struct, class, enum, flags, numeric, integral, integer, signed, unsigned, real, or blittable).

Inside the true branch, positive facts refine the generic parameter for semantic analysis:

  • T is Dog lets values of type T be used as Dog.
  • T is InterfaceName and T is ConstraintName expose the corresponding members.
  • T is real, T is integer, and other qualifiers expose the same constraint view as an equivalent where clause.
  • A && B applies both refinements.
  • A || B applies only facts common to all alternatives; for example T is real || T is integer refines T to numeric, T is signed || T is unsigned refines T to integral, and T is Dog || T is Animal refines T to Animal.
  • !A is valid for branch selection, but it does not add a positive refinement.

The else branch is checked under the original generic assumptions; v1 does not apply negative refinements. Payload capture is not supported on static if.

In v1, static if branches inside a state fn may use existing state values, but they may not declare new state variables, declare state.create / state.destroy hooks, or call another state fn. Those operations register state-tree metadata during semantic analysis, before generic specialization can prune unselected branches.

During specialization, the compiler evaluates the predicate against the concrete generic arguments and lowers only the selected typed branch. Ordinary runtime if semantics and code generation are unchanged.

Advanced Constraint Features#

Type Keywords in Constraints: this and base#

Within a constraint declaration, the keywords this and base take on special meanings as types, which makes some precise generic patterns expressible. This special behavior only applies inside constraints; elsewhere, they retain their standard meanings (instance reference and parent class access).

The this Keyword (Invariant Type)

When used as a type, this represents the exact type being checked against the constraint. It enforces an invariant relationship, meaning no subtype or supertype substitution is allowed. Patterns that must preserve the exact type, like fluent interfaces or cloning, depend on this.

Wax
constraint Cloneable {
    // The Clone method MUST return the exact same type.
    fn Clone() : this;
}

constraint FluentBuilder {
    // The WithOption method MUST return the exact same type to allow chaining.
    fn WithOption(string key, string value) : this;
}

// --- Implementation ---
// When implementing, use the concrete type name, not 'this'.
class MyBuilder implements FluentBuilder {
    fn WithOption(string key, string value) : MyBuilder {
        // ... configure ...
        return this; // Here, 'this' is the instance reference.
    }
}

The base Keyword (Contravariant Parameter Type)

When used as a parameter type in a constraint declaration, base means “the checked type, or any of its ancestor types.” This allows a type to satisfy a constraint even when its method accepts a wider type than itself. base may only appear in parameter position, not as a return type.

This solves a practical problem with inherited methods. Consider a class hierarchy where comparison logic lives on the base class:

Wax
constraint Comparable {
    fn Compare(this other) : int;
}

class Animal {
    fn Compare(Animal other) : int { return this.id - other.id; }
}
class Dog extends Animal { }

With this, Dog fails to satisfy Comparable because the inherited Compare takes Animal, not Dog. Every subclass would need to redeclare the method with its exact type, even when the base implementation is correct.

Using base instead relaxes the requirement:

Wax
constraint Comparable {
    // The parameter can be the checked type or any ancestor it is castable to.
    fn Compare(base other) : int;
}

class Animal {
    fn Compare(Animal other) : int { return this.id - other.id; }
}
class Dog extends Animal { }
// Dog satisfies Comparable: inherited Compare takes Animal, which is a base of Dog. ✓

At generic call sites, base resolves to the type parameter itself, which is always safe, because passing T where T or any supertype of T is accepted is a valid upcast:

Wax
fn Sort<T>(T a, T b) where T is Comparable {
    int result = a.Compare(b);  // b is T, always valid
}
Parameterized Constraints#

Constraints themselves can be generic, so one requirement can be reused across many types.

Wax
// A constraint for a factory that can create objects of type T.
constraint Factory<T> {
    fn Create() : T;
}

// A constraint for a type that can be converted from T to U.
constraint Converter<T, U> {
    fn Convert(T input) : U;
}

// A generic method that uses a parameterized constraint.
// The type U must be a factory that can produce T's.
fn CreateAndProcess<T, U>(U factory) : T where U is Factory<T> {
    T newInstance = factory.Create();
    // ... process newInstance
    return newInstance;
}
No Default Implementations#

A constraint is a pure compile-time contract describing a type’s required “shape”. Its members may not carry a body: every member is an abstract requirement that the satisfying type must provide itself. A member body on a constraint is a compile-time error. (For runtime fallback implementations, use a default interface method instead.)

Constraint Composition and Flattening#

Constraints can be combined and extended to create more complex requirements. When you compose constraints, the compiler flattens the entire hierarchy into a single, unified set of requirements.

Conflict Resolution#

During flattening, conflicts can arise. Because constraints are satisfied by a type’s public “shape”, there is no mechanism to provide alternate implementations to resolve conflicts. Therefore, any ambiguity results in a compile-time error.

  • Same Signature: Valid. Duplicate requirements are merged into one.
  • Different Signatures, Same Name: Error. The compiler cannot resolve the ambiguity.

Constraint Satisfaction#

A type can satisfy a constraint in two ways:

  1. Duck Typing (Implicitly): If a type has the required members with the exact matching signatures, it automatically satisfies the constraint. This is the primary mechanism.
  2. Explicit Implementation: You can optionally declare that a type implements a constraint using the implements keyword. This provides no runtime benefit but serves as clear documentation and allows the compiler to validate the implementation at the type’s declaration site.
Multi-Constraint Satisfaction Example#

When a generic parameter has multiple constraints, the type argument must satisfy all requirements from the combined, flattened hierarchy.

Wax
// Define the building blocks
constraint Printable { fn Print() : string; }
constraint Comparable { fn Compare(this other) : int; }

// 'Serializable' requires 'Printable' and adds its own requirement.
constraint Serializable implements Printable {
    fn ToBytes() : byte[];
}

// This function requires a type 'T' that is BOTH Serializable and Comparable.
fn ProcessComplex<T>(T item, T other) where T is Serializable & Comparable {
    // The flattened requirements for T are: Print(), ToBytes(), and Compare().
    // All of these calls are valid.
    string text = item.Print();
    byte[] data = item.ToBytes();
    int comparison = item.Compare(other);
    // ...
}

// To be used with ProcessComplex, this struct must implement all three methods.
struct ComplexType {
    public fn Print() : string { return "Complex data"; }
    public fn ToBytes() : byte[] { return new byte[](); }
    public fn Compare(ComplexType other) : int { return 0; }
}
Nullable Arguments and the Built-in Constraints#

A nullable declares no members, so U? never satisfies a constraint by duck typing. The three built-in constraints from the standard library — Equatable, Hashable, and Comparable — are the exception: U? satisfies each exactly when U does, with the lifted null semantics. null equals null and equals no value; null hashes to one fixed value; and null orders first (null < any value, null == null). Otherwise the element’s Equals, GetHashCode, or CompareTo decides. This is what admits List<int32?>.Contains, Set<string?>, Dictionary<Key?, V>, and Sort() on a List<int32?> (nulls first). When the element fails, the diagnostic names the element. A constraint of your own that merely matches the shape does not lift — U? is refused as before — while one that includes Equatable, Hashable, or Comparable lifts exactly those members.

Detailed Generic Method Inheritance Rules#

When overriding a virtual generic method in an inheritance hierarchy, or when implementing a generic method from an interface, Wax enforces strict matching rules to maintain type safety. The signature of the override or implementation must be identical to the base definition. This includes the number of generic parameters and all of their where clause constraints in the exact same order.

Wax
abstract class Base {
    public virtual fn Sort<T>(T[] items) where T is Comparable & Cloneable;
}

class Derived extends Base {
    // ✓ VALID: Constraints match exactly in the same order.
    public override fn Sort<T>(T[] items) where T is Comparable & Cloneable { /*...*/ }

    // ✗ ERROR: Constraints in the wrong order.
    // public override fn Sort<T>(T[] items) where T is Cloneable & Comparable { /*...*/ }
}

Per-Method Constraint Refinement#

A where clause on a method can constrain not only the method’s own type parameters, but also the enclosing type’s type parameters. This allows a generic type to offer some methods that only work when the type argument satisfies additional requirements, without forcing all users of the type into that constraint.

Wax
constraint Comparable {
    fn CompareTo(this other) : int32;
}

class Container<T> {
    T? value;

    // Works for any T, with no constraint needed.
    public fn Get() : T? { return this.value; }

    // Only callable when T satisfies Comparable.
    public fn Max(T other) : T where T is Comparable {
        if (this.value.CompareTo(other) > 0) {
            return this.value;
        }
        return other;
    }
}

fn Main() {
    // int32 satisfies Comparable, so both Get() and Max() are callable.
    var ints = new Container<int32>(5);
    ints.Get();
    ints.Max(3);           // OK

    // Blob does NOT satisfy Comparable, so only Get() is callable.
    var blobs = new Container<Blob>();
    blobs.Get();           // OK
    blobs.Max(new Blob()); // ERROR: Blob does not satisfy Comparable
}

The where clause does not affect whether the type can be instantiated. Container<Blob> is valid; you just cannot call methods that require constraints Blob doesn’t satisfy.

Inside the method body, the constraint is in effect: the compiler knows T is Comparable, so calling CompareTo on values of type T is valid.

A method can constrain both its own type parameters and enclosing type parameters in the same where clause:

Wax
class Container<T> {
    public fn MapSorted<U>(fn<(T) : U> f) : List<U>
        where T is Comparable, U is Hashable {
        // T is Comparable (from enclosing type), U is Hashable (method's own)
    }
}

Naming Conventions#

By convention, interfaces and constraints are named differently to improve clarity.

  • Interfaces: Start with an ‘I’ (e.g., IDisposable, IComponent).
  • Constraints: End with ‘-able’ or a similar suffix describing a capability (e.g., Comparable, Cloneable).

Constraints vs. Interfaces#

Constraints and interfaces serve different purposes. Constraints are a tool for compile-time polymorphism with zero runtime cost, while interfaces are for traditional runtime polymorphism.

When to Use Each#

Use a constraint when:

  • You are writing generic algorithms that need to operate on a wide variety of types.
  • You need to support value types (structs) without performance penalties.
  • Performance is critical and you want to avoid any runtime overhead (static dispatch).
  • The exact types are known at compile time.

Use an interface when:

  • You need to store different concrete types in a single, heterogeneous collection (e.g., IDrawable[]).
  • You need traditional object-oriented runtime polymorphism (dynamic dispatch).
  • You are working primarily with reference types (class).
  • You want to define a public API contract that must be explicitly implemented.
Key Design Considerations#

A critical difference is how name collisions are handled. Interfaces allow for explicit interface implementation (fn IMyInterface.Method() { ... }) to resolve conflicts. Constraints do not have this mechanism. A type must satisfy a constraint with its public “shape”. Any ambiguity results in a compile-time error.

Wax’s memory model is the deciding factor: structs cannot be boxed. This means a struct cannot be converted to a reference type to be stored in a collection of interface instances.

  • Constraints are the only way to achieve zero-cost, static polymorphism for structs.
  • Interfaces are the tool for runtime polymorphism for classes.
  • They are complementary. Constraints do not replace interfaces; they are the alternative for cases where runtime polymorphism is unnecessary or impossible.

Top-Level Declarations#

In Wax, not all code must live inside a class or struct. You can declare functions, constants, and global variables at the top level of a file. These declarations are fundamental for organizing code into modules, creating utility libraries, and defining global values.

Visibility and Host Communication#

Top-level declarations use a combination of keywords to control their visibility within Wax and their interaction with the host application.

Visibility Modifiers#

These keywords control whether a declaration can be accessed from other Wax code.

  • public (Default): The declaration is visible and can be used by any other Wax code. If no modifier is specified, public is assumed.
  • internal: The declaration is only visible to code within the same module.
  • protected: The declaration is only visible to code within the same namespace.
  • private: The declaration is only visible within the file it is declared in.
Host Communication Keywords#

These keywords control whether a declaration is part of the API exposed to or from the host application.

  • api: Exposes a Wax function to the host application. The host can call api functions declared at top level or as members of a static class. This keyword cannot be applied to classes, structs, top-level constants, or static fields.
  • host: Declares a function that is implemented by the host application, allowing Wax code to call it. host fn may also be declared inside a static class for namespace grouping.

These keywords can be combined with visibility modifiers to control both aspects.

Wax
// A function visible to all Wax modules and exposed to the host.
public api fn StartGame() { /* ... */ }

// An api function is a common way to expose a constant value to the host.
public api fn GetMaxPlayers() : int { return 4; }

// A function provided by the host, callable from any Wax module.
public host fn LogMessage(string message);

// A function provided by the host, but only callable from within this file.
private host fn LogDebug(string message);

// Static classes group related boundary functions without adding an instance receiver.
static class Console {
    api fn WriteLine(string message) { /* ... */ }
    host fn ReadLine() : string;
}

Top-Level Functions#

Functions declared outside of any type are known as top-level functions. They are useful for creating helper routines that don’t depend on the state of a specific object instance.

Wax
// A simple internal function.
internal fn CalculateDistance(Point a, Point b) : float {
    float dx = a.x - b.x;
    float dy = a.y - b.y;
    return Math.Sqrt(dx * dx + dy * dy);
}

For a complete guide to function syntax, including parameters, return values, and advanced features, please see the main “Functions in Wax” documentation.

Top-Level Constants (const)#

Use the const keyword to declare a value that is fixed at compile time. The compiler will substitute the value of a constant wherever it is used, which can lead to performance optimizations.

Wax
// A public constant, available to all Wax modules.
public const string AppName = "My Wax Application";

// An internal constant for this module only.
internal const float Pi = 3.14159f;

Because a const is substituted as a literal at each use, it holds only values that are literals: the numeric types, bool, char, string, and enums. Arrays, classes and structs are not const-able. Declare them static instead, which is the form that holds a real object:

Wax
const int32[] Sizes = new int32[] { 1, 2, 3 };    // rejected: an array is not a literal
static int32[] Sizes = new int32[] { 1, 2, 3 };   // ok

Top-Level Static Fields (static)#

For global state that can be modified at runtime, use a static field. These are global variables whose lifetime spans the entire program.

The static keyword can also be used on fields inside a class or struct, where the same rules apply.

Wax
// A global counter, visible only within this module.
internal static int32 globalCounter = 0;

// A nullable class reference, which starts as null.
private static GameState? currentState;

// Static fields on a class.
class Counter {
    static int32 instanceCount = 0;

    constructor() {
        Counter.instanceCount = Counter.instanceCount + 1;
    }
}

Once initialized, static fields can hold any value of their declared type, and they can be freely read and written at runtime like any other field. The restrictions below apply only to the declaration and initial value, not to runtime usage.

Type and Initialization Restrictions#

Declaration order is not the initialization order. The compiler first validates each initializer as either constant-safe or proven runtime-safe, records any static fields it reads, then orders statics so dependencies initialize first. Cycles in that dependency graph are rejected.

Allowed types:

Type Nullable required? Initializer required?
Blittable types (primitives, enums, blittable structs) No Yes
string No Yes
json No (implicitly nullable) No
Single-dimension static-initializer-safe arrays (int32[], string[], Color[], blittable Vec2[]) No Yes
Class references No, when the initializer is proven runtime-safe; otherwise yes Yes when non-nullable
Other reference types (delegates, jagged/multi-dimension arrays, arrays with class/delegate elements) Yes No
Nullable value types (int32?, etc.) n/a No

A single-dimension array whose size and elements are static-initializer-safe may be non-nullable, unlike reference types in general. Its contents are fully determined before normal program execution; when an element reads another static, that dependency participates in static-initializer ordering. The element type may be a primitive, an enum, a string, or a blittable struct (no reference fields). This is the idiomatic form for lookup tables: sine tables, palettes, charset maps, vertex/transform data. A non-nullable array of this kind must have an initializer (there is no implicit default); jagged/multi-dimension arrays (int32[][]) still must be nullable.

The initializer may be an element list (new int32[] { 1, 2, 3 }), a static-initializer-safe size (new int32[N], allocating N zero-filled elements), or the two combined (new int32[N] { 1, 2 }, filling a prefix and zero-filling the remaining tail). The combined form requires the size to be at least the initializer count, and is only prefix-fillable because the element’s zero is a real value. An element type with no valid zero default (a non-null reference, or a struct bearing one) must be covered exactly.

A non-nullable class static is allowed when the compiler can prove its initializer publishes a real object before normal execution starts. The value must come from a direct new Class(...) expression, or from a statically known helper/factory method whose single return is either a freshly allocated object or another already-proven static. The full constructor/helper path must avoid host and API calls, dynamic dispatch, delegate calls, escaping recoverable throws, static writes, writes through external references, and unknown heap writes. Recoverable throws handled by an explicit catch are allowed only when the catch path itself satisfies the same rules. Unrecoverable panics do not invalidate the proof: a panicking initializer does not publish a value that later Wax code can observe. Any static values it reads participate in the same dependency ordering as constant-safe initializers, and cycles are rejected. This allows constructors and generated field initializers to read other proven statics without requiring the static slot itself to be nullable.

Disallowed types:

  • Non-blittable structs, meaning structs containing reference-typed fields (e.g. string, class references), cannot be used as static fields. Their reference fields would need GC initialization, and allowing struct initializers would reintroduce circular dependency risk. Use a class static instead when reference fields are needed.
  • Generic type parameters: static T value inside a generic class is not allowed, because the concrete type could be anything at instantiation time. Use static T? value instead, since nullable types always have a safe default (null).
Initializer Scope#

A static field’s initializer resolves in its declaring type’s scope. A sibling static binds by bare name, which means the same slot as the qualified spelling, and the declaring type’s generic parameters are in view, so a generic type’s initializer can name the enclosing instantiation.

Statics of a generic type get per-instantiation storage, so a generic initializer seeds from the same instantiation it belongs to: in class Box<T>, Box<int32>.b reads Box<int32>.a and Box<bool>.b reads Box<bool>.a. Reading another generic type’s static through the enclosing parameter follows the same rule. Ordering and cycle rejection apply across all of these the same way they do at top level; a cycle that exists only between two instantiations, and not between the declarations they come from, is only visible once the program is generated, so it is reported then rather than while the declaration is checked.

The enclosing type’s instance members stay out of reach: an initializer runs before any instance exists, so this, base, and bare instance field, property, and method names are rejected.

Wax
static int32 a = 100;                 // top level

static class Counter {
    public static int32 a = 4;
    public static int32 b = a + 1;    // Counter.a, not the top-level a -> 5
}

class Box<T> {
    public static int32 a = 4;
    public static int32 b = Box<T>.a + 1;   // same slot as a bare `a`
}

class Other<T> {
    public static int32 c = Box<T>.a + 2;   // this instantiation's Box<T>.a
}
Initializer Restrictions#

Static field initializers must be either constant-safe static-initializer expressions or proven runtime-safe non-null class initializers. Constant-safe initializers may contain constants and reads of other static fields; the compiler dependency-orders those static initializers and reports a cycle if no valid order exists. Proven runtime-safe class initializers may call the selected constructor, fresh-receiver object-initializer field stores/property setters, and simple helpers/factories only when their transitive effects and return value are statically proven safe. The initializer expression itself cannot contain local variable or parameter reads, this, instance state, property accessors outside the admitted fresh object-initializer setter form, or other runtime-dependent expressions whose read set or effects cannot be tracked. Constructor bodies in the proven class path may use this only for the object being initialized.

Allowed initializers:

  • Literals: 42, 3.14f, true, 'A', "hello"
  • Enum members: Color.Red
  • Const references (resolved at compile time)
  • Arithmetic on constants: 1 + 2 (const-folded by the compiler)
  • Json literals: { x: 42, y: [1, 2, 3] }
  • Static-initializer-safe array literals: new int32[] { 1, 2, 3 }, new string[] { "a", "b" }, a sized array new int32[4] (zero-filled), and arrays of blittable struct literals new Vec2[] { new Vec2 { .x = 1, .y = 2 } }
  • Blittable struct object-initializers: new Vec2 { .x = 1, .y = 2 }, the parameterless form new Vec2(), and the combined form new Vec2() { .x = 1 } (every field value must itself be static-initializer-safe)
  • Reads of other static fields, including inside arithmetic, json literals, array literals, and blittable struct object-initializers, when the dependency graph is acyclic
  • Runtime-safe non-null class construction, such as static Settings settings = new Settings();, when the constructor/helper path satisfies the proof above
  • Runtime-safe non-null class object-initializers, such as static Settings settings = new Settings { .Value = LoadDefault() };, when every field value and property setter is proven safe for the freshly constructed object. An object-initializer assignment to a reference-storing field or an earlier property setter does not prove deep ownership for later setter writes through that field.
  • Runtime-safe factory/helper calls that return a fresh non-null class object or another already-proven static
  • null (for nullable types)

Not allowed:

  • Method calls for constant-safe values: static int32 x = ComputeValue();
  • Method or factory calls whose effects or return provenance are not proven safe
  • Parameterized constructor calls for blittable structs: static Vec2 origin = new Vec2(0, 0);. Use the object-initializer form new Vec2 { .x = 0, .y = 0 } instead
  • Class object-initializer/property-setter forms whose effects are not proven safe for the fresh object, including virtual/interface setters, setters that write statics, call host/API functions, use dynamic dispatch, write through external references, or rely on getter-returned external aliases as owned state
  • Local variables, parameters, this, instance fields, and property accessors
  • Static dependency cycles: static int32 a = b; static int32 b = a;
  • Any expression that could depend on untracked runtime state
Wax
// Valid static fields.
static int32 count = 0;
static string greeting = "hello";
static float pi = 3.14159f;
static json config = { debug: false, maxRetries: 3 };
static MyClass? instance;

// Non-nullable static-initializer-safe arrays (lookup tables) and blittable struct values.
static int32[] sineLut = new int32[] { 0, 70, 100, 70, 0 };
static string[] names  = new string[] { "ada", "alan" };
static Vec2[] points   = new Vec2[] { new Vec2 { .x = 1, .y = 2 }, new Vec2 { .x = 3, .y = 4 } };
static Vec2 origin     = new Vec2 { .x = 0, .y = 0 };

// Static reads are dependency-ordered, not declaration-ordered.
static int32 maxRetries = retryBase + 2;
static int32 retryBase = 3;

class StaticSettings {
    public int32 retries;

    public constructor() {
        this.retries = retryBase;
    }
}

static StaticSettings settings = new StaticSettings();

fn MakeSettings() : StaticSettings {
    return new StaticSettings();
}

static StaticSettings factorySettings = MakeSettings();

// At runtime, statics can be assigned freely.
fn Init() {
    instance = new MyClass();
    greeting = "goodbye";
    count = 100;
}

// Invalid: these would be compile errors.
// static int32 x = GetValue();        // method call
// static Vec2 origin = new Vec2(0,0); // struct constructor call
// static MyClass obj;                 // non-null class static needs a proven initializer
// static MyClass bad = MaybeGet();    // return value or effects not proven safe
// static NonBlittableStruct s;        // non-blittable struct not allowed
// static int32 a = b; static int32 b = a; // static cycle

Type Declarations and Imports Are Top-Level Only#

A type is scoped by the namespace it is declared in, never by an enclosing type. Wax has no nested type: class, struct, interface, enum, flags, error, constraint, host channel, and api channel are all top-level declarations, and there is no Outer.Inner name to reach one by. An import is top-level for a related reason: it binds a name for the whole file, not for one type’s body.

Written inside a type body, each of these is refused by name rather than treated as a member: ERR_TypeDeclarationMustBeTopLevel, ERR_ImportMustBeTopLevel, and ERR_ChannelMustBeTopLevel. Declare the type beside the one that would have contained it. Everything that could have named it from inside still can, because both live in the same namespace.

The rule runs the other way too. A constructor and an enum case each name something about the type that declares them, so both are members and nothing else; a top-level one is refused with ERR_ConstructorMustBeAMember and ERR_EnumCaseNotAllowedHere. A top-level function is spelled fn.

Wax
import List from Wax;

class Inner {
    public int32 x;
}

class Holder {
    public fn Make() : int32 {
        Inner i = new Inner();      // Inner is a sibling, and visible here
        return i.x;
    }
}

// Invalid: these would be compile errors.
// class Outer { class Nested { int32 x; } }   // ERR_TypeDeclarationMustBeTopLevel
// class Outer { import List from Wax; }       // ERR_ImportMustBeTopLevel
// class Outer { host channel Slots { int32 x; } }  // ERR_ChannelMustBeTopLevel
// class Outer { api channel Results { int32 x; } } // ERR_ChannelMustBeTopLevel
// constructor() { }                           // ERR_ConstructorMustBeAMember
// case Red;                                   // ERR_EnumCaseNotAllowedHere

Modules, Namespaces, and Imports#

Wax uses modules and namespaces to organize code and prevent naming conflicts.

  • Modules: A module is a self-contained unit of Wax code. Package modules have a unique registry identity and may also be given an importer-local alias.
  • Namespaces: A file can optionally declare a namespace, which must be the first statement in the file. This namespace is relative to the module’s root. If a file doesn’t declare a namespace, its contents belong to the module’s default root namespace. Namespaces can be nested using ::.
  • Imports: To use a declaration from another namespace, you must import it using the import keyword. Multiple symbols can be imported in a single statement using comma separation.
Wax
// In a file within a module named 'Gfx':
// This declaration must be at the top of the file.
namespace Paint::Textures; // The full namespace is Gfx::Paint::Textures

public class Texture2D { /* ... */ }
public fn LoadTexture(string path) : Texture2D { /* ... */ }

// ---

// In another file within the *same* 'Gfx' module:
// You can optionally omit the module name for same-module imports.
import Texture2D from ::Paint::Textures;

// ---

// In a file within a *different* module (e.g., 'App'):
// You must use the fully-qualified path.
import Texture2D, LoadTexture from Gfx::Paint::Textures;

// Now you can use the imported declarations directly.
var tex = LoadTexture("image.png");

Package dependencies can be addressed through the alias declared by the importing module or through an exact scoped identity. Exact identities are useful when aliases would be ambiguous. Package aliases begin with an uppercase ASCII letter. Durable package identities are lowercase ASCII words joined by single dots or hyphens, with every word beginning in a letter. Exact identities in Wax source are ASCII case-insensitive before ::; TitleCase words are recommended. Namespace names after :: remain case-sensitive.

When a project contains wax.lock, the compiler validates its package graph schema and verifies that the recorded SHA-512 hash matches the exact wax.json bytes before discovering package modules. Any manifest edit makes the lock stale until wax update records a new graph.

Wax
import Vector from Math::Linear;
import Matrix from @Wax/Math-Tools::Linear;
Import Aliasing#

To resolve naming conflicts or to use a shorter name for an imported type, you can use the as keyword to create an alias.

Wax
// Assume both modules define a 'Button' class.
import Button from MyEngine::UI;
import Button as GfxButton from Gfx::Widgets;

// Now the names are unambiguous.
var uiButton = new Button();
var gfxButton = new GfxButton();
Advanced Imports#

Just as with member access, it is possible to bypass the visibility rules when importing. This is highly discouraged outside of unit testing but is provided as a “sharp knife” when necessary. When using these features, you become responsible for their usage, i.e. library authors should always assume nobody is doing this and design their apis accordingly with appropriate modifiers, and should not be held accountable for non-public breaking changes.

  • Protected Imports: To import a protected symbol from another namespace, use the protected keyword at the beginning of the statement.

    Wax
    import protected MyType from Gfx::Paint::Internal;
    
  • Internal Imports: To import an internal symbol from another namespace, use the internal keyword at the beginning of the statement.

    Wax
    import internal MyType from Gfx::Paint::Internal;
    

Error Handling#

Wax’s error handling system is designed to be explicit, safe, and performant. It distinguishes between two classes of abnormal control flow: recoverable errors and unrecoverable panics.

An error represents a recoverable problem that is an expected part of program execution, such as a file not being found or a network connection failing. Functions that can produce errors must declare them as part of their signature. To maintain high performance, errors do not capture stack traces unless they are unhandled and are promoted to a panic.

A panic, on the other hand, represents a critical, unrecoverable bug in the program’s logic, like accessing an array out of bounds. Panics are triggered by the panic statement, which terminates the app immediately rather than raising a value that some caller could intercept. A panic’d app will not run again; the host application will need to restart it if it should continue. An unhandled error that propagates to the root of the call stack will also escalate into a panic.

Declaring Errors#

In Wax, an error is a specialized kind of reference type designed for recoverable failure conditions. The error keyword provides a clear, concise syntax for defining these types. Functionally, an error is a frame-scoped class that implicitly inherits from a built-in base error type. Errors can have fields, properties, constructors, and methods, and can form their own inheritance hierarchies; their lifetime is managed automatically by the frame-based memory system.

Wax
error FileError {
    public string path;
    public string message;
    
    constructor(string path, string message) {
        this.path = path;
        this.message = message;
    }

    // Errors can have methods to provide additional context or helpers.
    public fn GetDisplayMessage() : string {
        return "File Error on '${this.path}': ${this.message}";
    }
}

// Errors can extend other errors to create a hierarchy.
error HttpError extends NetworkError {
    public string responseBody;
    
    constructor(string endpoint, int statusCode, string body) 
        : base(endpoint, statusCode) {
        this.responseBody = body;
    }
}

Panicking#

panic terminates the app. It is a statement, not a throw: there is no Panic value, nothing for a catch to match, and no throws declaration required. A panic is legal in any function, including one that declares no errors at all.

A panic raised inside an api fn call unwinds to that API boundary and is reported to the host as a WxError; see Panics and the Host below. Anywhere else, meaning during startup, in frame-end work, or on the wasm backend, a panic terminates the process outright. Either way the app is finished; the difference is only whether a host survives to be told about it.

Wax
// Trigger a panic explicitly when an invariant is violated.
if (index < 0) {
    panic("Index must be non-negative, got $index");
}

It takes one or two string arguments: a message, and an optional detail string carried alongside it.

Wax
panic("bad state");
panic("bad state", "expected a non-zero capacity");

Because a panic never returns, it diverges. It satisfies a non-void return on its own, and it can be used anywhere a diverging term is allowed, such as the right-hand side of ?? or one arm of a ternary.

Wax
fn Lookup(string key) : string {
    panic("not implemented");           // stands in for the string return
}

string got = maybe ?? panic("missing");

unreachable is the sibling form, for a branch the programmer asserts can never execute. It takes an optional single string argument and panics if it is ever reached.

Wax
unreachable;
unreachable("regex VM produced an unknown opcode");

The runtime panics on its own for conditions like out-of-bounds array access, divide by zero, a failed forced cast (as!), and dereferencing or force-unwrapping a null (!). Note that ordinary +, -, and * wrap on overflow rather than panicking.

Function Error Declarations#

Functions must explicitly declare all recoverable errors they can produce using the throws keyword in their signature. This makes a function’s potential for failure a part of its contract.

Specific Errors#

A function declares the set of specific error types it can throw, separated by |. This is the most common and recommended form, because it gives the caller a precise, typed contract.

Wax
// This function can throw either of two specific error types.
fn FetchData(string url) : string throws NetworkError | TimeoutError {
    // ... implementation that can throw either error
}

A declared set names failure modes that need not be related to each other. Where they are related, subtyping composes with it: catch coverage is by supertype, so a function that declares throws NetworkError may throw any subtype of it, and a caller may catch either the base or a specific subtype.

Wax
error NetworkError { }
error TimeoutError extends NetworkError { }

fn Fetch(string url) : string throws NetworkError {
    throw new TimeoutError();          // a NetworkError, so the contract holds
}

fn Load(string url) : string throws NetworkError {
    // Handle just the timeout; any other NetworkError propagates.
    return try Fetch(url) catch TimeoutError |te| { yield ""; };
}

A single try may also see more than one error type because the expression it covers calls several different functions. Both routes feed the same typed catch clauses below.

A declared set is a promise about which types can leave the function, and callers rely on it: a set of typed catch clauses covering it is exhaustive on its own, with no untyped catch to fall back on. So every throw must name a value the compiler can place in the set. A value known only to be the base error type cannot be placed, even when it happens to hold a member at runtime, and is rejected.

Wax
fn Widened() : string throws NetworkError | TimeoutError {
    Error e = new TimeoutError();
    throw e;            // ✗ ERROR: only known to be `Error`, though it holds a member
}

fn Branching(bool useA) : string throws NetworkError | TimeoutError {
    throw useA ? new NetworkError() : new TimeoutError();
                        // ✗ ERROR: the two arms meet no lower than `Error`
}

fn Fixed(bool useA) : string throws NetworkError | TimeoutError {
    if (useA) { throw new NetworkError(); }   // ✓ each throw names its own type
    throw new TimeoutError();
}

Bare throws accepts all of these, because it promises nothing narrower than “some error” and so obliges the caller to handle everything.

Generic Errors#

In some cases, you may want to declare that a function can throw any error. This can be done with a generic throws clause. This is less type-safe but can be useful for simple scripts or highly generic functions.

Wax
// These two declarations are equivalent. They state that the function
// can throw any type of error.
fn RiskyOperation() throws {}
fn AnotherRiskyOperation() throws error {}

A function with no throws declaration at all cannot throw an error. It must handle all potential errors from functions it calls internally.

Handling Errors with try-catch Expressions#

Operations that can throw an error must be invoked with the try keyword. The try-catch construct is an expression, meaning it evaluates to a value. Both the try block and all catch blocks must produce a type that is compatible with the expression’s result type.

Wax
fn GetValue() : string {
    string content = try ReadFile("data.txt") catch |err| {
        // NOTE: 'return' exits the entire GetValue function.
        // To provide a value just for the try-catch expression, use 'yield'.
        Debug.Log("Failed to read file: ${err.message}");
        return "default content"; 
    };
    return content;
}

The yield Keyword#

Within a catch block, the yield keyword provides a concise way to return a value from the enclosing try-catch expression, effectively ending the catch block’s execution at that point. This is distinct from return, which exits the entire containing function.

Wax
// Using yield to return from the try-catch expression.
string result = try ReadFile("config.json") catch |e| {
    yield "default_config";  // Returns this value from the try-catch expression.
};

// Conditional yield for more complex logic.
string data = try FetchData(url) catch |e| {
    if (e.statusCode == 404) {
        yield "not_found";  // Early return from catch block.
    } 
    else {
        LogError(e);
        yield "error_default";
    }
};

What yield may carry follows the try it belongs to. A try that produces a value requires yield <value>; a bare yield there has nothing to supply and is rejected. A try used as a plain statement produces no value, so the reverse holds: a bare yield exits the catch early, and supplying a value is rejected.

Wax
// Statement-context try over a void call: bare yield leaves the catch early.
try DangerousOperation() catch |e| {
    if (CanRecover(e)) {
        yield;                  // done handling it; carry on after the try
    }
    panic("Unrecoverable error: ${e.message}");
};

Typed catch Blocks and Exhaustiveness#

To handle different error types gracefully, you can provide multiple, typed catch blocks. The compiler enforces exhaustiveness, meaning all error types declared in the function’s throws signature must be handled.

To ensure exhaustiveness, unhandled errors must either be propagated (by adding them to the containing function’s throws clause) or caught by a final, untyped catch |e| block.

The compiler knows the complete set of error types a try body can produce: every call in it declares its throws set, an override must declare the same throws set as the member it overrides, and panics are not catchable values. Coverage is by supertype, so catch NetworkError covers a thrown TimeoutError extends NetworkError, and a set of typed catches covering every type in that set is exhaustive on its own.

“Propagate the rest” and “handle all of it here” are both complete. When the typed catches leave an error type unhandled and the containing function’s throws clause accepts it, an error matching no clause is raised to the caller: the try does not fall through, and the statements after it do not run. That path leaves the function, so it owes a try expression no value, and a final untyped catch |e| is not required to satisfy the value analysis either.

This is what makes a clause set that is deliberately partial safe. Handling one specific error and leaving the rest to the caller is the same guarantee as handling none of them: the error always reaches someone.

Wax
fn Load(string url) : string throws NetworkError {
    // TimeoutError is handled here; any other NetworkError is raised to our
    // caller, so `Decorate` is not reached for one.
    string body = try Fetch(url) catch TimeoutError |te| {
        yield "";
    };
    return Decorate(body);
}

Coverage is decided on the declared types, so a clause naming a subtype of a declared thrown type (catch TimeoutError against a throws NetworkError callee) handles that subtype and propagates the rest, exactly as above. If the containing function declares no compatible throws, there is nowhere to propagate to and the unhandled type is a compile error instead.

A clause whose type is unrelated to every type the try can produce, being neither a supertype nor a subtype of any of them, can never run, and the compiler warns that it is dead code. Both relations count as live: catch NetworkError is live against a thrown TimeoutError because it catches it, and catch TimeoutError is live against a declared throws NetworkError because the value may be exactly that subtype. An untyped catch |e| catches everything and is never dead.

Wax
// This function handles all possible errors from FetchData locally.
fn GetHomepage() : string {
    string data = try FetchData("api.example.com")

    // This block only executes for a NetworkError.
    catch NetworkError |ne| {
        yield "Site is down, please try again later. (Code: ${ne.statusCode})";
    }

    // This block only executes for a TimeoutError.
    catch TimeoutError |te| {
        yield "Connection timed out. Please check your network.";
    }

    // Since FetchData can only throw NetworkError or TimeoutError, and both
    // are handled, the compiler knows this is exhaustive. A final untyped
    // catch is not needed here.

    return data;
}

Error Propagation and Rethrowing#

Errors can be propagated up the call stack or rethrown after partial handling.

  • Implicit Propagation: A try without a catch block automatically propagates any thrown error to its caller. The calling function’s throws signature must be compatible. This can be combined with return for concise propagation.
  • Explicit Rethrowing: Use the throw keyword inside a catch block to re-propagate an error or to translate one error type into another.
Wax
// Implicit propagation can be written concisely.
fn LoadConfiguration() : string throws FileError {
    // Any FileError from ReadFile is propagated to the caller.
    return try ReadFile("app.config");
}

// Translating an error
fn ProcessFile(string path) : string throws ProcessError {
    string content = try ReadFile(path)
    
    // Catch the specific error from ReadFile...
    catch FileError |fe| {
        // ...log it, and throw a new, more specific error.
        LogError("Underlying file error: ${fe.message}");
        throw new ProcessError("Failed to process file contents from $path");
    };
    
    return content;
}

Chaining Failable Operations#

The try keyword applies to an entire expression chain. If any part of the chain fails, the expression aborts and control transfers immediately to the catch block.

Wax
class DataProcessor {
    fn LoadData() : DataProcessor throws FileError { /* ... */ return this; }
    fn ValidateData() : DataProcessor throws ValidationError { /* ... */ return this; }
    fn ProcessData() : string throws ProcessError { /* ... */ }
}

// This function must declare all unhandled errors from the chain.
fn RunProcessor() : string throws ProcessError {
    string result = try new DataProcessor()
        .LoadData()     // Can throw FileError
        .ValidateData() // Can throw ValidationError
        .ProcessData()  // Can throw ProcessError
    
    // Handle FileError specifically
    catch FileError |fe| {
        yield "Default result: File error";
    }
    
    // Handle ValidationError specifically
    catch ValidationError |ve| {
        yield "Default result: Validation error";
    }

    // ProcessError is not handled, so it is allowed to propagate
    // because the function signature includes 'throws ProcessError'.
    
    return result;
}

Error Handling in Inheritance#

When overriding a method that throws, an override may declare the same throws set or a narrower one – a subset of what the base declares. It may not add an error type the base does not declare, as that would break the caller’s ability to exhaustively handle all possible errors. Narrowing is safe for every caller: code holding the base type already handles the full base set, so the clauses for errors the override cannot raise simply never run, while code holding the concrete type sees the smaller, honest set.

Narrowing stops short of dropping throws entirely. A throwing and a non-throwing method have different call signatures, and a virtual call goes through the base method’s signature – so an override must still declare throws, even when it raises fewer errors than the base.

Wax
abstract class DataSource {
    abstract fn Load() : string throws FileError | NetworkError;
}

class FileDataSource extends DataSource {
    // ✓ VALID: the same error signature.
    override fn Load() : string throws FileError | NetworkError {
        // ...
    }
}

class LocalFileSource extends DataSource {
    // ✓ VALID: narrowing. Reading a local file cannot raise a NetworkError, and
    // saying so is safe for every caller.
    override fn Load() : string throws FileError {
        // ...
    }
    
    // ✗ ERROR: cannot widen the error signature with a type the base never declares.
    // override fn Load() : string throws FileError | ParseError { }
}

Best Practice: It’s often better to avoid overriding throwing functions when possible. Consider using non-virtual methods or composition for operations that can fail, as it leads to simpler contracts.

Stack Traces and Performance#

Wax prioritizes performance in its error handling design. Throwing and catching a recoverable error costs nothing beyond the branch: an error value carries no stack trace, and none is captured on the throw path.

A recoverable error is returned as an ordinary value rather than unwound, so an error path is just a return: each function’s epilogue pops its own trace entry and nothing extra is needed.

A trace is still available at a panic, because the runtime maintains a lightweight call-depth trace stack rather than reconstructing one after the fact. Each entry records a function id, an iteration count, and a line/column, so pushing and popping it costs a couple of stores per call. A panic jumps straight to the api fn boundary and skips every callee epilogue, which is exactly why the stack still holds the full depth when it lands. The boundary formats the trace first, then restores the depth it snapshotted on the way in.

Trace recording is always on in the native/C backend. A release build is not debuggable, so it drops the work it can prove is dead: a function whose emitted body makes no call and contains nothing that can raise carries no trace ops at all. Any call disqualifies that elision, because the callee may raise.

The wasm backend carries no in-module trace instrumentation, so Debug.StackTrace() returns an empty string there. This pairs with the fact that a wasm panic traps rather than reaching a host boundary.

When a panic reaches an api fn boundary, the trampoline reports it to the host as a WxError, and the host can then request the formatted trace. Because the trace stack is live at the moment of the panic, producing it requires no replay. Replaying a frame from a snapshot is a separate and far more expensive facility that exists for time-travel debugging, not for stack traces.

Interaction with the Host Application#

The error handling system described here is entirely self-contained within the Wax environment. To maintain a clean and explicit boundary, the error system does not cross into the host application.

  • Error handling at the boundary uses WxError. Every api fn has an implicit WxError* parameter in the generated C signature. If the Wax function panics or throws an unhandled error, it is caught at the boundary trampoline and written to the WxError*. host fn declarations can optionally be marked throws, which adds a WxError* parameter to the generated signature that the host can write to signal failure. The Wax side must try such calls. See the Host Communication chapter for details.
  • Panics and the Host: A panic raised during an api fn call is caught at that API boundary trampoline and reported as a WxError with error type "panic", leaving the host process intact. This covers panics in host-invoked Wax code, which is nearly all of it; a panic with no boundary armed terminates the process, which covers startup, frame-end work, and the wasm backend (which traps instead). After a caught panic the host can optionally request diagnostic information. That includes the stack trace, read straight off the live trace stack, and a snapshot of the Wax memory state at the time of the panic, which can be used for later replay and debugging.

Best Practices#

  1. Handle errors close to the source. Propagate errors only when the caller has better context to handle them.
  2. Use specific, structured error types. This allows for more granular and intelligent error handling in catch blocks.
  3. Design APIs to minimize the error handling burden on the caller by handling errors internally when it makes sense.
  4. Use typed catch blocks to handle different errors differently and improve code clarity.

The json Type#

The json type is a special-purpose reference type designed for working with loosely-structured, dynamic data. Its primary use case is for safe and convenient data exchange with the host application. You represent and manipulate data with a familiar, JSON-like syntax, without the strictness of structs or classes.

JSON Literals#

You can create json objects directly in code using literals.

Object and Array Literals#

The most common way to create a json value is with the object ({...}) and array ([...]) literal syntax. A key difference from standard JSON is that property keys do not require quotes.

Wax
json config = {
    // Keys are unquoted identifiers
    name: "MyApp",
    version: 1.2,
    features: ["logging", "analytics", "debugging"],
    database: {
        host: "localhost",
        port: 5432
    },
    metadata: null
};
Primitive Literals#

You can also create a json value directly from any primitive literal (string, number, bool, or null). This is useful for representing simple values within the json type system.

Wax
json name = "Alex";
json score = 100;
json isAdmin = true;
json data = null;

Accessing Data#

Accessing data within a json object is designed to be safe by default. All property and array access operations return a value of type json. If a key or index does not exist, the returned json value will represent null.

Property Access#

You can access object properties using either dot notation or bracket notation with a string key.

Wax
json user = { name: "Alex", score: 100 };

// Dot notation
json name = user.name; // Returns a json value containing "Alex"
json score = user.score;   // Returns a json value containing 100
json email = user.email; // Returns a json value containing null
Array Access#

You can access elements in a json array using numeric indexing.

Wax
json data = { values: [10, 20, 30] };

json firstValue = data.values[0]; // Returns a json value containing 10
json outOfBounds = data.values[3]; // Returns a json value containing null
Null-Safe Chaining#

A key feature of json access is that it is null-safe. You can chain multiple accessors together without checking for null at each step. If any part of the chain is null or doesn’t exist, the entire expression gracefully evaluates to a json value containing null.

Wax
json config = { database: { host: "localhost", port: 5432 } };

// This chain is safe. Since 'connection' does not exist,
// the expression evaluates to a json value containing null.
json poolSize = config.database.connection.pool.maxSize;

// This null-safety composes directly with implicit conversion.
// The result is null because the path is invalid.
int? poolSizeAsInt = config.database.connection.pool.maxSize;

Spread in JSON Literals#

The spread operator (...) can be used inside JSON array and object literals to merge values from an existing source into a new literal.

Spreading into Arrays#

You can spread a json value (expected to be an array) or a typed array into a JSON array literal.

Wax
json head = [1, 2, 3];
json extended = [...head, 4, 5]; // [1, 2, 3, 4, 5]

// Multiple spreads
json a = [1, 2];
json b = [3, 4];
json merged = [...a, ...b, 5]; // [1, 2, 3, 4, 5]

// Spread a typed array; elements are automatically wrapped to json
int32[] scores = [] { 90, 85, 92, };
json data = [...scores, 100]; // [90, 85, 92, 100]
Spreading into Objects#

You can spread a json value (expected to be an object) into a JSON object literal. When the same key appears multiple times, the last value wins.

Wax
json defaults = { theme: "dark", fontSize: 14, lang: "en" };
json userPrefs = { ...defaults, fontSize: 18, lang: "fr" };
// Result: { theme: "dark", fontSize: 18, lang: "fr" }

// Multiple spreads
json a = { x: 1, y: 2 };
json b = { y: 3, z: 4 };
json merged = { ...a, ...b }; // { x: 1, y: 3, z: 4 }
Spread Type Requirements#
  • Array spread: The source must be either a json value or a typed array (T[]). Spreading other types is a compile-time error.
  • Object spread: The source must be a json value. Only json objects can be spread into object literals.

Mutability#

json objects are mutable. You can add, remove, or change properties after the object has been created. Because json is a dynamic type, you can also change the type of a value stored in a field at will.

Wax
json settings = { theme: "dark", id: 1 };

// Change an existing property's value and TYPE
settings.id = "user_001";

// Add a new property
settings.fontSize = 14;

// Add a property with a computed/dynamic key
string id = "user_123";
settings[id] = { name: "guest" };

// To remove a property, call DeleteProperty on the value.
settings.DeleteProperty("fontSize");
Freezing#

Freeze() makes a json object or array read-only, recursively, and returns the same node so the call chains. Any later mutation of a frozen node panics: SetProperty and SetElement (including their j.k = v and j[i] = v spellings), plus DeleteProperty, Push, Pop, and RemoveAt. The attempt is refused rather than its effect, so deleting an absent key or popping an empty frozen array panics too. IsFrozen() reports the state; it is always false for a scalar, which has nothing to protect (assigning j.x = 5 mutates the parent object, and the parent’s flag catches that).

Freezing is a property of the node, not of the path to it: a frozen subtree grafted into a mutable tree stays frozen, and freezing a child leaves its parent writable. Clone() is the way back to a mutable value. It builds fresh nodes, so a clone of a frozen tree is fully mutable. Spread ({ ...frozen }) produces a mutable result, but shares the source’s children, so those stay frozen.

Wax
json config = { theme: "dark", limits: { retries: 3 } };
config.Freeze();

bool locked = config.IsFrozen();          // true
bool deep = config["limits"].IsFrozen();  // true, because Freeze recurses

json editable = config.Clone();           // an unfrozen deep copy
editable.theme = "light";                 // fine

Writing to the frozen original instead panics, at any depth:

Wax
config.theme = "light";           // panics
config["limits"].retries = 5;     // panics, because the child is frozen too

Converting to Strong Types#

The most convenient way to get data out of a json object is to assign it directly to a nullable typed variable. The compiler will perform a safe, implicit conversion.

When you assign a json value to a variable like string? or int?, Wax automatically attempts to convert it. The result will be null if the json value did not exist, was null, or was of an incompatible type.

For numeric conversions to integer types, the conversion will only succeed if the json number is a whole number (has no fractional part) and fits within the range of the target integer type.

Wax
json data = {
    name: "main",
    value: 123,
    tooBig: 9999999999999999999999,
    hasDecimal: 45.6
};

string? name = data.name;           // -> "main"
int? value = data.value;             // -> 123
int? wrongType = data.name;         // -> null (type mismatch)
string? missing = data.address;     // -> null (key doesn't exist)
int? outOfRange = data.tooBig;       // -> null (value out of range)
int? fractional = data.hasDecimal;  // -> null (not a whole number)
Explicit Casting (Advanced)#

For cases where you need more control, you can use the explicit runtime casting operators.

  • as? (Safe Cast): Behaves similarly to the implicit conversion, returning null on failure. j as? T has type T?.

  • as! (Panicking Cast): Forces the conversion. If it fails, it will cause a panic. j as! T has type T.

Both forms accept the same target types the implicit conversion does: string, bool, and any numeric type (int8uint64, float, double, char). Any other target is a compile error.

Wax
json data = { name: "Alex", age: 30 };

string name = data.name as! string;  // "Alex"
int age = data.age as! int;          // 30
int? maybeAge = data.age as? int;    // 30
int? notAnInt = data.name as? int;   // null, since "Alex" is not a number
// int bad = data.name as! int;      // PANIC! "Alex" is not a number

Note on as: The standard as cast operator cannot be used with json because it performs compile-time-provable conversions.

An enum is not in the json-convertible set. Go through the enum’s backing integer, which keeps the two failure modes separate and visible: “the json value was not a number” and “the number was not a declared case”.

Wax
json data = { status: 2 };

int raw = data.status as! int;       // json -> backing integer
Status s = raw as! Status;           // integer -> enum, checked against the cases
Status? maybe = raw as? Status;      // null if 2 is not a declared case

Equality#

The standard == and != operators perform reference equality on json types, checking if two variables point to the exact same object in memory.

To perform a deep, value-based comparison, call DeepEquals() on one of the values. Two json values are considered deeply equal if they have the same structure and all their corresponding primitive values are equal. The order of keys in objects does not matter.

Wax
json user1 = { name: "Alex", score: 100 };
json user2 = { score: 100, name: "Alex" };
json user3 = user1; // user3 is a reference to user1

bool refEqual = (user1 == user3); // true (both point to the same object)
bool refNotEqual = (user1 == user2); // false (different objects in memory)

bool valEqual = user1.DeepEquals(user2); // true (deep equality, key order ignored)

Iteration#

You can iterate over json objects and arrays using a foreach loop.

A json value’s kind is a runtime fact, so the compiler cannot dispatch on it. The number of payload bindings selects the shape instead:

Form Meaning
foreach (j) |item| walks an ARRAY, binding each element as json. A non-array iterates zero times.
foreach (j) |key, value| walks an OBJECT’s properties in insertion order, binding string key and json value. A non-object panics, naming the kind.

There is no |item, index| form over a json array: one second binding cannot be both an int32 index and a property value. Track an index yourself, or use Length() with a plain for loop.

Iterating Over Arrays#

A foreach loop over a json array yields each element as a json value.

Wax
json features = ["logging", "analytics"];
foreach (features) |feature| {
    string? featureName = feature; // Implicit conversion
    Debug.Log("Feature: ${featureName}");
}

Serialization, Deserialization, and Introspection#

Serialization and introspection are split between one static on the json type and instance methods on a value.

Serialization and Deserialization#

json.FromString() is a static that creates a json value from a string; ToString() is an instance method that serializes one back. Because parsing can fail if the string is malformed, json.FromString() throws a JsonParseError and must be called within a try block.

JsonParseError.kind is one of UnexpectedEnd, UnexpectedToken, ExpectedColon, ExpectedCommaOrClose, InvalidString, InvalidNumber, UnterminatedComment, TrailingContent, or ExpectedArray. Its readonly position is the UTF-8 byte offset of the offending input. Both fields preserve the first parse failure, and its message includes that byte offset and kind name.

Wax
// A well-formed JSON string
string text = """{ "name": "API Data", "value": 42 }""";

// Parsing must be wrapped in a try-catch expression
json parsed = try json.FromString(text) catch |err| {
    Debug.Log("Failed to parse JSON: ${err.message}");
    yield { error: "Invalid format" }; // Provide a fallback value
};

string? name = parsed.name; // "API Data"

string serialized = parsed.ToString();
Iterating Over Object Properties#

Two payload bindings walk an OBJECT’s properties in insertion order, binding string key and json value. The walk is allocation-free and visits each property once — unlike GetKeys(), which allocates a string[] and then re-scans the property list on every user[key] lookup.

Wax
json user = { name: "Alex", score: 100 };
foreach (user) |key, value| {
    Debug.Log("$key: ${value}");
}

The property list is snapshotted before the loop, so properties the body adds or removes are not observed — the same rule the array form gets from hoisting Length(). Iterating anything but an OBJECT this way panics, naming the kind: two bindings are a claim about the value, not a request to skip it silently.

Introspection#
Wax
import JsonType from Wax;

json user = { name: "Alex" };
bool hasName = user.HasProperty("name"); // true

json data = { id: 123, tags: ["a", "b"] };
JsonType idType = data.GetPropertyType("id"); // JsonType.Number
int32 len = data["tags"].Length(); // 2
int32 count = data.PropertyCount(); // 2

Limitations#

  • Performance: json is significantly less performant than native Wax structs and classes. It should not be used for performance-critical logic.

  • No Constraints: A json type cannot satisfy any constraint.

  • Host Boundary Focus: Its primary role is to act as a data container for communication with the host application.


The Wax Memory Model#

Most programming languages force a choice between manual memory management (fast but error-prone) or garbage collection (safe but often unpredictable). Wax is designed to give you both safety and predictability.

Wax’s memory model provides deterministic performance and automatic memory management. It combines frame-based execution with a hybrid garbage collector that uses nursery promotion to handle short-lived allocations efficiently, making advanced features like memory snapshotting a core part of the language.

Core Memory Concepts#

1. Frame-Based Execution#

Wax organizes program execution into discrete frames, well-defined units of work with a clear beginning and end. The host application has complete control over what constitutes a frame:

  • Game engines: A single render frame (e.g., 16.67ms at 60fps)
  • Web servers: Processing a single HTTP request/response
  • Databases: Executing a single query
  • UI applications: Handling one event or render pass

The host application signals frame boundaries to the Wax runtime. This model provides predictable points for memory cleanup and ensures consistent performance. Frames are never nested, which simplifies the execution model.

2. Automatic Garbage Collection#

Wax uses a hybrid garbage collector, split by whether a type can form a reference cycle.

  • Compiler Analysis: The compiler statically analyzes all type definitions to determine which types could possibly form a reference cycle.
  • Optimized Management for Acyclic Types: For types that cannot form cycles, the runtime uses a more lightweight management strategy that avoids the overhead of full garbage collection scans.
  • Cycle Detection: Only the small subset of objects that could participate in a cycle are tracked by the main garbage collector. The cycle detection algorithm runs only on these objects.
  • Deterministic Timing: The garbage collector runs only at the end of each frame. This provides predictable, consistent pauses instead of random ones, but it also means an application that over-allocates memory within a single frame can exceed its limit before the collector has a chance to run.

This hybrid approach means the vast majority of objects are managed with minimal overhead, making memory management highly efficient and predictable.

3. A Note on Object Pooling#

Developers coming from other languages, especially in game development, are often accustomed to implementing manual object pools to avoid garbage collection overhead. In Wax, this practice is an anti-pattern and is strongly discouraged.

The hybrid garbage collector with nursery promotion is already highly optimized for short-lived allocations. Fighting the GC by manually managing object lifecycles adds complexity, is error-prone, and is unlikely to yield any performance benefits.

Trust the Wax memory model; it is designed to handle these scenarios for you.

Memory Safety Guarantees#

The Wax memory model provides several compile-time and runtime safety guarantees:

  1. No Null Dereferences: Null safety is enforced by the type system.
  2. No Buffer Overflows: All array access is bounds-checked.
  3. No Use-After-Free: The garbage collector ensures objects are not deallocated while still in use.
  4. No Data Races: Host-facing execution is app-thread-affine. A restricted parallel kernel can use runtime workers only after complete bounds/alias preflight and only with statically isolated, allocation-free effects.

Snapshotting and Time-Travel Debugging#

Wax’s memory model is what makes cheap state snapshotting possible.

  • Deterministic Replay: The memory model, combined with Wax’s deterministic execution, allows for perfect replays of program sessions.
  • Time-Travel Debugging: Developers can step forward and backward through code execution to inspect state at any point in time.
  • Efficient Snapshots: Frame boundaries provide natural points to snapshot memory. Nursery objects that have not survived promotion do not need to be included in the snapshot, dramatically reducing the size and performance cost.
  • Universal Replay: This efficiency makes it practical to record a production issue and replay it perfectly on a developer’s machine, eliminating the need for vague bug reports.

Spans#

Wax provides Span<T> and ReadOnlySpan<T> as lightweight, bounds-checked views over contiguous storage. A span borrows its backing storage; it does not own or keep that storage alive. Both types are scoped structs containing a backing address and an element count.

The examples in this chapter assume these ordinary stdlib imports:

Wax
import List from Wax;
import ReadOnlySpan from Wax;
import Span from Wax;

The scoped lifetime is part of the safety contract. A span may be passed to callees, sliced, and used by iterator pipelines, but it cannot be stored in a heap object, array, static, or closure. Escape analysis also prevents a view from being returned beyond the lifetime of the storage from which it was derived.

Nullability composes with a span like it does with any other type. Span<T>? is a stack value holding the span inline next to a presence flag, so an absent view and a present-but-empty view are distinct: new stackalloc int32[0] wrapped into a Span<int32>? is not null. The wrapper borrows exactly as far as the bare span does: it is admitted wherever Span<T> is admitted, and refused wherever Span<T> is refused. In particular Span<T>?[], a Span<T>? field in a non-scoped struct, and a Span<T>? static all stay illegal, because those are heap storage rather than a composition of nullability.

Wax
fn First(Span<int32>? maybe) : int32 {
    if (maybe) |view| { return view[0]; }
    return 0;
}

fn Widen(Span<int32> view) : Span<int32>? {
    return view; // implicit wrap; still bounded by view's own lifetime
}

The same holds for any user scoped struct, including a generic one over a span field, so an optional view type composes with ? without a wrapper type of its own.

Bounds#

Indexing checks that the index is in [0, size). Slice(start, len) checks the entire half-open range [start, start + len) before constructing a sub-view; negative values, arithmetic overflow, and ranges beyond the current view panic. The checks are relative to the current span, so slicing a slice cannot reach outside its parent view.

Wax
var values = new int32[] { 10, 20, 30, 40 };
scoped Span<int32> middle = values.Slice(1, 2);
middle[0] = 99; // writes values[1]

// middle[2] = 0;       // panics: index is outside middle
// middle.Slice(1, 2);  // panics: range is outside middle

Mutable and Read-Only Views#

Span<T> permits reads and writes. ReadOnlySpan<T> has the same scoped borrow and bounds rules, but exposes no mutating indexer or algorithms. A mutable span widens implicitly to ReadOnlySpan<T> without copying; the reverse conversion is not allowed.

Wax
var list = new List<int32>();
list.Add(10);
list.Add(20);

scoped Span<int32> writable = list.ToSpan();
scoped ReadOnlySpan<int32> readable = writable;
int32 first = readable[0];

A read-only indexer returns its element by value. A value-type element therefore does not expose writable storage: copy it to a local before changing one of its fields. A reference-type element still names the same object, so its fields may be changed; the span slot itself cannot be assigned or passed by ref/out. Mutable Span<T> still permits whole-element writes. Passing an element slot by ref/out, including to change one of its fields, is currently supported for user-struct elements.

For a mutable span over GC-managed storage, the runtime marks the owner dirty when the span is formed so writes are visible to frame-end change tracking. The owner is not carried in the span itself. Collection mutation rules still apply: mutation through a span panics while the collection is pinned by an active iterator.

Stack-Allocated Spans: new stackalloc T[...]#

new stackalloc T[n] allocates n contiguous T slots in scratch stack memory and returns a scoped Span<T>. Leaving the scope restores the scratch stack.

Stackalloc storage is zero-initialized before any explicit stores:

Wax
var tmp = new stackalloc int32[128]; // every slot starts at 0

Because zero is not a valid value for every T, the legal forms are:

  • new stackalloc T[n] is valid only when zero is a valid T value, or when n is the compile-time constant 0.
  • new stackalloc T[] { ... } allocates exactly the number of initializer elements, possibly zero, and is valid for ref-bearing and other non-zero-default element types.
  • new stackalloc T[k] { ... } is valid when the compile-time size is at least the initializer count for zero-valid T, and exactly equal to the initializer count for non-zero-default T.
  • new stackalloc T[n] |i, length| { yield expr; } fills every slot with the yielded value. Every path through the generator body must yield.
Wax
var names = new stackalloc string[] { "ann", "bea" };
var objs = new stackalloc Widget[10] |i| { yield new Widget(i); };

lateinit cannot be combined with stackalloc; use an initializer list or a generator when the element type cannot be safely zero-filled. Multi-dimensional and jagged stackalloc forms are not supported.

Text Views#

StringSlice is a scoped, immutable view over a string’s UTF-8 bytes. It is backed by ReadOnlySpan<uint8>, and its offsets and length are byte counts. Slicing is zero-copy and follows the same scoped lifetime and relative bounds rules as ReadOnlySpan<uint8>. A text slice must start and end on UTF-8 scalar boundaries; splitting a multi-byte scalar panics rather than creating invalid text.

Span<char> is not a string byte view. Wax char values are UTF-32 Unicode scalars, so Span<char> is contiguous scalar storage with four-byte elements. Use StringSlice for text slicing and ReadOnlySpan<uint8> when an API needs the encoded UTF-8 bytes.

Wax strings always contain valid UTF-8. string.FromUtf8(bytes) validates a ReadOnlySpan<uint8> and throws Utf8Error with the first invalid byte position in both its readonly position field and message. string.FromUtf8Lossy(bytes) replaces malformed input with U+FFFD. Use ToReadOnlySpan() for a zero-copy view of a string’s encoded bytes; arbitrary binary payloads belong in byte arrays or buffers, not strings.


Iterators#

Wax’s iterator system is a zero-cost abstraction for composable data transformation pipelines. Where traditional iterator patterns lean on heap allocations and virtual dispatch, Wax iterators are expression-only constructs. A pipeline of iterator operations is a temporary expression that cannot be stored in a variable and must be consumed immediately by a “sink” operation. That restriction is what lets the compiler fuse the whole chain.

This system is built on a few core principles:

  • Expression-Only: An iterator chain is a temporary expression that must be consumed immediately by a “sink” operation. It cannot be stored in a variable.
  • Compile-Time Fusion: An entire chain of operations like Filter and Map is flattened into a single, efficient loop by the compiler.
  • Stack-Based State: The state required for an iteration is managed on the stack, avoiding heap allocation for the iterator itself.
  • Capability-Based Sources: The -> operator works on indexed span/range/collection sources and on opaque single-pass iterator sources. Indexed sources expose size and element access for the strongest optimizations. Opaque sources still fuse streaming pipelines, but they do not claim size, indexing, reverse iteration, or hidden buffering.

The Iterator Pattern: Source -> Transformer -> Sink#

An iterator pipeline begins with a source, applies zero or more transformers, and ends with a sink. The -> operator is used to chain these operations together.

Wax
// A simple pipeline:
// - `numbers` is the source.
// - `Filter` and `Map` are transformers.
// - `Sum` is the sink that consumes the iterator.
int sum = numbers
    ->Filter(|x| => x > 0)
    ->Map(|x| => x * 2)
    ->Sum();

Because iterator chains are expressions that must be terminated, they cannot be assigned to variables. Only ordinary collection or span sources can be stored and reused.

Wax
import List from Wax;
import Range from Wax;

// Store an ordinary collection source.
List<int32> values = new Range<int32>(1, 10)->ToList();

// This source can be used to start multiple pipelines.
var evens = values->Filter(|x| => x % 2 == 0)->ToList();
var odds = values->Filter(|x| => x % 2 != 0)->ToList();

// ERROR: You cannot store an incomplete pipeline.
// var myPipeline = values->Filter(|x| => x > 0); // Compile-time error.

// ERROR: A pipeline must be consumed by a sink.
// values->Filter(|x| => x > 0); // Compile-time error: no sink.

Span-Based Iteration#

The -> operator works on readable span sources, meaning contiguous, indexed views of data. Any type that is already a Span<T> or ReadOnlySpan<T>, or has a zero-argument ToSpan() or ToReadOnlySpan() method, can be used as a pipeline source. The compiler inserts that span-producing call automatically:

Wax
// Arrays, Lists, FixedLists, and Stacks all expose readable spans, so the compiler calls the span producer automatically.
int32[] arr = new int32[] { 1, 2, 3, 4, 5 };
int sum = arr->Filter(|x| => x > 0)->Sum();

List<int32> list = new List<int32>();
int count = list->Filter(|x| => x > 10)->Count();

// Spans can be used directly.
Span<int32> span = arr.ToSpan();
int first = span->First() ?? 0;

// ReadOnlySpan sources can also be used directly.
ReadOnlySpan<int32> readOnly = arr.ToReadOnlySpan();
int found = readOnly->IndexOf(3);

The span conversion is a zero-cost operation for arrays and lists (it’s a pointer + length, no copy). This design keeps all fused iteration over contiguous, indexed data, which is what makes its performance predictable.

Opaque Iterator Sources#

A type with public instance, non-generic, non-throwing, non-state zero-argument methods MoveNext() : bool and Current() : T can also start a pipeline. This is a single-pass, C#-style cursor protocol: MoveNext() advances to the next item and returns whether one exists; Current() returns the item selected by the most recent successful MoveNext().

Wax
struct Counter {
    int32 next;
    int32 max;
    int32 current;

    constructor(int32 max) {
        this.max = max;
    }

    public fn MoveNext() : bool {
        if (next >= max) return false;
        current = next;
        next = next + 1;
        return true;
    }

    public fn Current() : int32 {
        return current;
    }
}

int32 sum = new Counter(10)
    ->Filter(|x| => x % 2 == 0)
    ->Map(|x| => x * 3)
    ->Take(3)
    ->Sum();

Opaque sources are fused into one streaming loop and do not allocate iterator objects. They are intentionally treated as unknown-size and non-indexed:

  • Allowed: streaming operations that do not require known size, indexing, or hidden buffering:
    • Transforms: Filter, Map, FilterMap, NotNull, FlatMap, Flatten, Enumerate, Take, Skip, TakeWhile, SkipWhile, Scan, Repeat, PadTo, Inspect, With, Zip, Chain.
    • Aggregation/search sinks: Sum, SumBy, Average, AverageBy, AverageOrElse, AverageByOrElse, Count, Min, Max, MinOrElse, MaxOrElse, MinBy, MaxBy, MinByOrElse, MaxByOrElse, Fold, Reduce, ReduceOrElse, First, Last, FirstOrElse, LastOrElse, Singular, SingularOrElse, Any, Empty, All, None, AtLeast, AtMost, Exactly, Contains, IndexOf, LastIndexOf, FindIndex, FindLastIndex.
    • Collection/action sinks: ToList, ToListWithCapacity, AppendToList, ToSet, AppendToSet, ToDictionary, AppendToDictionary, Partition, Join, ForEach.
  • Rejected: operations that need known size, indexing, or hidden buffering: Reverse, TakeLast, SkipLast, Distinct, DistinctBy, Window, Chunk, GroupBy, Sorted, SortedDescending, SortedBy, SortedDescendingBy, SortedStable, SortedStableDescending, SortedStableBy, SortedStableDescendingBy, and ToArray.

Materialize explicitly when you want collection behavior:

Wax
List<int32> items = new Counter(10)->ToList();
List<int32> sorted = items->Sorted();

Built-in Operations#

The iterator system includes a standard library of sources, transformers, and sinks.

Sources#

Sources start a new iterator chain.

  • new Range<int32>(start, end): Generates integers from start (inclusive) to end (exclusive). Element type is int32.
  • new Range<int32>(start, end).Step(step): Generates integers from start to end with a given step. Step must be a positive integer. Example: new Range<int32>(0, 10).Step(2)0, 2, 4, 6, 8.

Range<T> is an integral value type, so values such as Range<uint32> and Range<int64> are valid. Iterator sources currently require Range<int32> because fused iterator indices and counts are int32.

  • Collections: Any Array<T>, List<T>, FixedList<T>, Stack<T>, Span<T>, ReadOnlySpan<T>, or type with a zero-argument ToSpan() or ToReadOnlySpan() method.
  • Opaque iterator sources: Any type with public instance, non-generic, non-throwing, non-state zero-argument methods MoveNext() : bool and Current() : T. These support fused streaming operations but reject operations that require size, indexing, or hidden buffering.
Transformers#

Transformers take an iterator and produce a new one. They are lazy and do not execute until a sink is called.

Transformer Signature / Example Description
Filter ->Filter(|x| => x > 0) or ->Filter(IsPositive) Keeps items that match a predicate
Map ->Map(|x| => x * 2) or ->Map(Project) Applies a function to each item
FilterMap ->FilterMap(|x| => x > 0 ? x * 10 : null) Maps to T?, keeps non-null results, unwraps to T. Combines Map + NotNull in one step.
NotNull() ->NotNull() Removes null items from T? and returns T
FlatMap ->FlatMap(|x| => x.items) Maps each element to a collection and flattens one level
Flatten() ->Flatten() Flattens a sequence of collections (1 level)
Enumerate() ->Enumerate() Enables index access in downstream payload bindings. ->Enumerate()->Map(|x, i| => ...)
Take(n) ->Take(10) Takes first n elements. Non-positive n yields empty.
Skip(n) ->Skip(5) Skips first n elements. Non-positive n skips none.
TakeLast(n) ->TakeLast(3) Buffers and yields the last n elements. Non-positive n yields empty.
SkipLast(n) ->SkipLast(3) Buffers and skips the last n elements. Non-positive n skips none.
TakeWhile ->TakeWhile(|x| => x < 100) Takes while predicate is true
SkipWhile ->SkipWhile(|x| => x < 100) Skips while predicate is true
Distinct() ->Distinct() Removes duplicate items (hash-based). Requires T is Hashable.
DistinctBy ->DistinctBy(|x| => x.name) Removes duplicates by key (hash-based). Requires key type is Hashable.
Reverse() ->Reverse() Reverses iteration order (zero-cost for indexed sources)
Scan ->With(0)->Scan(|acc, x| => acc + x) or ->With(0)->Scan(Accumulate) Like Fold but yields the running accumulator at each step. Requires With(initial).
Window(n) ->Window(3) Sliding windows of positive size n (yields T[] arrays)
Chunk(n) ->Chunk(4) Groups into fixed-size T[] arrays with positive size n (last chunk may be smaller)
GroupBy ->GroupBy(|x| => x.category) Groups elements by key. Requires key type is Hashable. Yields Group<K, V> structs with .key and .values fields. Groups are yielded in first-encounter key order and each group’s .values are in source order; the key expression is evaluated exactly once per element.
Repeat(n) ->Repeat(3) Repeats each element n times. Non-positive n yields empty.
PadTo(n, val) ->PadTo(5, 0) Ensures at least n items, padding with val if needed. Non-positive n is a no-op.
Inspect ->Inspect(|x| => Log(x)) Performs a side effect (debug/log) but passes the item through unchanged
With(values...) ->With(0) or ->With(b, c) Provides values for a subsequent operation. Context-sensitive: single value for Fold/Scan/Zip, multiple for Chain.
Zip ->With(other)->Zip(|a, b| => a + b) or ->With(other)->Zip(Combine) Pairs elements from two sources with a combining function. Requires With.
Chain ->With(b)->Chain() Concatenates sources. Consumes all pending With values.

Why are Transformers Built-in? Transformers are a fixed set of operations built directly into the Wax compiler. The zero-cost promise depends on it. The compiler knows exactly what each transformer does, which lets it perform compile-time loop fusion, rewriting an entire chain into a single, highly-optimized loop. If users could define their own transformers, the compiler would be unable to reason about their behavior and could not guarantee this fusion, breaking the core performance model.

Sinks#

Sinks consume the iterator and produce a final result.

Aggregation:

  • Sum(): Returns the sum of all elements. Requires numeric element type.
  • SumBy(|x| => x.value): Returns the sum of a numeric selected value. Also accepts a matching function/delegate value such as SumBy(ProjectValue).
  • Average(): Returns the average as double?. Returns null if empty. Requires numeric element type.
  • AverageBy(|x| => x.value): Returns the average of a numeric selected value as double?. Returns null if empty. Also accepts a matching function/delegate value.
  • AverageOrElse(default): Returns the average or the provided default. Returns double. Requires numeric element type.
  • AverageByOrElse(default, |x| => x.value): Returns the average of a numeric selected value or the provided default. Returns double. Also accepts a matching function/delegate reference as AverageByOrElse(default, selector).
  • Count(): Returns the number of elements (int32).
  • Min() / Max() / With(compare)->Min() / With(compare)->Max(): Returns the min/max element as T?. Returns null if empty. Without With(compare), the element type must be numeric or define a plain non-static non-throwing instance CompareTo(T) : int32. With(compare) uses a named function or delegate value with shape fn(T, T) : int32 and does not require the element type to define CompareTo.
  • MinOrElse(default) / MaxOrElse(default): Returns min/max or the provided default. Returns T. Also accepts a comparator via With(compare).
  • MinBy(|x| => x.key) / MaxBy(|x| => x.key) / With(compare)->MinBy(...) / With(compare)->MaxBy(...): Returns the element with the min/max key as T?. Also accepts a matching one-, two-, or three-argument function/delegate value such as MinBy(ProjectKey). The element type does not need to be numeric. Without With(compare), the selected key must be numeric or define a plain non-static non-throwing instance CompareTo(K) : int32. With(compare) uses a named function or delegate value with shape fn(K, K) : int32 over the selected key type.
  • MinByOrElse(default, |x| => x.key) / MaxByOrElse(default, |x| => x.key): Returns the element with the min/max key or the provided default. Returns T. Also accepts a matching function/delegate reference as MinByOrElse(default, selector), and accepts a key comparator via With(compare). Uses the same key-ordering rule as MinBy / MaxBy.
  • Fold(|acc, x| => expr): General-purpose reduction. Requires With(initial): ->With(0)->Fold(|acc, x| => acc + x). Also accepts a matching function/delegate reference with (acc, x) or (acc, x, index) parameters.
  • Reduce(|acc, x| => expr): Like Fold but uses the first element as the seed. Returns T? (null if empty). Also accepts a matching function/delegate reference with (acc, x) parameters.
  • ReduceOrElse(default, |acc, x| => expr): Like Reduce, but returns the provided default when empty. Returns T. Also accepts a matching function/delegate reference as ReduceOrElse(default, reducer).

Element access:

  • First() / Last(): Returns first/last element as T?. These sinks take no predicate; use Filter(predicate)->First() or Filter(predicate)->Last() to select matching elements.
  • FirstOrElse(default) / LastOrElse(default): Returns first/last or default. Returns T. These sinks take exactly one default value and no predicate; use Filter(predicate)->FirstOrElse(default) or Filter(predicate)->LastOrElse(default) to select matching elements.
  • Singular() / SingularOrElse(default): Returns the only element when the stream has exactly one element. Singular() returns T? and returns null for zero or multiple elements; SingularOrElse(default) returns T and returns the default for zero or multiple elements.
  • Any(predicate?) / Empty() / All(predicate) / None(predicate): Tests if any elements match, if the stream is empty, if all elements match, or if no elements match. Any() without predicate tests if non-empty.
  • AtLeast(n) / AtMost(n) / Exactly(n): Tests the number of elements in the stream. These short-circuit as soon as the result is known.
  • Contains(value): Tests if a specific value exists in the sequence. Short-circuits on first match.
  • FindIndex(predicate) / FindLastIndex(predicate): Predicate search. Returns the first/last stream ordinal matching a predicate as int32, or -1 if not found. Uses payload syntax (->FindIndex(|x| => ...)) or a matching function/delegate reference and is compiler-inlined. The ordinal is relative to the stream at the sink, after upstream transforms.
  • IndexOf(value) / LastIndexOf(value): Value search. Returns the first/last stream ordinal equal to value as int32, or -1 if not found. Uses equality and requires the element type to be comparable to the value type. The ordinal is relative to the stream at the sink, after upstream transforms.

Collection building:

  • ToList(): Collects into a new List<T>. (Allocates).
  • ToListWithCapacity(n): Like ToList() with pre-allocated capacity. (Allocates).
  • ToArray(): Collects directly into a new T[] when the compiler can prove the exact output count, such as arrays, spans, ToSpan()/ToReadOnlySpan() sources, and cardinality-preserving pipelines (Map, Inspect, Reverse, Scan, Enumerate). Count-changing pipelines such as Filter, NotNull, FilterMap, Take, Skip, TakeLast, SkipLast, Distinct, FlatMap, Window, Chunk, GroupBy, and PadTo must use ToList() instead. Opaque iterator sources must also use ToList() because their size is unknown. (Allocates).
  • AppendToList(list): Appends to an existing List<T>.
  • ToSet(): Collects into a new Set<T>. Deduplicates. Requires T is Hashable. (Allocates).
  • AppendToSet(set): Adds elements to an existing Set<T> instead of creating a new one. Requires T is Hashable.
  • ToDictionary(|x| => key): Collects into a Dictionary<K, V> using the key extractor. Requires key type is Hashable. (Allocates).
  • AppendToDictionary(dictionary, keyFunction) / With(dictionary)->AppendToDictionary(|x| => key): Adds elements to an existing Dictionary<K, V> using the key extractor. Requires key type is Hashable. Payload syntax requires the dictionary to be supplied by With(dictionary). Function/delegate key selectors work in either form.
  • Sorted() / SortedDescending() / With(compare)->Sorted() / With(compare)->SortedDescending(): Collects into a sorted List<T>. Sorted() and SortedDescending() use the element type’s ordering; With(compare) uses a named function or delegate value with shape fn(T, T) : int32 and does not require the element type to define CompareTo. Descending forms reverse the element ordering. Inline lambda arguments are rejected; use payload syntax for payload-capable operations or store the comparator in a function/delegate value. (Allocates). Not stable; see Ordering stability below.
  • SortedBy(|x| => key) / SortedDescendingBy(|x| => key) / With(compare)->SortedBy(...) / With(compare)->SortedDescendingBy(...): Collects and sorts by key extraction. Also accepts a matching one-argument function/delegate value such as SortedBy(ProjectKey). The element type does not need to be numeric. Without With(compare), the selected key must be numeric or define a plain non-static non-throwing instance CompareTo(K) : int32. With(compare) uses a named function or delegate value with shape fn(K, K) : int32 over the selected key type. (Allocates). Not stable; see Ordering stability below.
  • SortedStable() / SortedStableDescending() / SortedStableBy(|x| => key) / SortedStableDescendingBy(|x| => key): The stable siblings of the four sinks above, accepting exactly the same forms including With(compare). Elements that compare equal keep their relative order from the source stream. (Allocates both the result List<T> and, when the result has more than 16 elements, one scratch array the same length.)
Ordering stability#

Sorted, SortedDescending, SortedBy and SortedDescendingBy are deterministic but not stable. Deterministic means the same input elements and the same comparator always produce the same output, on every backend and in every build configuration. Not stable means that when two elements compare equal, their relative order in the output is unspecified: it is whatever the sort algorithm happens to produce, it is not derivable from the input, and it may change between compiler versions without notice. Do not write a program whose output depends on it.

SortedStable, SortedStableDescending, SortedStableBy and SortedStableDescendingBy are stable: elements that compare equal appear in the output in the same relative order they had in the source. This is a language guarantee. Note that the descending stable forms reverse the ordering of the keys only; equal elements still keep their source order rather than being reversed.

The same split applies to the collection methods: Array<T>.Sort / SortBy and List<T>.Sort / SortBy are unstable and allocate nothing; Array<T>.SortStable / SortStableBy and List<T>.SortStable / SortStableBy are stable and allocate one scratch array when the collection has more than 16 elements. Both families are O(n log n).

Prefer the unstable sorts unless equal-element order matters, since they are the ones that do not allocate. Sorting by a key that is unique makes the two families produce identical output, in which case the unstable form is strictly better.

  • Partition(predicate): Appends matching elements to the first list and non-matching elements to the second list supplied by With(listA, listB).

String building:

  • Join(separator): Joins elements into a string with the given separator. Elements are converted via ToString().

Side effects:

  • ForEach(action): Executes an action for each element. Uses payload syntax (->ForEach(|x| { DoSomething(x); })) or a matching function/delegate reference.
Wax
// Nullable sinks, safe for empty sequences
int32? first = numbers->Filter(|x| => x > 0)->First();
int32? smallest = numbers->Min();
double? average = numbers->Average();

// OrElse variants provide a fallback
int32 first = numbers->Filter(|x| => x > 0)->FirstOrElse(0);
int32 smallest = numbers->MinOrElse(0);
double average = numbers->AverageOrElse(0.0);

// Force-unwrap when you know the sequence is non-empty
int32 first = numbers->First()!;

// Null-coalesce
int32 first = numbers->First() ?? 0;

Advanced Usage#

Payload Bindings and Index Access#

Pipeline operations that process elements prefer payload bindings (|x|) instead of inline lambdas. Payload bodies are inlined by the compiler and do not allocate closures. Optional index bindings expose the current stream ordinal and, for single-element payloads, the original source/provenance ordinal:

Wax
// Filter with index: keep only the first 5 positive values
arr->Filter(|x, i| => x > 0 && i < 5)->Sum();

// Map with discard: use only the index
arr->Map(|_, i| => i * 2)->Sum();

// Use the third binding when you need the original source/provenance index
arr->Filter(|x| => x > 0)->Map(|x, i, source_i| => source_i)->Sum();

// Enumerate makes index access discoverable
arr->Enumerate()->Map(|x, i| => x + i)->Sum();

// Fold uses With() to provide the initial value
arr->With(0)->Fold(|acc, x| => acc + x);

Payload arity depends on the operation shape:

Operation shape Accepted payloads Index binding meaning
Single-element transforms and sinks: Filter, Map, FilterMap, FlatMap, DistinctBy, TakeWhile, SkipWhile, GroupBy, Inspect, SumBy, AverageBy, AverageByOrElse, MinBy, MaxBy, MinByOrElse, MaxByOrElse, Any, All, None, ToDictionary, AppendToDictionary, Partition, ForEach ` x
FindIndex, FindLastIndex ` x
SortedBy, SortedDescendingBy, SortedStableBy, SortedStableDescendingBy ` x
Fold, Scan ` acc, x
Zip ` a, b

The accepted payloads above are exact: extra payload bindings are rejected instead of being ignored. Count-changing upstream transforms such as Filter, NotNull, FilterMap, Distinct, DistinctBy, Skip, SkipLast, TakeLast, SkipWhile, Repeat, FlatMap, PadTo, Chunk, Window, and GroupBy renumber downstream i values from zero. The optional source_i binding exposes the original/provenance source index when the pipeline still has a single source element; after TakeLast or SkipLast, it is the retained/emitted element’s original source index, after FlatMap, it is the outer source index for each emitted inner element, after Chunk or Window it is the last source index included in that output, and after PadTo or GroupBy it is the produced-stream ordinal because padded values and groups do not have one original source element.

You can also pass an existing function or delegate reference whose signature matches the operation:

Wax
fn IsPositive(int32 x) : bool { return x > 0; }
fn AddIndex(int32 x, int32 i) : int32 { return x + i; }
fn SumStep(int32 acc, int32 x) : int32 { return acc + x; }
fn Combine(int32 a, int32 b) : int32 { return a + b; }

arr->Filter(IsPositive)->Count();
arr->Map(AddIndex)->Sum();
arr->Any(IsPositive);
arr->With(0)->Fold(SumStep);
arr->With(0)->Scan(SumStep)->ToList();
a->With(b)->Zip(Combine)->Sum();

Function/delegate references must match one of the same accepted payload shapes. For example, Map can accept fn(int32), fn(int32, int32), or fn(int32, int32, int32), but SortedBy only accepts a one-argument key selector and Zip only accepts a two-argument combiner.

Inline lambda arguments are intentionally rejected:

Wax
arr->Map((x) => x + 1); // error: use payload syntax `|x| => ...`
The With Step#

With provides values to subsequent operations. It is context-sensitive:

  • Single value for Fold, Scan, and Zip, providing an initial accumulator or a second source.
  • Multiple values for Chain, where all provided sources are concatenated in order.
Wax
// Fold: With provides the initial accumulator value
arr->With(0)->Fold(|acc, x| => acc + x);

// Scan: same as Fold, but yields intermediates
arr->With(0)->Scan(|acc, x| => acc + x)->ToList();

// Zip: With provides the second source
a->With(b)->Zip(|x, y| => x + y)->Sum();

// Chain: With provides all additional sources; multiple args supported
a->With(b, c, d)->Chain()->Sum();  // iterates a, then b, then c, then d

Unconsumed With values (no subsequent Fold, Scan, Zip, or Chain) produce a compile error.

Range with Step#

Call Step(step) to build a stepped range source:

Wax
import Range from Wax;

new Range<int32>(0, 10).Step(2)->Sum();      // 0 + 2 + 4 + 6 + 8 = 20
new Range<int32>(1, 10).Step(3)->ToList();   // [1, 4, 7]
new Range<int32>(0, 100).Step(10)->Count();  // 10

// Step must be positive.
// new Range<int32>(0, 10).Step(0);   // panics: Range step must be positive
// new Range<int32>(10, 0).Step(-1);  // panics: Range step must be positive

Unstepped ranges work with Reverse(); stepped ranges currently reject it:

Wax
import Range from Wax;

new Range<int32>(0, 5)->Reverse()->ToList();  // [4, 3, 2, 1, 0]
Count Arguments#

Count-taking transforms use explicit empty/no-op semantics for non-positive counts:

  • Empty on non-positive: Take(n), TakeLast(n), and Repeat(n) yield no items when n <= 0.
  • No-op on non-positive: Skip(n), SkipLast(n), and PadTo(n, value) leave the stream unchanged when n <= 0.
  • Positive-only: Window(n) and Chunk(n) require n > 0; constant non-positive counts are compile errors, and dynamic non-positive counts panic before iteration. Range.Step(step) also requires a positive step and panics otherwise.
FilterMap: Combined Map + Filter#

FilterMap combines mapping and null-filtering in a single step. The payload body or function reference must return a nullable type T?. Non-null results are unwrapped to T and passed downstream; null results are skipped.

Wax
// Parse valid integers, skip invalid ones
var numbers = strings->FilterMap(|s| => try ParseInt(s) catch { yield null; })->ToList();

// Extract positive values doubled, skip negatives
var doubled = arr->FilterMap(|x| => x > 0 ? x * 2 : null)->Sum();

This is equivalent to ->Map(|x| => expr)->NotNull() but more concise and intention-revealing.

Conditional Pipeline Construction#

Because a completed pipeline is an expression, it can be used in any context that expects a value, including the arms of a ternary. The choice of which pipeline to run is therefore itself an expression.

Wax
var data = GetSomeData();
bool needsSorting = CheckIfSortingIsNeeded();

// Each arm is a complete pipeline, ending in its own sink.
var results = needsSorting ? data->Sorted() : data->ToList();

Note that each arm must be complete. An unterminated chain cannot be stored or passed, so the branch has to pick between two results, never between two partial pipelines.

Error Handling in Iterator Chains#

The payload bodies and function references passed to transformers like Map and Filter cannot throw errors that propagate out of the iterator chain. This design preserves the simple, linear flow of the fused loop. Any failable operation must be handled inside the payload body or delegated function using standard Wax error handling.

A common pattern is to use FilterMap or Map + NotNull() to convert failures into nullable types and filter them out:

Wax
// Using FilterMap, which is more concise
var validNumbers = stringInputs
    ->FilterMap(|s| => try ParseInt(s) catch { yield null; })
    ->ToList();

// Equivalent using Map + NotNull
var validNumbers = stringInputs
    ->Map(|s| => try ParseInt(s) catch { yield null; })
    ->NotNull()
    ->ToList();

Putting It All Together: A Complete Example#

This example demonstrates processing a simple CSV file of employee data, combining error handling and multiple transformation steps.

Wax
// 1. Define the data structure for a record.
struct EmployeeRecord {
    string name;
    int age;
    string department;

    constructor(string name, int age, string department) {
        this.name = name;
        this.age = age;
        this.department = department;
    }
}

// 2. Define a helper function to parse a single line.
// It returns a nullable record to handle parsing errors gracefully.
fn ParseRecord(string line) : EmployeeRecord? {
    var parts = line.Split(",");
    if (parts.size != 3) {
        return null; // Invalid format
    }

    // Use a try-catch expression for failable parsing.
    int? age = try ParseInt(parts[1].Trim()) catch { yield null; };
    if (age == null) {
        return null; // Invalid age
    }

    return new EmployeeRecord(
        .name = parts[0].Trim(),
        .age = age!,
        .department = parts[2].Trim()
    );
}

// 3. Process the file using an iterator pipeline.
fn GetEngineeringStaff(string filePath) : List<EmployeeRecord> {
    return FileLines(filePath)
        // Skip the CSV header row.
        ->Skip(1)

        // FilterMap: parse each line, skip failures in one step.
        ->FilterMap(ParseRecord)

        // Keep only employees in the "Engineering" department who are over 30.
        ->Filter(|emp| => emp.department == "Engineering" && emp.age > 30)

        // Collect the final results into a list sorted by age, descending.
        ->SortedDescendingBy(|emp| => emp.age);
}

Best Practices#

  • Avoid Side Effects: Transformers like Map and Filter should be pure functions. For side effects, use the Inspect transformer for debugging or the ForEach sink at the end of a chain.
  • Be Mindful of Allocation: Collection-building sinks (ToList, ToListWithCapacity, ToSet, ToDictionary), ordering sinks (Sorted, SortedDescending, SortedBy, SortedDescendingBy, and their SortedStable* siblings, which allocate one more scratch array), and buffering transforms (Distinct, DistinctBy, Window, Chunk, GroupBy) allocate. Append-style sinks (AppendToList, AppendToSet, AppendToDictionary) and Partition use caller-supplied collections.
  • Use OrElse for Known-Safe Defaults: Prefer FirstOrElse(0) over First()! when a sensible default exists. Reserve ! for cases where emptiness is a programmer error.
  • Use FilterMap for Failable Transforms: When a map operation can fail, prefer FilterMap over Map + NotNull() for clarity.

Performance and Implementation#

The primary goal of the iterator system is performance through compile-time fusion. An entire chain of iterator operations is analyzed by the compiler and lowered into a single, optimized loop.

What the Compiler Does

An expressive iterator chain like this:

Wax
var sum = numbers
    ->Filter(|x| => x > 0)
    ->Map(|x| => x * 2)
    ->Sum();

Is conceptually rewritten by the compiler into an efficient, fused loop. No intermediate collections are created.

Wax
// Conceptual lowered form:
var sum = 0;
for (int i = 0; i < numbers.size; i++) {
    var item = numbers[i];

    // The Filter operation becomes an 'if' check.
    if (!(item > 0)) {
        continue;
    }

    // The Map operation becomes an inline transformation.
    var transformedItem = item * 2;

    // The Sum operation becomes an accumulator.
    sum += transformedItem;
}

This fusion process eliminates the overhead of function calls and intermediate allocations, making iterator pipelines as fast as a handwritten loop. Supported sources are compiler-known indexed sources such as arrays, spans, collection span views, and Range<int32>, so the compiler generates tight indexed loops.

Performance Characteristics#

Most transformers are zero-cost, but some have specific performance characteristics:

  • Reverse(): Zero-cost on indexed sources (arrays, spans, unstepped ranges), because the compiler iterates backward. Stepped ranges currently reject Reverse().
  • Distinct() / DistinctBy(): Allocates a temporary Set<T> for deduplication.
  • TakeLast(n) / SkipLast(n): Buffer up to n elements so the tail of the stream can be retained or dropped. Non-positive counts do not allocate a tail buffer.
  • Window(n) / Chunk(n): Require positive counts. Constant non-positive counts are rejected; dynamic non-positive counts panic before iteration. Allocate temporary arrays for each window/chunk yielded.
  • GroupBy(): Allocates a temporary Dictionary<K, List<V>> for key lookup plus two parallel lists holding the groups in first-encounter order, then iterates those. The dictionary’s internal slot order is never observable.
  • Sorted() / SortedDescending() / SortedBy() / SortedDescendingBy(): Always allocate, because they collect all elements, sort in place, then return the sorted collection. The SortedStable* forms allocate the same collection plus one scratch array of the same length once the result exceeds 16 elements, which is the price of the stability guarantee.

Array Initializer Lists#

Array initializer lists are expressions written as [<optional_capacity>] { ... }. A surrounding array target supplies the element type; var cannot infer the result type from this syntax, and non-array targets are rejected.

Wax
int32[] values = [] { 1, 2, 3, };
int32[] reserved = [10] { 1, 2, 3, };

The brace body is an initializer list, not a statement block. It may contain only comma-terminated value entries and spread entries. Declarations, nested blocks, expression statements, if/switch, loops, and other control flow are not permitted.

Wax
int32[] prefix = [] { 1, 2, };
int32[] combined = [] { 0, ...prefix, 3, };

Spreads may supply arrays, spans, or values such as List<T> that expose a readable span. Spread sources are evaluated before the destination is allocated; entries are then inserted in initializer order.

The optional capacity may be any int32 expression. It is evaluated exactly once, and the allocation uses the greater of that value and the space required by entries and runtime spread sizes. Negative capacities therefore behave like zero. When nullable entries or spreads feed a non-nullable element type, null values are omitted and the result is trimmed to its populated length. This trimming also means a non-nullable initializer such as [10] { 1, 2, 3, } produces a three-element result.


The State System#

The Wax State System provides a declarative syntax for managing values that persist across multiple executions of a function. It is designed primarily for building dynamic user interfaces, managing game object behaviors, and implementing any component that acts as a state machine. The system is invoked repeatedly whenever its context is active (e.g., during a UI render pass or a game loop tick).

The system automates the storage and lifecycle of stateful data by tying it to the code’s lexical structure. This approach eliminates the need for manual management of backing objects, allowing developers to write clear, compositional, and resource-safe stateful code.

Core Principles:

  • Declarative & Explicit: State is declared where it’s used. Stateful loops require an explicit state modifier.

  • Per-Invocation Lifecycle: State persists for as long as its enclosing scope keeps being entered on each invocation. The first time a scope is entered, its state is allocated and state.create fires. When a later invocation discovers that an inline scope or foreach item is no longer active, its state.destroy cascade runs synchronously at that discovery point. Re-entry after destruction is a fresh creation. Root state owned by an unreachable stateful closure follows the deferred closure-cleanup rules below.

  • Compositional: Stateful functions and scopes can be nested. A “stateful call stack” carries statefulness down through the nesting.

  • Robust & Safe: The system enforces strict error handling rules to prevent corrupt state, and captured state follows ordinary GC reachability, so a surviving closure can never read freed memory.

Core Concepts with Examples#

This section covers the fundamental building blocks of the state system.

Stateful Functions (state fn)#

The entry point to the state system is a function marked with the state keyword. This declares that the function’s execution is stateful, meaning it can contain variables and logic that persist across multiple calls.

Wax
// A top-level stateful function.
state fn RenderUI() {
    // ... stateful logic here ...
}

// A stateful lambda stored on a class field: the "component owns its state" pattern.
class MyComponent {
    fn<() : void> update = state () => {
        // ... stateful logic here ...
    };
}

The state modifier is not permitted on class methods. State methods would require either per-caller records (breaking the “state tied to the instance” intuition) or instance-owned records (which don’t compose with polymorphic dispatch, since a derived override may have a different scope tree than its base). Instead, use a top-level state fn or, for the “component owns its state” pattern, a stateful lambda stored on a class field (as shown above).

Stateful Variables (state)#

The most basic unit of persistent state is a variable declared with the state keyword. The variable’s initializer runs once when its enclosing scope is first entered (or re-entered after an invocation in which the scope was skipped). On every invocation where the scope remains entered, the variable retains its value from the previous invocation. When an invocation skips the scope, the state is destroyed and the next entry runs the initializer fresh.

Wax
state fn CounterButton() {
    // `clickCount` is allocated and set to 0 the first invocation the
    // surrounding scope is entered, and retains its value on each
    // subsequent invocation the scope is entered. If the scope is skipped
    // for an invocation, the state is destroyed; the next entry starts over.
    state int clickCount = 0;

    if (Button("Click Me!")) {
        clickCount++;
    }

    Label("Clicked ${clickCount} times.");
}

For data that must survive scope-not-entered gaps, such as a panel’s scroll position across tab switches or a cached fetch that shouldn’t repeat, store it on a class field (or any object whose lifetime exceeds the state scope). state is for the lightweight inline cases; class fields are the right home for “alive but inactive” persistence.

Lifecycle Hooks#

The state system provides two hooks: one for setup, one for teardown.

  • state.create => { ... }: Executes when the scope first becomes active (the edge from “not entered” to “entered”). Ideal for one-time initialization that the inline state-var initializer can’t express in a single expression.

  • state.destroy => { ... }: Executes when the scope leaves the active set, which happens when control flow takes a sibling branch of an if/switch, the enclosing branch is not taken, or (for state foreach) the next outer invocation observes the item missing during reconciliation. Hooks run synchronously at the discovery point, before any subsequent statements observe the dropped scope’s state.

    Ordering guarantee:

    • Post-order. A scope’s children are always destroyed before the scope itself: a child’s destroy hook can safely read parent state.
    • Reverse-creation within a cascade. When a scope drops and takes nested state with it, the cascade destroys subtrees in reverse declaration order: the most-recently-created subtree dies first, and the dropped scope’s own state.destroy hook fires last. This is the C++ dtor model: a later sibling may hold references into earlier siblings’ state, and destroying later-first leaves earlier siblings intact during the unwind. Multiple state.destroy blocks within a single scope likewise fire in reverse source order.
    • Foreach stale items. When multiple items drop in the same invocation, their destroy hooks fire in reverse-insertion order, so the most-recently-added item dies first.
    • Independent partial teardowns. Two if/switch statements at the same lexical level that both go untaken in the same invocation are destroyed at the lexical points the not-taken branches are reached, which is source order. If you need a specific order across such siblings, nest them under a shared parent scope so they participate in a single cascade.
Wax
state fn Tab() {
    state EventSubscription sub = Events.Subscribe("OnDataUpdate", ...);
    state.destroy => {
        sub.Unsubscribe();
    }
    // ...
}
Stateful Control Flow (if, switch, & state foreach)#

State is lexically scoped to control-flow blocks. A scope’s state is alive on invocations where the scope is entered. A later invocation that discovers the scope is no longer active destroys it synchronously at the corresponding control-flow or foreach-reconciliation edge.

  • if / switch branches: Each branch (and each else if / case) is an independent state scope. On an invocation, exactly one branch of an if-else chain or switch runs; previously-active sibling state is destroyed when the new branch is selected. Switching back starts that branch’s state fresh.

  • state foreach: Per-item state reconciled by position by default, or by a stable identifier when an explicit key clause is present. When an item drops out of the collection, its state is destroyed.

This means a tab UI whose state lives inside a branch will lose its state when the tab is hidden. If you want persistence across visibility toggles, hoist the data onto a class field (or any object outlasting the state scope); state itself is strictly per-invocation-entered.

Detailed Rules and Semantics#

The Stateful World#

The state system operates within a well-defined “stateful world.” The core invocation rule is that a state fn can only be called from another state fn. This creates a “stateful call stack” that ensures state is always managed within a valid, active context.

To enter this world from normal, non-stateful code (like the host application), you must bootstrap a root state scope.

Bootstrapping a State Context#

The primary way to create a root state scope is with a stateful lambda. When a state lambda is created, it becomes a stateful closure: an object that bundles the function’s logic with the persistent state data it manages.

This stateful closure object can be stored and invoked from non-stateful code, serving as the bridge into the stateful world.

Wax
// 1. A stateful lambda is assigned to a variable. This creates a stateful
//    closure object that owns the state for the lambda's logic.
fn<() : void> myStatefulUI = state () => { RenderScene(); };

// 2. The closure is invoked like a regular function to execute the logic.
//    The state persists between calls.
myStatefulUI();

The lifetime of the root state is tied to the lifetime of the stateful closure object itself. The collector only discovers closure death and quarantines the closure; it never executes Wax cleanup code. The complete root-state destroy cascade runs at the beginning of a later frame, after that frame has entered the runtime and before normal event or frame logic. Acyclic and nursery-only deaths discovered at FrameEnd therefore normally destroy at the next FrameBegin. A closure retained by any strong reference remains alive and is never torn down.

Dead cycles are torn down only after the amortized cycle collector proves them unreachable, so cleanup may occur later than the immediately following frame. Application shutdown does not wait for reachability discovery: it destroys every remaining active root before runtime services are disconnected. When several independent roots are ready together, newer roots are destroyed first according to their stable creation identity. Each root cascade still uses the child-first, reverse-creation ordering described above.

A closure whose cleanup has begun cannot be re-entered, and a closure whose cleanup has completed cannot be invoked again; either attempt panics. Calling any stateful closure from a state.destroy hook also panics if static checking could not reject the call. Publishing a reference to the closure during cleanup does not resurrect its state: the closure remains destroyed and inert. If cleanup itself panics, the current drain stops, the root stays partially cleaned up, and that partially executed cleanup is not retried.

Initialization and Error Handling#

Initializers for state variables and code within lifecycle hooks cannot propagate errors. Any failable operation must be handled internally, typically with a try-catch expression that provides a valid fallback. This is a critical safety feature that prevents the state of a component from becoming corrupt.

state.destroy must monotonically tear down the existing state tree. It cannot declare state, iterate with state foreach, create a stateful lambda, call a state fn, or declare another lifecycle hook. Ordinary non-state functions, host cleanup calls, mutation, and allocation remain legal. The compiler rejects statically visible violations; the invocation panic above closes indirect paths whose statefulness was erased behind a delegate.

Wax
// The initializer for 'data' must handle the potential FileError.
state string data = try ReadFile("config.txt") catch |err| {
    Log(err);
    yield "default_data"; // Provide a valid fallback value.
};
state foreach: Per-Item State#

To attach independent state to each item in a collection, mark the loop state. Without a key clause, item state is reconciled by index. Supply a key expression when state must follow item identity across reordering:

Wax
// Keyed iteration: the `key <expr>` clause goes after the payload capture
// and is evaluated per iteration with the payload variable(s) in scope.
state foreach (userList) |user| key user.id {
    // Each user gets their own independent `isExpanded` state, retained
    // across invocations while `user.id` keeps appearing in `userList`.
    state bool isExpanded = false;
    // ...
}

Keying Rules:

  • No explicit key: Index is the identity. Reordering the collection therefore keeps state in its old position rather than moving it with the item.
  • Type: An explicit key can be any type that implements Hashable (defines both Equals and GetHashCode), except a type containing a scoped struct. Keys are retained across invocations, so stack-lifetime values cannot be keys. Small collections reconcile linearly; larger collections use a retained hash index. This is an implementation choice and does not change equality semantics.
  • Form: The key is an inline expression (not a delegate or lambda) evaluated in the payload’s scope. Any expression is permitted, including field access (user.id), a method call (user.GetKey()), an external function call (KeyFor(user)), a composite tuple, or a conditional, as long as the resulting type satisfies the rules below.
  • Evaluation order: Reconciliation snapshots every item key and hash before the first loop body executes. A body mutation cannot change the keys selected for later items in the same invocation.
  • Failed reconciliation: Reaching the loop starts a new reconciliation. If collection or key evaluation exits the loop before the snapshot completes, no item was rendered in that invocation and all prior per-item state is retired.
  • Uniqueness: Keys must be unique within the collection for any given invocation. A duplicate key will cause a runtime panic.
  • Stability: Keys must produce the same Equals/GetHashCode result across invocations for a given item. The compiler cannot prove this; it is the user’s responsibility.

When an item’s key no longer appears in the collection on an invocation, that item’s state is destroyed synchronously while reconciling the keyed collection.

Per-item records that contain destruction semantics are cleaned up when they die. Normal reconciliation and parent teardown still destroy them synchronously and mark them destroyed; collector-driven cleanup is the catch-all for a record whose owning state graph becomes unreachable without a later reconciliation, and never repeats an already completed cascade.

Hierarchical Destruction#

When a parent scope is not entered on an invocation, every state scope nested inside it is also gone, because there’s no way for a child to be alive when its parent isn’t. Destruction propagates from a not-entered parent down through every descendant.

Capturing State in Closures#

A closure may capture any state variable in scope. There are no restrictions on what it captures or on how long it is kept.

state.destroy hooks run at retirement, exactly as described above: the cleanup you write happens synchronously, at the discovery point, in the documented order. What retirement does not do is invalidate the storage. Captured state follows GC reachability — a closure that captured a scope’s state holds that storage alive, and after the scope retires it keeps reading and writing it as a detached record. Those reads and writes are fully defined; the record is reclaimed by the ordinary collector once the last holder dies. Once a scope has retired, nothing about it panics and nothing becomes unreadable.

The one window that is not readable is the retirement itself: a read taken while a scope’s destroy cascade is running observes state the cascade is dismantling, and panics. This is the same rule as the state.destroy restrictions above — it applies only to reentrant paths a state.destroy hook reaches, which is the only way to be inside the window.

Wax
state fn Panel() : fn<() : int32> {
    if (visible) {
        state int32 clicks = 0;
        // The scope's state outlives the scope through this closure.
        return () => { clicks++; return clicks; };
    }
    return () => { return 0; };
}

If visible goes false on a later invocation, the if scope retires and its state.destroy hooks run — but the returned closure keeps counting on its own detached copy of clicks.

Two closures created during the same entry of a scope share one record, so each sees the other’s writes, before and after the scope retires. Closures created during different entries hold different records.

That last point is the one sharp edge worth naming: because re-entry starts a fresh lifetime, a closure that survived an earlier lifetime and the freshly re-entered scope each own a separate copy of the same state. Writes through one are invisible to the other. This is the same behavior closures have in JavaScript, C#, and Compose, and it is the price of letting a closure outlive its scope without either skipping cleanup or panicking on a read.

Wax
state fn Counter() : fn<() : int32> {
    if (active) {
        state int32 n = 0;
        n++;
        return () => { return n; };
    }
    return () => { return -1; };
}
// Invocation 1 (active):   captured closure A sees n == 1
// Invocation 2 (inactive): the scope retires; A still reads its own n == 1
// Invocation 3 (active):   a fresh lifetime starts at n == 0, then n == 1.
//                          A's record is untouched; the two never converge.

Whether a captured slot needs its own record is decided by the compiler, per scope, from the code alone. There is no keyword, no annotation, and no way to observe the decision: an uncaptured slot and a captured one behave identically while their scope is live.

Non-Stateful Loops in Stateful Contexts#

A standard foreach, while, or for loop inside a state fn does not create a state scope. Its body can access state from parent scopes, but it cannot declare its own state variables or call other state functions.

Advanced Patterns#

Composition with Higher-Order Functions#

The state system composes with higher-order functions. A standard, non-stateful function can accept a stateful lambda as a parameter. The state for the lambda is keyed to the lexical call site of the higher-order function.

Wax
// A generic, non-stateful UI component function.
fn Button(string label, fn<() : void> onClick) {
    if (IsClicked(label)) {
        onClick();
    }
}

// A regular function can compose UI elements that use stateful lambdas.
fn MyUI() {
    // The state for this button's clickCount is tied to this specific line of code.
    Button("Click Me!", state () => {
        state int clickCount = 0;
        clickCount++;
        Log("Button clicked ${clickCount} times.");
    });
}

Wax Host Communication Interface#

Communication with the host application is handled through explicitly marked functions. The interface is designed to be a thin, simple, and safe contract where Wax defines the API and the host application provides the implementations.

This design is guided by the following principles:

  • Safety First: No raw pointers are passed into the Wax runtime. Data entering Wax is copied and managed by the Wax GC and memory model, preventing memory corruption.
  • Explicit Contracts: All host-provided functions and Wax-provided functions are explicitly declared, eliminating “magic” and making dependencies clear.
  • Controlled Data Flow: The interface uses a restricted set of boundary types for bidirectional communication. Structured data crosses the boundary as json. Heavy resources use opaque handles.
  • No Callbacks: Control flow remains simple and unidirectional. The host invokes Wax code; Wax does not invoke the host asynchronously.

Calling the Host from Wax: host fn#

To call a function implemented by the host, you declare it as a host fn. This declaration serves as a contract that the host must fulfill. Host functions can be declared at top level or as members of a static class for namespace grouping.

Declaration Rules#

A host fn must adhere to the following rules to ensure a clean and predictable boundary:

  • It must be a top-level function or a member function of a static class.
  • It cannot be state.
  • Parameters and return types must be boundary types: primitives (int32, int64, float, double, bool), enums, string, json, opaque, the fixed vector/matrix/color/quaternion types (float2/float3/float4, float3x2/float3x3/float4x4, color8/color16/color32, quaternion), arrays of these, and structs composed of them. A required host fn may additionally take or return secret; see the Secret Type chapter for what that crossing does and does not record.
  • It can optionally be marked throws to indicate the host may signal failure.
  • A host fn inside a static class is implicitly static. Writing static host fn is allowed but redundant.
Wax
// Declares a function named `Log` that the host must provide.
host fn Log(string message);

// A failable host function: the host can signal errors via WxError.
host fn GetPrice(int32 itemId) : float throws;

// Static classes group related host functions without changing the boundary model.
static class Console {
    host fn Write(string message);
}

Top-level host functions use their declared name as their host slot. Static-class host functions use a qualified native ABI slot name of Class_Method, such as Console_Write, so different static classes can declare the same method name without colliding. Host-language bindings expose grouped configuration facades where the host language supports them, such as Console.Write, while the C ABI remains the flat slot name.

Optional Host Functions: host? fn#

For top-level host functions that may not always be available (e.g., for debugging), you can declare them as optional using the ? modifier. The generated host bindings expose these slots as optional and do not require them during initialization. In Wax, the function name is a nullable delegate: payload capture, null-conditional invocation, and null coalescing provide the ordinary nullable operations. A bare direct call is rejected because it does not prove availability. host? fn is not currently valid inside a static class.

Wax
// The host may or may not provide this debugging function.
host? fn LogAnalytics(string eventName, json data);

if (LogAnalytics) |logFn| {
    logFn("player_action", { timestamp: GetCurrentTime() });
}

// Equivalent one-shot conditional invocation.
LogAnalytics?.("heartbeat", {});

Failable Host Functions: throws#

A host fn marked throws generates a WxError* parameter in the C signature. The host writes to this parameter to signal failure. On the Wax side, callers must use try/catch:

Wax
host fn GetPrice(int32 itemId) : float throws;

float price = try GetPrice(42) catch |err| {
    if (err.errorType == "not_found") {
        // handle missing item
    }
    yield 0.0f;
};

Non-throws host functions are trusted, with no error path on either side.

Calling Wax from the Host: api fn#

To expose Wax functionality to the host, you mark a top-level function or a member function of a static class with api. Static-class api fns are namespace grouping only: they are implicitly static, have no instance receiver, and generate the same kind of host-callable boundary entry as top-level api fns. Classes and structs themselves cannot be marked api.

Wax
// This function can be looked up and invoked by the host.
api fn GetVersion() : string {
    return "1.0.0";
}

static class Console {
    api fn WriteLine(string message) {
        // ...
    }
}

API functions use app/module-qualified native ABI names carrying a kind token: top-level API functions use <App>_api_<Module>_<Namespace>_<Function> and static-class API functions use <App>_apiclass_<Module>_<Namespace>_<Class>_<Method>, such as WaxApp_apiclass_WaxApp_Console_WriteLine with the default app and module names. An underscore inside an ordinary name is doubled, so api fn Do_Thing exports WaxApp_api_WaxApp_Do__Thing. A name with an underscore touching a segment boundary uses the reserved wxq_ hex form instead, keeping the encoding injective even for paths such as A_::B and A::_B. No two declarations can export the same symbol, and no API function can collide with a lifecycle export such as WaxApp_LoadApp. Host bindings keep those names private and expose language-native API facades with the module as the first child: app.Api.WaxApp.GetVersion in C# and Java/Kotlin, app.api.WaxApp.GetVersion in C++ and Swift, app.api.wax_app.get_version in Python, app.api().wax_app().get_version() in Rust, and app.api.waxApp.getVersion() in JavaScript/TypeScript. Static classes continue beneath the module, such as app.api.waxApp.console.writeLine() in JavaScript. The qualified native symbol remains binding plumbing, not the public host-language call shape.

Every api fn exports a native ABI function with WxError* as the last parameter. If the Wax function panics or throws an unhandled error, the boundary trampoline catches it and writes the error to the WxError*. The host can pass NULL if it doesn’t care about errors. The generated C header wraps those native exports in shorter host-facing functions, while storing the raw function pointers under qualified private fields.

C
// Native dylib export for: api fn DoThing(int32 mode, json config)
void WaxApp_api_WaxApp_DoThing(int32_t mode, struct WxJson config, struct WxError* err);

// Generated C header wrapper for the same function.
static inline void WaxApp_DoThing(struct WaxApp_App* app, int32_t mode, struct WxJson config, struct WxError* err);

// Static-class API native exports include `class_<Class>`.
void WaxApp_apiclass_WaxApp_Console_WriteLine(struct WxString message, struct WxError* err);

Combining api with Visibility#

The api keyword is orthogonal to Wax’s visibility modifiers (public, internal, private, etc.). You can combine them to control both host access and inter-module visibility within your Wax codebase.

Wax
// This API is part of the public, stable interface for this module.
public api fn StartGame();

// This API is intended for internal testing tools or other modules
// within the same project, but not for external consumers.
internal api fn RunDiagnostics();

Data Types at the Boundary#

To maintain safety and performance, only a restricted set of types can be used in the signatures of host and api functions. These are the boundary types:

Boundary Types#

  • Primitives (int32, int64, float, double, bool): Passed by value. No allocation needed.
  • string: Data is always copied across the boundary. Inbound buffers must contain valid UTF-8; generated managed bindings guarantee this, while callers using a raw WxString are responsible for the contract. Outbound strings are allocated with the host’s allocator.
  • json: Structured data crosses the boundary as JSON. Inbound JSON strings are parsed into Wax’s 16-byte node format on the Wax heap. Outbound JSON is serialized to a string allocated with the host’s allocator.
  • opaque: An unvalidated uint64 handle to a host-managed resource. Passed through as-is with no allocation. The host is responsible for interpreting the value.
  • Enums: Cross the boundary using the enum’s declared backing integer type. Normal enums default to int32; flags default to uint32. Wax validates incoming values against the enum’s declared cases; flags values must contain no undeclared bits.
  • Fixed vector, matrix, color, and quaternion types: float2/float3/float4, float3x2/float3x3/float4x4, color8/color16/color32, and quaternion cross by value in their declared layout.
  • Structs composed of boundary types: copied by value, field by field.
  • Arrays of the above: Contiguous buffer + length for primitive arrays. Arrays of strings or JSON use the appropriate typed array representation.

The opaque Type#

The opaque type represents a handle to a resource that is entirely managed by the host. Wax can store and pass around opaque handles, but it cannot inspect or manipulate them. This is the standard way to work with host objects like window handles, file handles, or graphics resources.

Wax
// Host provides these functions
host fn CreateWindow(int32 width, int32 height) : opaque;
host fn DestroyWindow(opaque handle);

// Wax uses opaque handles to manage host resources
api fn CreateApp() : opaque {
    return CreateWindow(800, 600);
}

Structured configuration for resource creation uses JSON:

Wax
api fn LoadTexture(json config) : opaque {
    // config: { "path": "hero.png", "filter": "nearest" }
    return CreateTexture(config);
}

Data Marshaling Rules#

The following table summarizes which types can cross the Wax/host boundary.

Type Behavior
Primitives Passed by value.
string Data is always copied.
json Inbound: parsed into Wax heap. Outbound: serialized to string.
opaque uint64 passthrough, no allocation.
Enums Cross using the declared backing integer type (int8/uint8 through int64/uint64; normal enums default to int32, flags to uint32). Incoming values must match a declared case; flags values must contain no undeclared bits.
Fixed vector/matrix/color/quaternion Cross by value in their declared layout.
Structs of boundary types Copied by value, field by field.
Arrays Contents are copied. Element type must be a boundary type.
secret A required host fn parameter hands the host real bytes and tapes nothing; a return tapes a zeroed payload of the recorded length. A direct required-channel value register also admits secret and records zero-filled deltas. Refused on api fn, host? fn, and nested/wrapped channel positions.
Other Types Disallowed to ensure a simple and safe boundary. Rejected with WAX0461, which names the admitted set.

Frame-Stream Channels: The host channel Declaration#

host fn is a pull: Wax calls out and the return is recorded. A channel is the push direction: data the host produces on its own clock (input, window size, network messages) that becomes visible to Wax at a frame boundary. A channel is a top-level declaration of its own, carrying only fields. Top-level only: a channel’s slots are global, so one written inside any other type is an error rather than a member.

Wax
import ReadOnlySpan from Wax;

host channel Net {
    int32 threshold;                         // value channel  -> boundary name Net_threshold
    int32? limit;                            // nullable value channel
    ReadOnlySpan<int32> seeds;               // array-payload value channel
    event int32 inbox;                       // event channel
}

channel is a reserved word, and the host in front of it is required rather than defaulted: it names the side that fills the block, the way host fn and api fn name the side that implements them. Every field is implicitly static, implicitly read-only, and implicitly one boundary slot, and the declaration is read by naming it: Net.threshold, Net.inbox.

A channel is a singleton, so it is never a value. It has no type parameters, no base type, no interfaces, no user-declared methods, no constructor, and cannot be instantiated, stored in a variable or field, passed as an argument, returned, or used as a generic argument, array element, cast target, or is/as operand. Each of those is a compile error naming the channel. Its name is upperCamel and its field names are lowerCamel, because those spellings are the two halves of the boundary name a host calls; neither may contain _, which is the separator those halves are joined with.

A channel declaration and each of its fields take a visibility modifier and nothing else. static, readonly and native restate what a channel field already is; const contradicts it, because a folded constant is not a slot. A channel must declare at least one field: one with none binds nothing and can never be read.

The Two Kinds#

A value channel (T x;) is a register: the host asserts a change and the value persists until it asserts another. An event channel (event T x;) is a batch: it carries everything the host pushed since the previous frame, and its ground state is the empty batch, never the previous frame’s contents.

Everything staged during frame N becomes visible atomically at the beginning of frame N+1. Nothing staged inside a frame is observable within it, so a channel read is stable for the whole frame. Cross-channel ordering within a frame is deliberately unobservable.

Restrictions#

A channel is read-only in Wax. Assignment, compound assignment, ++/--, binding ref or out, and a declaration-site initializer are all compile errors. A channel also cannot be read from a static initializer: materialization needs the heap and runs during app creation, and static initialization must not race it.

A field’s own optionality is the payload’s, so write T? x;. Whole-channel optionality is a different thing, spelled on the host channel keyword; see below.

Every channel has one compiler-generated method, GetChanges(). It returns a value struct with one bool field per register. A value field is true when its canonical bytes differ from the previous frame; an event field is true when a non-empty batch arrived. For an optional channel each field is compared independently, including presence transitions. The returned struct lives in the channel declaration’s snapshot state and is copied by value, so reading several flags performs one channel-object lookup and restores reproduce the flags seen on the captured frame.

Optional Channels#

Not every program has a window, so a channel whose facts may not exist declares that with a ? on the keyword, and is read through a nullable payload capture:

Wax
host channel? Screen {
    float2 size;
    float  scale;
}

if (Screen) |s| { Layout(s.size, s.scale); }
guard (Screen) |s| else { return; }

Presence is whole-channel and atomic: the compiler gathers the registers into one block on a single slot, so the block is present or absent as a unit, per frame, with absent as the ground state. A bare Screen.size is an error naming the capture idiom. The standard library’s FrameInfo is declared this way.

Three restrictions follow from the single slot, each refusing by name inside an optional channel only. Its registers must be ref-free, so a string or ReadOnlySpan<T> register, legal on a required channel, is refused. An event field is refused, because an empty batch is already a truthful ground state and nesting one would cost the atomicity. A nullable field is refused, because it would be a second presence fact for one value.

Because absent is truthful, an optional channel needs no bind-time initial value, and a launcher build that reads only optional channels compiles and runs.

View Types#

An event field reads as ReadOnlySpan<T>. An array-valued register is declared as ReadOnlySpan<T>. In both cases the slot stores a T[], and each read mints a fresh scoped view over the frame’s storage. App code can iterate, index, copy it through the ordinary pipeline surface (->ToList()), and pass the view as a ReadOnlySpan<T> argument, but the ordinary scoped-borrow rules forbid storing, returning, capturing, or aliasing it. A bare T[] x; field is rejected, because it would hand out a mutable reference to storage the runtime owns.

A value-type element is copied when indexed. Mutating one of its fields therefore requires a local copy; it cannot mutate the span slot or bind that slot by ref/out. An indexed reference-type element still denotes the referenced object, so its fields may be mutated just as with a non-ref-returning List<ReferenceType> indexer. Channel admission nevertheless excludes mutable reference types. Span-like scoped types and structs containing another scoped value remain invalid event elements.

Payload Types#

Registers admit primitives, enums, the fixed vector/matrix/color/time families, user structs recursively composed of admitted members, immutable string, frozen json, ReadOnlySpan<T>, and nullability over any admitted register payload. Event elements use the same value vocabulary. A string or json nested at any depth is reconstructed into app-owned heap storage; json nodes are frozen before publication. An in-place json edit whose receiver is syntactically rooted at an inbound register – the register itself, or a json member of a register value – is a compile-time error naming the register; Clone() yields a mutable copy. Mutation reached through a local that merely holds the register’s value is still refused at run time by the frozen-json guard. Arrays may not occur inside an aggregate, because they would expose mutable runtime-owned storage. Use a sibling ReadOnlySpan<T> register instead. A span element must be ref-free because its backing is fixed-stride; reference-bearing span elements require a variable-length encoding not provided by this boundary. opaque, mutable object references, and secret are refused.

Binding and Initial Values#

Every app-declared channel is subscribed: it receives stable slots and appears in the manifest and generated host surface whether or not the current program reads it. Materialization remains read-driven. An unread declaration therefore has canonical runtime storage and a staging surface but no channel object and no per-frame heap work. Reading any field materializes the declaration object and the fields that code can reach. Slots are numbered by declaring channel and then field declaration order, and pinned across incremental rebuilds, so field order is slot order within a declaration on a fresh build, and a rebuild that drops a field leaves the survivors where they were.

Every bound value channel that is neither nullable nor optional must be given a bind-time initial value, because its ground state would otherwise be bytes nobody wrote. The generated C host takes one parameter per such register in <App>_SetChannelInitialValues, so adding or removing one breaks the host build; a runtime backstop fails LoadApp naming any register still missing one. The bind additionally compares the host’s channel table against the app’s own (count, name, schema digest) and fails closed on any disagreement.

--oneshot and --standalone launchers have no host and therefore cannot supply an initial value; waxc refuses to build one whose declared subscription contains a register that needs one, and names it. Event channels, nullable registers, and optional channels build there, because their ground states are truthful.

Recording and Replay#

Channel data is written to the recording at the frame boundary that delivers it, so a run that reads a channel replays without host involvement: a replayed frame takes its channel values from the tape and ignores anything a live host stages. The frame after every keyframe begins with a counted copy of the keyframe’s complete register set, followed by that frame’s ordinary deltas. A seek seeds the canonical registers from that pre-frame set without reporting changes, then replays the deltas normally.

App-to-Host Frame Registers: The api channel Declaration#

An api channel is the outbound frame-stream direction. Wax owns and writes its registers; the host reads them after a frame has ended.

Wax
api channel Telemetry {
    int32 spawned;
    float fps;
    string status;
}

fn Tick() : void {
    Telemetry.spawned++;
    Telemetry.fps = 60.0f;
    Telemetry.status = "ready";
}

An API channel follows the singleton and top-level declaration rules above, but its fields are writable static storage. Wax may also read them back, so an accumulation such as Telemetry.spawned++ is deterministic. Writing a host channel remains invalid. Reading either channel direction from a static initializer is invalid because its runtime object is not available there.

API channels are required declarations: api channel? is invalid. Their value registers admit primitives, enums, fixed vector/matrix/color/time families, immutable string, and structs recursively composed of those values. Nullable, secret, opaque, span-shaped, and bare-array fields are refused, as is json nested in a struct. For a large payload that the host pulls into its own buffer, use a readonly api fn with a WriteOnlySpan<T> parameter.

A register may also be a List<T>, and a field may be an event T batch:

Wax
struct Sample { public int32 id; public string label; }

api channel Telemetry {
    List<int32> samples;
    event Sample readings;
}

Both resolve to the app’s own List<T> and admit every ordinary use of one: Add, Clear, assignment of another list, passing it to a function, holding a reference. T takes the rules an inbound event element does: primitives, enums, the fixed families, and value structs whose only references are string leaves; a nested collection, an array element, and json are refused. A List<T> register persists across frames. An event batch is per-frame: FrameBegin assigns the register a fresh empty list, so a reference taken in an earlier frame keeps that frame’s contents and no list the program can reach is emptied by the runtime. A collection register begins as a real empty list. The host copies the elements and their count out after the frame.

A register may be a json document:

Wax
api channel Telemetry {
    json summary;
}

The register is an ordinary json reference the program builds, replaces, or edits in place through any alias. When the frame ends the document is stringified once, and the host reads that text as its json value; a register never assigned reads as null. The close reuses the previous text while the register holds the same document and no json node was edited in place since. The writer refuses nesting beyond 512 levels with a panic, which is how a cyclic document fails. json[] has no register spelling.

Neither a collection nor a json register carries a change bit: GetChanges() omits the member, since an append or an in-place edit has no store site. A declaration whose registers are all bitless has no GetChanges() at all.

Neither spelling carries a change bit. GetChanges() has no member for a collection register, and reading one is an unresolved-member error rather than a bit that is always false.

Every register has a valid value as soon as the app is loaded: numeric and value storage begins at its logical all-zero value, while string leaves begin as real empty strings. An enum used directly or inside a register struct must therefore admit raw value zero. A register struct may not declare field initializers, because API-channel singleton storage does not run constructors or those initializers; assign the register during a frame when a different initial value is required.

Each value register holds its last value until Wax stores another value. FrameBegin clears only the register’s write bit. A generated GetChanges() field is true after any store since the most recent FrameBegin, including a store of bytes equal to the previous value, and false after a later FrameBegin when no subsequent store occurred. API entry points run only inside an active frame, so every reported store belongs to that frame. Mutating receiver methods rooted at a value register are refused because a conditional or panicking callee has no exact call-site write fact; assign a field or the complete register instead. References into a value register cannot be returned or obtained through a ref-returning member because they would let a later write bypass the register’s change bit. A readonly api fn cannot perform such a store because it would mutate pre-existing static state. None of this applies to a collection register, which claims no bit.

The host may read an API channel only while the application is quiescent: no frame is open, no Wax code is executing, and the call is on the app’s owner thread. A host function invoked by Wax therefore cannot read an API channel mid-frame; synchronous delivery belongs in that host function’s arguments. After the host completes FrameEnd cleanup for a panicking frame, the next read window exposes values stored before the panic.

Every getter returns an owned host copy. No pointer into the Wax heap escapes, and a returned string, nested value aggregate, or collection remains valid independently of the next frame. Copy-out does not allocate on the Wax heap.

Outbound registers have a pinned slot namespace separate from inbound host channels. Generated bindings validate the outbound slot count, names, holes, and schema digests before exposing their output facade. The C ABI provides per-register getters and changed predicates plus grouped snapshots where the payload permits them. Higher-level bindings group these beneath outputs. JavaScript additionally validates the wx.api-channels custom section of its Wasm artifact before exposing the getters.

An API-channel store adds no recording row. Its declaration object is ordinary heap state, so replay reconstructs its registers and write bits by re-executing the recorded application calls. Snapshots, inspection, mutation watches, and queries therefore observe the reconstructed values naturally. waxdbg recording output-schema reports the exact outbound groups, members, slots, and structural types for a bound project.

Standard-Library Channels#

InputData.events, the raw key/mouse/window batch (with the derived Input.Down / Pressed / Released / MousePosition / MouseDelta / ScrollDelta / Modifiers accumulator); TextData.events, text entry as editing intents whose offset and length index the sibling per-frame TextData.text read-only span; the optional FrameInfo; and the optional TextInputState, the host-owned text-input mode the app requests through the stdlib TextInput.Begin() / TextInput.End() host fns and the host answers by staging the block. Their payload layouts are compiler-known on both sides rather than negotiated, and are version-locked by WX_FRAMESTREAM_EVENT_ABI_VERSION. See PublicDocs/FrameStream.md for the Key code space, the TextIntent vocabulary, and what that version does and does not lock.

The generated JavaScript/TypeScript boundary publishes one atomic push_TextData(intent, text, secret?) operation. It validates intent with the ordinary generated enum boundary checks, appends the UTF-32 run and event under one runtime lock, and assigns the event range. The two physical slots remain visible in schema and recording metadata but are not independently callable generated methods.

Advanced Topics#

Host API Workflow#

The Wax compiler simplifies host integration by generating a header file (build.wax.h) that is the entire host-facing API. This provides a type-safe and self-documenting interface, eliminating string-based lookups and manual function registration.

Host Calls and Snapshotting#

To guarantee perfect, deterministic replay for time-travel debugging, the Wax runtime must record all data passed from the host to an api function. This has important implications:

  • Snapshotting is not optional: This recording mechanism is a fundamental part of the runtime.
  • Inbound data is recorded: All data passed from the host to Wax contributes to the execution log size. For high-frequency calls, it is best to keep this data small.
  • Outbound data is not recorded: Data returned from Wax to the host has no impact on snapshot size, so returning large buffers is acceptable.

Non-Recorded api Functions and readonly#

Recording exists only to reproduce the deterministic state trajectory on replay. An api fn that has no observable effect contributes nothing to that trajectory, so the compiler emits it without the recording machinery. Its inbound arguments are not logged and it adds no entry to the call timeline.

The compiler classifies an api fn as non-recorded when, transitively, its body:

  • performs no heap or static write (writes to plain by-value locals are fine);
  • contains no allocation (new, array/JSON literals, string interpolation, etc.);
  • calls no host / host? function;
  • invokes no virtual, delegate, or otherwise unanalyzable target; and
  • calls only other functions that are themselves non-recorded, or a small set of vetted side-effect-free, deterministic natives (numeric math, length/size, indexing, read-only string queries).

The default is conservative: if purity cannot be proven, the function is recorded as normal. This classification is build-invariant: it runs identically in debug and release, so a recording made by one replays on the other.

Wax
// Automatically non-recorded: a pure read, never enters the timeline.
api fn GetPlayerHP(int32 playerId) : int32 {
    return gPlayers[playerId].health;
}

Recording-stream caveat. A read that looks side-effect-free is still excluded if it touches the recording stream itself, most notably Time.Now, which appends a clock sample to the recording on every call. The value it returns is deterministic on replay (it is read back from that stream), but the entry must be consumed in order; a non-recorded function never runs during replay, so eliding it would leave the entry unconsumed and desync the replay cursor. Such a function is always recorded.

The readonly Transient Boundary#

Marking an api fn readonly asserts that it must be non-recorded and cannot change state that existed before the call. The function may read pre-call state, allocate and mutate an invocation-owned object graph, and write explicit WriteOnlySpan outputs. It may not mutate a static, parameter, borrowed field, or other pre-call object; call an unproven effect boundary; or expose an invocation-owned reference after return. The compiler checks the complete direct call range and reports a compile error at the first unproven effect rather than silently falling back to recording.

The generated boundary snapshots the complete resettable nursery state as one allocation checkpoint around the whole host call, including managed input conversion and managed return copy-out. Ordinary helper calls stay inside that region; calling another readonly api fn is rejected because readonly entry regions cannot nest. On normal return or panic, the temporary heap cursor, nursery-entry cursor, and allocation-identity cursor are restored together. Allocation that carries destruction semantics is forbidden, and the proof requires nursery roots and cleanup registrations to remain unchanged rather than rolling them back. This makes the boundary suitable for high-frequency derived work such as rendering: persistent world and asset objects are inputs, mutable render scratch originates inside the call, and final pixels are copied to a host-provided WriteOnlySpan. A framebuffer that must be sampled while drawing is therefore an ordinary fresh read/write array, while only the final host destination is write-only. Allocator capacity is an explicitly permitted side effect: committed nursery pages and side-table capacity may retain their high-water marks even though no invocation-owned object or identity survives.

Wax
// Reads persistent state and remains allocation-free.
readonly api fn GetScore() : int32 { return gScore; }

// Fresh scratch may be mutated and discarded; output is the only write that escapes.
readonly api fn Shade(WriteOnlySpan<uint8> output) : bool {
    uint8[] scratch = new uint8[output.size];
    foreach (scratch) |_, i| { scratch[i] = (i & 255) as uint8; }
    foreach (scratch) |value, i| { output[i] = value; }
    return true;
}

readonly is valid only on top-level api fns. Note this is a distinct use of the readonly keyword from the field modifier described in the type-system chapter.

Forcing Full-Fidelity Recording for Debugging#

Because non-recorded calls leave no entry in the timeline, a time-travel debugger cannot place a replay breakpoint inside one or scrub to it. When you need a full-fidelity recording of ordinary automatically pure APIs, compile with the environment flag WAX_RECORD_ALL_API_FNS=1. The flag does not override a readonly transient boundary: that call remains omitted because rollback and non-recording are part of its checked contract, not an optimization choice. A recording made with the flag is larger and only replays against an image compiled with the same flag.

Debugger Query Overlays#

A debugger query overlay is source rooted under a recognized Queries/ directory. It is a POST-HOC overlay: an ordinary build or check never discovers or compiles it, and a recording never captures it. The debugger compiles the overlay sources a command supplies explicitly against the recorded application’s ordinary source graph for that invocation; overlay source is never included in the ordinary application image. Consequently, query facilities introduce no inactive branch or runtime cost in a normal application build, and a query may be authored after the recording it runs against was captured.

A query root has one of these signatures:

Wax
query fn Inspect() : void { ... }
query fn InspectWithArguments(json arguments) : void { ... }
query fn InspectTyped() : Row { ... }
query fn InspectWithTypedArguments(Args a) : Row { ... }

A query root accepts either no parameters or exactly one parameter, which is either a bare json blob or a TYPED argument struct. It returns void, or a value whose type is query-compatible — the debugger publishes that type as the root’s schema and renders the returned value against it.

A typed argument struct’s fields use the return vocabulary with two restrictions. The declared-return class exception is removed: an argument names an object only as StableRef<C>, because the caller is on the far side of a serialized boundary and has no object to hand over. <!-- wax-query-vocabulary:spec-argument-sequences:start --> Collection types admitted only for results are refused. A sequence argument is a fixed input decoded from a JSON array of known length, so it takes T[] rather than List<T>. Result-only collections can be built from it inside the query. <!-- wax-query-vocabulary:spec-argument-sequences:end --> An array field decodes element by element over that same vocabulary, and a refusal names the element it stopped at (ids[2], rows[1].depth). Vector, matrix, color, quaternion, angle, and time values use ordinary objects of their declared struct components on both the argument and return wires: float3 is {"x":1,"y":2,"z":3}, while angle, timespan, and timepoint remain one-field objects. Enum fields are named by case name. Every non-nullable field is required; a T? field may be omitted. The debugger publishes the struct as the root’s argument schema, decodes an invocation’s arguments against it before the body runs, and refuses a mismatch by naming the offending field. The debugger catalogs query roots and invokes a selected root against a replayed application frame. Ordinary functions declared in the same query overlay may serve as helpers and have access to the same query facilities; they are not themselves query roots unless declared with query fn.

Investigations#

An investigation groups discoverable debugger work under one top-level declaration:

Wax
struct PlayerArgs { int32 id; }

investigation Players("Inspect recorded players") {
    /** Return the selected player's health. */
    fn Health(PlayerArgs args) : int32 { return ReadHealth(args); }
    private fn ReadHealth(PlayerArgs args) : int32 { return args.id; }
    case "First player" => Health(new PlayerArgs { .id = 1 });
}

An investigation is structure for the debugger runner, not an object or namespace. It cannot be constructed, stored, or called by ordinary Wax code. Its public members are independent runner entries and are published with their argument and result schemas; private members are callable only by other members and cases in the same declaration. Omitted visibility means public. internal warns and behaves as private, while protected is invalid because a case program has no inheritance boundary.

Every executable member is non-generic and non-throwing. It takes no argument or one plain by-value typed struct argument and returns the same portable result vocabulary as a query entry. A named case is a closed direct call to one member in the declaration; its arguments are type checked normally and are evaluated before investigation capability begins, so case arguments cannot use query.scan or query.at.

The declaration description, case names, and member documentation are discovery metadata. A documentation block (/** ... */) attaches only to the immediately following member under the ordinary documentation-block rules. Investigation catalog construction does not execute member bodies or case expressions.

A runner selects an investigation by its durable catalog identity and then selects either one public member or one named case. Direct member invocation supplies the complete JSON object required by the member’s typed argument, if any. A named case uses only its source-authored arguments and accepts no overrides. Case argument preparation runs before investigation capability begins; the selected member and helpers it calls run with that capability. A named case may target a private member, but a runner cannot select that member directly.

Identity in a Returned Value: StableRef<C>#

A returned value carries CONTENTS, never IDENTITY. A container’s elements cross by value; a class does not cross at all, because one reference out of an object graph reaches the whole graph. A class-typed member of a returned value is therefore spelled StableRef<C>, a value struct over the object’s stable id:

Wax
import StableRef from Wax;

struct BossRow { StableRef<Entity> target; int32 health; }

StableRef<C> requires C to be a class. A C converts to a StableRef<C> implicitly, in any position — a field initializer, an assignment, a return, an argument. StableRef<C>.Of(value) is the same conversion written explicitly. Likewise, a C? converts to StableRef<C>?, preserving null. The explicit spelling of that nullable conversion is StableRef<C>.FromValue(value). The struct exposes id (the packed stable id), Equals, and GetHashCode; equality and hashing are by id, since identity is the whole of what it carries.

A ref may only name an object that existed in the replay the query is observing. Converting an object the query itself allocated panics: a query’s allocations are rolled back when it returns, and the ordinal one consumed is reissued to an unrelated application object afterwards, so such a ref would resolve to a live wrong object rather than to nothing.

Resolve() : C? reads the ref back as an object. Resolution happens against the frame the replay cursor is on when it is called, not against the frame the id was harvested from, which is what makes a single id usable across a whole range of frames; a frame whose heap does not hold the object answers null, and not-yet-allocated is indistinguishable from already-collected. The reserved id 0 that a default-constructed ref carries also answers null.

Resolve() is an INGRESS, not a navigation tool: a query already reaches the whole replay heap, so the only thing a ref is needed for is turning an inbound @id argument into an object. It is scoped to a running query invocation and panics outside one, because a stable id names a position in the RECORDED run’s allocation sequence — read against a live program, the same ordinal names whatever that program has since put there, which is a plausible wrong object rather than a missing one.

A StableRef<C> ARGUMENT is checked against C when the arguments are decoded, before the body runs. A stable id carries its object’s concrete type, so the check is decided from the id: C or any class deriving from C is accepted, and anything else is an argument error naming the field’s path, what the id names, and what was declared. It follows that an id of the wrong type is refused whether or not that object is alive at the frame in question, while an id of the right type naming an object that frame does not hold is a perfectly good argument that resolves to null.

Query Results#

A query fn root produces exactly one result per invocation: the value it returns. The root’s declared return type IS the result schema — the debugger publishes it ahead of the call and decodes the value against it, so a caller knows the shape without inspecting the output. There is no separate emit facility and no result buffer; a query has one value, in one place, named by one type.

Wax
struct PlayerRow { int32 health; bool alive; }

query fn PlayerState() : PlayerRow {
    var player = GetPlayer();
    return new PlayerRow { .health = player.health, .alive = player.health > 0 };
}

A root declared : void produces no value. It is a well-formed query — its effects and its diagnostics still happen — and its published schema is the absence of a return type, which every host renders as JSON null.

The declared return type must be in the query row vocabulary, which is checked at the declaration rather than resolved into an implicit encoding at run time. The vocabulary is:

  • the primitive scalars — int8/int16/int32/int64 and their unsigned counterparts, float, double, bool, char;
  • string;
  • enums;
  • the vector/matrix/color/quaternion/angle/time family; <!-- wax-query-vocabulary:spec-recording-types:start -->
  • the Wax::Recording types Frame, FrameRange, QueryScanStatus, and QueryFailure (QueryFailureKind is covered by enums); <!-- wax-query-vocabulary:spec-recording-types:end -->
  • StableRef<C> for a class C, which carries that object’s stable id rather than its contents;
  • T? for a T in the vocabulary; <!-- wax-query-vocabulary:spec-result-sequences:start -->
  • List<T> and T[] of members of the vocabulary; and <!-- wax-query-vocabulary:spec-result-sequences:end -->
  • structs whose every field is itself in the vocabulary.

The DECLARED return type may additionally be a class, in which case the result is that object’s identity rather than a row of contents. A class reached anywhere inside the return type is refused: use StableRef<C> to carry its id, or project the fields into a row struct declared in the overlay. An interface is refused even at the declared return, because which class satisfies it is a fact about the running heap rather than about the row.

Other collection types such as Stack, Queue, Dictionary, Set, and BitSet are refused because a query row needs a defined order. Project them into a List or an array with the order the result should preserve.

Two structural limits apply to the walk: a row may not be cyclic — a value snapshot cannot represent a cycle, and StableRef<C> is how a self-reference is broken — and it may not nest more than 64 levels deep.

Signed and unsigned integers remain distinct result types even though they share the same scalar IR widths.

Error Handling at the Boundary#

Errors cross the boundary via the WxError struct:

C
typedef struct {
    const char* errorType;    // short category, e.g. "not_found"
    const char* errorMessage; // human readable
    const char* details;      // optional JSON string, NULL if absent
} WxError;

api fn (host calls Wax): Every generated function takes WxError* as the last parameter. If the Wax function panics or throws an unhandled error, the trampoline catches it and writes the error. The host can pass NULL if it doesn’t care.

host fn ... throws (Wax calls host): The host writes to WxError* to signal failure. The Wax side must try the call:

Wax
host fn ReadFile(string path) : string throws;

string content = try ReadFile("config.json") catch |err| {
    if (err.errorType == "not_found") {
        // handle missing file
    }
    yield "";
};

Thread Affinity and Reentrancy#

The Wax app has thread affinity. The thread ID is stored at app creation and checked on every API call. Reentrancy is strictly forbidden: a bool flag on the app prevents calling a Wax API function from within a host function callback. Both violations are reported through the same WxError* path, never as crashes.

Threading Model#

Each runtime instance has one app thread. API entry, host calls, frame boundaries, GC, recording, debugging, and profiling remain owned by that thread. A deterministic parallel statement may temporarily use runtime-owned workers, but its kernel cannot cross the host boundary, allocate, mutate runtime-global state, or outlive the statement. All workers join before app-thread execution resumes. Host applications may also run separate runtime instances in parallel.

Why No Async?#

Wax is intentionally synchronous. All execution within a frame runs to completion without yielding. For async I/O operations, the host is responsible for managing async context externally and invoking Wax when results are ready. This keeps the Wax execution model simple and deterministic while allowing the host to use whatever async patterns are idiomatic for its platform.


The secret Type#

The secret type is a built-in value type for text the user is entering that must never reach a recording or a snapshot, such as a password in the middle of being typed. It is an ordinary Wax-resident value: the live heap holds its real bytes at all times. The protection is about artifacts: a snapshot or a recording handed to anyone carries zeros in exactly the content ranges a secret’s buffers occupy, with structure and lengths intact.

Content is UTF-32, one Unicode scalar per element, matching the text-input pipeline’s TextData.text side run.

The two promises#

  1. A snapshot handed to the user never contains secret bytes. Taking the snapshot zeroes secret buffer content in its copied image, in flat, chunked, and compact forms alike, while headers, counts, and reference structure stay intact. The live heap is untouched.
  2. Text the host marked secret is recorded in the normal format with content ranges zeroed, at the same structure and the same lengths. Secrecy is per event, not per frame: the host passes the mark with an intent and scalar run to the atomic text-event entry point, and the runtime constructs the TextEvent plus its sibling TextData.text range. Marked and unmarked events may share one frame; a marked event’s record keeps its tag, count, intent, and run length while the referenced scalar words are zero. The mark remains in the event on tape, so replay protects the same sibling range the live run did.

What is deliberately not promised: recordings quantize events to frames, so keystroke timing survives at frame granularity (roughly 17 ms buckets at 60 Hz). Which frames carried events, and how many, is visible even though content is provably absent. The full disclosure lives in Docs/RecordingFormat.md.

Declaring and using a secret#

secret is a contextual keyword: it names the type wherever the grammar expects a type, and remains a legal ordinary identifier everywhere else (locals, parameters, and fields may be named secret; a struct, class, or generic parameter may not).

Wax
import TextData from Wax;
import TextIntent from Wax;

class LoginForm {
    public secret password;
    constructor() { password = new secret(); }
}

fn ReadInput(LoginForm form) : void {
    foreach (TextData.events) |t| {
        if (t.intent == TextIntent.Insert || t.intent == TextIntent.InsertFromPaste) { form.password.AppendText(t); }
        if (t.intent == TextIntent.DeleteBackward) {
            if (form.password.Count > 0) { form.password.RemoveAt(form.password.Count - 1); }
        }
    }
}

The API, implemented in ordinary Wax over a runtime-tagged buffer:

Member Meaning
Count : int32 number of scalars
Append(uint32) append one scalar (a keystroke)
AppendRun(scoped ReadOnlySpan<uint32>) append a borrowed run in one allocation
AppendText(TextEvent) append one text event’s checked side-run range
Insert(int32, uint32) insert at an index
RemoveAt(int32) / RemoveRange(int32, int32) delete
Clear() empty the value
At(int32) : uint32 read one scalar back
Reveal() : string build a UTF-8 string from the scalars

Copying a secret copies the reference: two values may share a buffer, like any struct holding an array. secret?, secret[], generic instantiation (List<secret>), fields, locals, parameters, and returns inside Wax are all ordinary.

Structural refusals#

Formatting a secret, whether by string interpolation or ToString, renders exactly <secret:redacted>: no length, no content, no branch for an observer to learn from. Equality (==/!=), hashing, use as a map or set key, and ordering are refused because Secret declares no Equals, GetHashCode, or CompareTo, so Equatable, Hashable, and Comparable constraints reject it structurally rather than by policy.

At and Reveal hand the app real bytes in a live run. Anything the app derives from them into ordinary state is ordinary state: it lands in artifacts in plaintext, and it fails byte-for-byte replay verification in exactly the derived value, because a replayed run sees zeros of the same lengths where the live run saw content. This is the intended cost of moving content out of the protected type, and it is the one deliberate determinism divergence in the record/replay design: a run that derives nothing replays byte-for-byte. Derive at the moment of use by handing the secret to a host fn, rather than deriving into stored state.

Boundary rules#

  • A secret may cross a required host fn parameter or return, spelled exactly secret. The nullable and array spellings keep their ordinary boundary refusals. A returned secret’s taped record is its ordinary array record with a zeroed payload; replay materializes tagged zeros of the recorded length. A secret parameter hands the trusted host the real bytes and tapes nothing (host-fn arguments are never taped).
  • api fn parameters and returns refuse secret by name: the api boundary tapes its arguments verbatim into recordings.
  • Host-optional imports (host? fn) refuse secret positions by name: the fallback crossing’s recording story is not audited for redaction.
  • A direct field of a required channel may be exactly secret. The live canonical value and the Wax heap contain plaintext. Its channel record preserves the scalar count and whether a change occurred while zero-filling the scalar body; replay therefore reproduces GetChanges() while reading zeros. Aggregate fields, arrays, event elements, nullable wrappers, and optional-channel fields refuse secret with WAX0661.

In each refused boundary position, pass the secret through a required host fn or move it to its own direct required-channel register.

Restore semantics#

Restoring a snapshot yields zeros in secret buffers, at the right structure and the right lengths, and the buffers keep their secret tag, so artifacts created after a restore stay redacted too.