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.
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.
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.
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.
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.
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.
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:
Like any good tour, we’ll start with the simplest program: printing a message.
Wax
apifnMain() {
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.
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. */apifnStartGame(stringplayer) { }
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.
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 variablestringmessage = "This is a string.";
// Type is inferred from the assigned value (string)varinferredMessage = "This is also a string.";
// Variables are mutable by defaultintcounter = 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
constfloatPi = 3.14159f;
// Pi = 3.0; // This would cause a compile error
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 integerssbyte// 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
intdecimal = 42;
longbigNumber = 900_000_000L; // underscores can be used for visual separationuintpositiveOnly = 100u;
int32hexValues = 0xff;
int32binaryValues = 0b10_010101;
Floating-Point Numbers: For numbers with fractional parts.
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.
You can control the execution path of your code with conditional statements and loops.
if-else Statements
Wax
intscore = 85;
if (score > 90) {
Debug.Log("Grade: A");
}
elseif (score > 80) {
Debug.Log("Grade: B");
}
else {
Debug.Log("Grade: C or lower");
}
for Loops
Wax
// A traditional C-style for loopfor (inti = 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 = newList<string>();
names.Add("Alice");
names.Add("Bob");
names.Add("Charlie");
foreach (names) |name| {
Debug.Log("Hello, $name!");
}
// You can also get the indexforeach (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) : (
readonlyint32 value from source,
outint32 result from destination,
) |index| {
result = value + index;
}
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.fnAdd(inta, intb) : int {
return a + b;
}
// A function with no return value (implicitly returns void).fnGreet(stringname) {
Debug.Log("Welcome, $name!");
}
// Calling the functionsint result = Add(10, 20); // result is 30
Greet("developer"); // Prints "Welcome, developer!"
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.
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 typeuintunsignedInt = 101u;
longlongInt = 101l;
ulongunsignedLong = 101ul;
// Underscores can be placed anywhere for readability.longbigNumber = 1_000_000_000l;
inthexValue = 0xDE_AD_BE_EF;
intbinaryValue = 0b0101_0101;
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.inta = 10;
int32b = 10;
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 ---longmyLong = 123; // OK: Implicitly promotes 'int' literal.// --- Variable Casting Examples ---longbigValue = 3_000_000_000l;
floatpi = 3.14f;
// Truncating 'as'inttruncated = bigValue asint; // Value wraps around, result is -1294967296inttruncatedFloat = pi asint; // Value is truncated, result is 3intsaturatedFloat = 1.0e30asint; // Value saturates, result is 2147483647// Safe 'as?' - Returns null on data lossint? noFit = bigValue as? int; // noFit is nullint? alsoNoFit = pi as? int; // alsoNoFit is nullint? 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.intmustFit = 100l as! int; // OK: mustFit is 100
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
inta = 10;
longb = 20l;
// OK: 'a' is implicitly widened to long.longc = a + b;
// Cross-domain requires explicit cast:floatf = 2.5f;
// double d = a + f; // ERROR: int and float are different domainsdoubled = (a asdouble) + f; // OK: explicit cast to double
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
charletterA = 'A';
// Examples of Unicode escape sequencescharomega = '\u03A9'; // 4-digit hex, for Basic Multilingual PlanechargrinningFace = '\u{1F600}'; // Variable-length hex, for full Unicode rangecharmusicalG = '\U0001D11E'; // 8-digit hex, for full Unicode range
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
stringgreeting = "Hello, World!";
stringempty = "";
// Using escape sequences in strings.stringpath = "C:\\Users\\Default\\";
stringmultiline = "Line 1\nLine 2";
stringwithOmega = "The symbol for Omega is \u03A9.";
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 stringstringpoem = """
The rose is red,
The violet is blue,
Wax is awesome,
And so are you.
""";// Interpolation works the same way.stringreport = """
Report for: $user
-------------------
Final Score: $score""";// You can use more quotes to allow """ inside the string.stringdoc = """"
This string can contain """, which is useful for examples."""";
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
stringrequiredText = "This can't be null.";
string? optionalText = null; // This is allowed.intrequiredInt = 10;
int? optionalInt = null; // This is also allowed.
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;
boolbothNull = a == b; // truebooloneNull = a == c; // falseboolsameValue = c == d; // true
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
fnLength(string? text) : int32 {
if (text == null) {
return0;
}
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.
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!");
}
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.
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: ?.fieldint32? 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");
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.stringvalue = maybeValue!;
// This would cause a panic at runtime.string? nullValue = null;
stringbadAccess = nullValue!; // PANIC!
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
vari = 10; // Inferred as intvarpi = 3.14; // Inferred as doublevarname = "Alex"; // Inferred as stringvarisDone = true; // Inferred as bool
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
constint MaxUsers = 100;
constint MaxAdmins = 10;
constint TotalAccounts = MaxUsers + MaxAdmins; // Math on constsconststring AppName = "My Awesome App";
conststring Version = "1.0";
conststring AppTitle = "${AppName} v${Version}"; // String interpolationflagsPermissions { case Read=1; case Write=2; }
constPermissions ReadWrite = Permissions.Read | Permissions.Write; // Enum ops
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
objectobj1 = newMyClass();
objectobj2 = "a string is a reference 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
fnLogMessage(stringmessage) : void {
// This function performs an action but does not return a value.
}
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.
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 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.
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.
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:
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 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
enumColor {
defaultcase Black; // This case is the defaultcase 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 0string? s = default; // s is nullstructPoint { float x; float y; }
Point p = default; // p is a Point where p.x=0.0f, p.y=0.0fenumState { case Idle = 0; case Running = 1; }
Statestate = default; // state is State.IdleclassPlayer { constructor() { /* ... */ } }
Player player = newPlayer(); // 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.
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.
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;
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
Regexdigits = regex(/\d+/);
Regexemail = regex(/[a-zA-Z]+@[a-zA-Z]+\.[a-zA-Z]+/);
// With flagsRegexcaseInsensitive = 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.
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:
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.
Pass RegexOptions as a second argument to regex():
Wax
Regexr = 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).
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;
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.
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.
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: ?.fieldint32? 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.
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.stringdisplayName = 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
Playerp = playerName ?? thrownewError("no name"); // in a `throws` fnPlayerp = playerName ?? panic("no name");
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.stringunwrappedValue = requiredValue!; // Panics if requiredValue is null.
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
objectshape = GetShape();
boolisCircle = (shape isCircle); // isCircle is true or falseif (isCircle) {
// We know the shape is a Circle, but we still need to cast it.varc = shape as! Circle;
Debug.Log("It's a circle with radius ${c.radius}");
}
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.
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
fnProcess(Animalanimal) {
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
Animalanimal = GetDogFromDatabase(); // We are certain this is a DogDogmyDog = animal as! Dog; // Panics if it's not a Dog
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.
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
vara = newfloat2(1, 2);
varb = newfloat2(3, 4);
// This works because float2 has a built-in operator overload for '+'.varc = a + b; // c is (4, 6)
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.
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++.
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
intscore = 85;
if (score >= 90) {
Debug.Log("Grade: A");
}
elseif (score >= 80) {
Debug.Log("Grade: B");
}
else {
Debug.Log("Grade: C or lower");
}
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.objectdata = GetSomeAnimal();
if (data isDog) |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 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.
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 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
enumStatus {
case Pending;
case Running;
case Completed;
case Failed;
}
StatuscurrentStatus = 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.
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
intscore = 75;
stringresult = (score > 50) ? "Pass" : "Fail";
// result is "Pass"// Like 'if', the ternary expression also supports payload unwrapping.string? maybeName = GetName();
stringdisplayName = 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.intport = config.HasPort ? config.Port : thrownewConfigError("no port");
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 loopintcountdown = 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 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.inti = 10;
do {
Debug.Log("This will print once.");
i++;
} while (i < 5);
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
importListfrom Wax;
varnames = newList<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.");
}
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
stateforeach (users) |user| key user.id {
statebool 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.
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) : (
readonlyint32 value from source,
outint32 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.
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 (inti = 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 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.
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).fnDoSomething() {
// ...
}
// A function with parameters and an explicit return type.fnAdd(inta, intb) : int {
return a + b;
}
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.
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
structBox<T> {
publicint32 tag;
publicconstructor Tagged() { tag = 7; }
}
// `new Box<T>()` in the default resolves T per instantiation.fnPick<T>(int32 a, Box<T> b = newBox<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.fnCreateEntity(stringname, Pointposition = newPoint(0, 0), boolenabled = true) {
// ... function body
}
// Invalid: 'priority' is required but comes after 'retry', which has a default.fnConfigure(stringid, boolretry = true, intpriority) { // 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.fnPostNotification(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.");
};
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.
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 modifiersfnProcessData(refint value, outstring result) {
value *= 2; // Modifies the original caller's variable
result = "Processed"; // Assigns to the caller's variable
}
// Call-siteintmyValue = 10;
stringmyResult; // Does not need to be initialized for 'out'ProcessData(ref myValue, out myResult);
// After the call, myValue is 20 and myResult is "Processed"
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.
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.
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
classCalculator {
// Instance methodfnAdd(inta, intb) : int { return a + b; }
// Static methodstaticfnGetHelp() : string {
return"This is a calculator class.";
}
}
// Calling an instance method requires an object.var calc = newCalculator();
int sum = calc.Add(2, 3);
// Calling a static method is done on the type.string help = Calculator.GetHelp();
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
abstractclassAnimal {
// An abstract method that derived classes MUST implement.publicabstractfnMakeSound() : string;
}
classDogextendsAnimal {
// We MUST provide an implementation for the abstract method.publicoverridefnMakeSound() : string {
return"Woof!";
}
}
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.fnSwap<T>(ref T a, ref T b) {
vartemp = a;
a = b;
b = temp;
}
// The compiler infers 'T' as 'int' at the call-site.intx = 10;
inty = 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.
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.fnProcessInput(stringdata, fn<(string) : bool> validator) {
if (validator(data)) { /* ... */ }
}
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.fnKeepIf(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.
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
fnCreateGreeter(stringgreeting) : fn<(string) : string> {
// This lambda captures the 'greeting' variable.return (name) => "$greeting, $name!";
}
var helloGreeter = CreateGreeter("Hello");
string message = helloGreeter("Wax"); // Returns "Hello, Wax!"
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
classCalculator {
privateint factor;
constructor(intfactor) { this.factor = factor; }
fnMultiply(intvalue) : int { return value * this.factor; }
}
var calc5 = newCalculator(5);
// This creates a closure that captures the 'calc5' instance.fn<(int) : int> multiplyBy5 = calc5.Multiply;
int result = multiplyBy5(3); // returns 15
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.fnOnButtonClick(fn<() : void> action) { /* ... */ }
// The | | is not required if the closure has no parameters.
OnButtonClick() {
Log("Button was clicked!");
};
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
fnForEach<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.");
};
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.fnExecuteIfValid(stringdata, fn<() : voidthrowsValidationError> action) {
if (data.length > 0) {
// The error propagates from this invocation point.action();
}
}
fnProcess() throwsValidationError {
// The 'try' is required here at the call-site.tryExecuteIfValid("someData", () => {
thrownewValidationError("Failed!");
});
}
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.
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.varscores = newint[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.
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.
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.vargrid = newint[3][5];
grid[0][2] = 42; // inner arrays are pre-allocated, so indexing works immediately// Three-level jagged, fully pre-allocated.varcube = newint[2][3][4];
// Partial pre-allocation: only outer array allocated, inner arrays are null.varragged = newint[3][];
// Partial: outer and middle allocated, innermost null.varpartial = newint[2][3][];
When inner arrays have different sizes, allocate the outer array first, then assign each inner array individually.
Wax
vargrid = newint[3][];
grid[0] = newint[2]; // 2 columns in the first row
grid[1] = newint[4]; // 4 columns in the second row
grid[2] = newint[3]; // 3 columns in the third row// Accessing elements uses sequential brackets.
grid[0][1] = 10;
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.
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
importDictionaryfrom Wax;
varplayerScores = newDictionary<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.intaliceScore = 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}");
}
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
importSetfrom Wax;
vartags = newSet<string>();
tags.Add("ui");
tags.Add("player");
tags.Add("ui"); // This is a duplicate and will be ignored.Log(tags.size); // Outputs: 2boolhasPlayerTag = tags.Has("player"); // true
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.
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.
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.
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.varhighScores = playerScores
->Filter(|kvp| kvp.key.length > 3)
->Map(|kvp| kvp.value)
->ToList();
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.
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
varnumbers = newint[] { 10, 20, 30, 40, 50, };
// Create a span that views the entire array.scopedSpan<int> fullSlice = numbers.ToSpan();
// Create a span that views a portion of the array.scopedSpan<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 }
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
lateinitstring[] names = newlateinitstring[3]; // 3 unassigned slots
names[0] = "ann"; // checked writeboolhas0 = names.IsAssigned(0); // per-slot test (never panics) -> trueboolall = 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 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.
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.
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.
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:
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
structVec(floatx, floaty) {
fnLengthSquared() : 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:
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 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
classCharacter {
privateint _health;
// 'Health' is a property that controls access to the private '_health' field.publicgetHealth() : int {
returnthis._health;
}
publicsetHealth(intvalue) {
// The setter can contain logic, like validation.this._health = value < 0 ? 0 : value;
}
}
// --- Usage Example ---varhero = newCharacter();
// The 'set' accessor is called using a simple assignment.
hero.Health = 90;
// The 'get' accessor is called by accessing the value.intcurrentHealth = hero.Health; // currentHealth is now 90// The setter's validation logic is automatically used.
hero.Health = -10;
intnewHealth = hero.Health; // newHealth is now 0, not -10.
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
classExample {
publicint a; // Accessible everywhereprivateint b; // Accessible only within Exampleprotectedint c; // Accessible within Example and its subclassesinternalint d; // Accessible within the same module
}
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
classGame {
// A static field to track the total number of players across all games.publicstaticint totalPlayers = 0;
// A static property to get the name of the game engine.staticgetEngineName() : string {
return"WaxEngine";
}
// A static method to provide help text.staticfnGetHelpText() : string {
return"This is a generic help message for all games.";
}
}
// Accessing static membersint players = Game.totalPlayers;
string engine = Game.EngineName;
string help = Game.GetHelpText();
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.structPoint {
float x;
float y;
constructor(floatx, floaty) {
this.x = x;
this.y = y;
}
fnDistance(Pointother) : float {
floatdx = this.x - other.x;
floatdy = this.y - other.y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
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.classAnimal {
string name;
constructor(stringname) {
this.name = name;
}
// A 'virtual' method can be replaced by derived classes.virtualfnMakeSound() : string {
return"Some generic animal sound";
}
}
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.varp1 = newPoint(10, 20);
varp2 = 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.varanimal1 = newAnimal("Fido");
varanimal2 = 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.
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.
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
fnGetSize<T>(Tvalue) : int32 {
returnsizeof(T);
}
Using sizeof on a recursive struct (a struct that contains itself as a field) produces a compile-time error.
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.
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.enumGameState {
case MainMenu; // 0case Playing; // 1case Paused; // 2case GameOver; // 3
}
// Using the enumGameStatecurrentState = GameState.Playing;
if (currentState == GameState.Playing) {
// ...
}
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.enumStatusCodeextendsshort {
case Success = 200;
case NotFound = 404;
case ServerError = 500;
}
// An enum with mixed explicit and auto-incrementing values.enumPriorityextendsbyte {
case Low; // 0case Medium = 5; // 5case High; // 6 (increments from Medium)case Critical = 10; // 10
}
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.
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
flagsExampleextendsuint {
case A = 1; // Single bit (power of 2)case B = 2; // Single bitcase C = 4; // Single bitcase AB = 3; // VALID: Decomposes to A | Bcase AC = 5; // VALID: Decomposes to A | Ccase BC = 6; // VALID: Decomposes to B | Ccase 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.
You can manipulate flags using bitwise operators (|, &, ^) to add, check for, or toggle values.
Wax
// Start with Read and Write permissions.PermissionsuserPerms = 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;
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
enumColor {
defaultcase Black; // This case is the defaultcase Red;
case Blue;
}
// Now you can use the default keyword with this enum.Color c = default; // c is Color.Black
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.
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.
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
classDogextendsAnimal {
// Subclasses of Dog inherit this, but cannot replace it.publicsealedoverridefnMakeSound() : string {
return"Woof";
}
}
classPuppyextendsDog {
// ERROR: Dog.MakeSound is sealed.// public override fn MakeSound() : string { return "Yip"; }
}
Wax
// --- Example with Methods ---classAnimal {
// This method can be replaced by subclasses.publicvirtualfnMakeSound() : string {
return"Some generic animal sound";
}
}
classDogextendsAnimal {
// This provides a specific version of the method for Dogs.publicoverridefnMakeSound() : string {
return"Woof!";
}
}
// --- Example with Properties ---classBaseValue {
// Base class provides a read-only virtual property.publicvirtualgetValue() : int { return0; }
}
classSettableValueextendsBaseValue {
privateint storedValue;
// Override the getter.publicoverridegetValue() : int {
returnthis.storedValue;
}
// Add a setter, making the property read-write in the derived class.publicsetValue(intvalue) {
this.storedValue = value;
}
}
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.abstractclassShape {
// An abstract method that derived classes MUST implement.publicabstractfnGetArea() : float;
// A regular virtual method that can optionally be overridden.publicvirtualfnGetName() : string {
return"A generic shape";
}
}
classCircleextendsShape {
publicfloat radius;
// We MUST provide an implementation for the abstract GetArea method.publicoverridefnGetArea() : float {
return3.14f * this.radius * this.radius;
}
}
// var s = new Shape(); // ERROR: Cannot create an instance of an abstract class.var c = newCircle();
c.radius = 10.0f;
float area = c.GetArea(); // 314.0f
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:
Any other constructor is explicitly declared.
Wax
classExample {
// The presence of this constructor prevents the default one.constructor(intvalue) { }
}
// var ex = new Example(); // ERROR: No parameterless constructor exists.
The base class does not have an accessible parameterless constructor.
Wax
classBase {
constructor(intvalue) { } // No parameterless constructor.
}
classDerivedextendsBase {
// ERROR: No default constructor is generated because it cannot// implicitly call a non-existent base() constructor.
}
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
classUser {
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.
}
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
classBase {
publicint value;
constructor(intvalue) {
this.value = value;
}
}
classDerivedextendsBase {
publicstring name;
// This constructor explicitly calls the base constructor.constructor(intval, stringname) : base(val) {
this.name = name;
}
// This constructor chains to another constructor in THIS class.constructor(stringname) : this(0, name) {
// The call to :this(0, name) handles the base initialization.
}
}
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
classBase {
constructor() { /*...*/ }
constructor FromValue(intvalue) { /* ... */ }
}
classDerivedextendsBase {
// Calls the NAMED constructor in the base class.constructor(stringname) : base.FromValue(name.length) {
// ...
}
}
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
classPoint {
publicint x;
publicint y;
}
Point p = newPoint { .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
Pointp = 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
fnApply(Config c) : int { /* ... */ }
fnMake() : Point {
returnnew { 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
classFoo {
publicreadonlyint x;
constructor(intv) { x = v; }
}
Foo a = newFoo(1); // okFoo b = newFoo { .x = 1 }; // rejected: `x` is readonly
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:
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.
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.
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:
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.
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:
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
classAccount {
privateint32 balance;
publicconstructor(int32balance) { this.balance = balance; }
privatefnRate() : int32 { return3; }
// A second instance of the same type is reachable, in every direction.fnAbsorb(Accountother) : int32 {
balance = balance + other.balance; // read another instance's private field
other.balance = 0; // ...and write itreturn 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.
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.interfaceIDamageable {
getHealth() : int;
fnTakeDamage(intamount);
}
// Classes can promise to provide that capability.classPlayerimplementsIDamageable { /* ... */ }
classBarrelimplementsIDamageable { /* ... */ }
// struct Boulder implements IDamageable { /* ... */ } // ERROR: Structs cannot implement interfaces.
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
interfaceILogger {
fnLog(stringmessage);
}
classConsoleLoggerimplementsILogger {
publicfnLog(stringmessage) { /* ... */ }
}
classFileLoggerimplementsILogger {
publicfnLog(stringmessage) { /* ... */ }
}
// This class is decoupled from any specific logger implementation.classApp {
privateILogger logger;
constructor(ILoggerlogger) {
this.logger = logger; // Store any object that can log.
}
fnDoWork() {
this.logger.Log("Doing some work...");
}
}
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.
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 ---interfaceIWriter { fnWrite(stringdata); }
interfaceIArchiver { fnWrite(byte[] data); }
classDataHandlerimplementsIWriter, IArchiver {
publicfnWrite(stringdata) { /* ... */ }
fnIArchiver.Write(byte[] data) { /* ... */ } // Explicit implementation
}
// --- Scenario 2: Conflict between a class and an interface ---interfaceIAction {
fnExecute();
}
classTaskimplementsIAction {
// This public method has a different signature and purpose.publicfnExecute(boolforce) {
// ...
}
// Explicit implementation is required to satisfy IAction without a name collision.fnIAction.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.
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
classTaskimplementsIAction { /* ... */ }
var myTask = newTask();
// Implicit upcast from Task to IAction. This is always safe.IAction myAction = myTask;
// Implicit upcast from Dog to its base class Animal.Animal myPet = newDog();
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
fnProcessAction(IActionaction) {
// 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 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.
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.publicclassContainer<T> {
privateT? item;
publicfnPut(Titem) {
this.item = item;
}
publicfnTake() : T? {
vartemp = this.item;
this.item = null;
return temp;
}
}
// A generic utility method. The compiler can often infer the type.classUtility {
publicstaticfnSwap<T>(refTa, refTb) {
Ttemp = a;
a = b;
b = temp;
}
}
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
classLane<T> {
fnSelect<U>(fn<(T) : U> sel) : Lane<U> { ... }
}
Lane<int32> hp = robots.Select((r) => r.health); // U inferred as int32 from the body
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.
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.
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.
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.classGameObject { /* ... */ }
// An interface for things that can be saved.interfaceISaveable { fnSave(); }
// A named constraint for things that can be reset.constraintResettable { fnReset(); }
// This function's type parameter 'T' has three requirements.fnProcessObject<T>(Tobj) whereTisGameObject & ISaveable & Resettable {
// ...
}
// You can also use qualifiers directly as a requirement.// This function will only accept floating-point types (float, double).fnProcessRealNumber<T>(Tnumber) : voidwhereTisreal {
// ...
}
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.
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.
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:
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 listfnProcessObject<T>(Tobj) whereTisGameObject & ISaveable & Resettable { }
// is rewritable as a simple (and reusable) constraint list:constraintProcessableObjectimplementsGameObject, ISaveable, Resettable {}
fnProcessObject<T>(Tobj) whereTisProcessableObject {
// ...
}
This improves readability and makes the code easier to maintain.
Qualifiers are keywords that restrict the fundamental kind of a type. They can be used in two ways:
When declaring a named constraint, to limit what kinds of types can satisfy it.
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 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.
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.
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
constraintCloneable {
// The Clone method MUST return the exact same type.fnClone() : this;
}
constraintFluentBuilder {
// The WithOption method MUST return the exact same type to allow chaining.fnWithOption(stringkey, stringvalue) : this;
}
// --- Implementation ---// When implementing, use the concrete type name, not 'this'.classMyBuilderimplementsFluentBuilder {
fnWithOption(stringkey, stringvalue) : MyBuilder {
// ... configure ...returnthis; // 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:
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
constraintComparable {
// The parameter can be the checked type or any ancestor it is castable to.fnCompare(baseother) : int;
}
classAnimal {
fnCompare(Animalother) : int { returnthis.id - other.id; }
}
classDogextendsAnimal { }
// 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
fnSort<T>(Ta, Tb) whereTisComparable {
intresult = a.Compare(b); // b is T, always valid
}
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.constraintFactory<T> {
fnCreate() : T;
}
// A constraint for a type that can be converted from T to U.constraintConverter<T, U> {
fnConvert(Tinput) : U;
}
// A generic method that uses a parameterized constraint.// The type U must be a factory that can produce T's.fnCreateAndProcess<T, U>(Ufactory) : TwhereUisFactory<T> {
TnewInstance = factory.Create();
// ... process newInstancereturn newInstance;
}
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.)
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.
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.
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.
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.
When a generic parameter has multiple constraints, the type argument must satisfy all requirements from the combined, flattened hierarchy.
Wax
// Define the building blocksconstraintPrintable { fnPrint() : string; }
constraintComparable { fnCompare(thisother) : int; }
// 'Serializable' requires 'Printable' and adds its own requirement.constraintSerializableimplementsPrintable {
fnToBytes() : byte[];
}
// This function requires a type 'T' that is BOTH Serializable and Comparable.fnProcessComplex<T>(Titem, Tother) whereTisSerializable & Comparable {
// The flattened requirements for T are: Print(), ToBytes(), and Compare().// All of these calls are valid.stringtext = item.Print();
byte[] data = item.ToBytes();
intcomparison = item.Compare(other);
// ...
}
// To be used with ProcessComplex, this struct must implement all three methods.structComplexType {
publicfnPrint() : string { return"Complex data"; }
publicfnToBytes() : byte[] { returnnewbyte[](); }
publicfnCompare(ComplexTypeother) : int { return0; }
}
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.
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
abstractclassBase {
publicvirtualfnSort<T>(T[] items) whereTisComparable & Cloneable;
}
classDerivedextendsBase {
// ✓ VALID: Constraints match exactly in the same order.publicoverridefnSort<T>(T[] items) whereTisComparable & Cloneable { /*...*/ }
// ✗ ERROR: Constraints in the wrong order.// public override fn Sort<T>(T[] items) where T is Cloneable & Comparable { /*...*/ }
}
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
constraintComparable {
fnCompareTo(thisother) : int32;
}
classContainer<T> {
T? value;
// Works for any T, with no constraint needed.publicfnGet() : T? { returnthis.value; }
// Only callable when T satisfies Comparable.publicfnMax(Tother) : TwhereTisComparable {
if (this.value.CompareTo(other) > 0) {
returnthis.value;
}
return other;
}
}
fnMain() {
// int32 satisfies Comparable, so both Get() and Max() are callable.varints = newContainer<int32>(5);
ints.Get();
ints.Max(3); // OK// Blob does NOT satisfy Comparable, so only Get() is callable.varblobs = newContainer<Blob>();
blobs.Get(); // OK
blobs.Max(newBlob()); // 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
classContainer<T> {
publicfnMapSorted<U>(fn<(T) : U> f) : List<U>
whereTisComparable, UisHashable {
// T is Comparable (from enclosing type), U is Hashable (method's own)
}
}
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.
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.
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.
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.publicapifnStartGame() { /* ... */ }
// An api function is a common way to expose a constant value to the host.publicapifnGetMaxPlayers() : int { return4; }
// A function provided by the host, callable from any Wax module.publichostfnLogMessage(stringmessage);
// A function provided by the host, but only callable from within this file.privatehostfnLogDebug(stringmessage);
// Static classes group related boundary functions without adding an instance receiver.staticclassConsole {
apifnWriteLine(stringmessage) { /* ... */ }
hostfnReadLine() : string;
}
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.
For a complete guide to function syntax, including parameters, return values, and advanced features, please see the main “Functions in Wax” documentation.
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.publicconststringAppName = "My Wax Application";
// An internal constant for this module only.internalconstfloatPi = 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
constint32[] Sizes = newint32[] { 1, 2, 3 }; // rejected: an array is not a literalstaticint32[] Sizes = newint32[] { 1, 2, 3 }; // ok
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.internalstaticint32 globalCounter = 0;
// A nullable class reference, which starts as null.privatestaticGameState? currentState;
// Static fields on a class.classCounter {
staticint32 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.
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.
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).
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
staticint32 a = 100; // top levelstaticclassCounter {
publicstaticint32 a = 4;
publicstaticint32 b = a + 1; // Counter.a, not the top-level a -> 5
}
classBox<T> {
publicstaticint32 a = 4;
publicstaticint32 b = Box<T>.a + 1; // same slot as a bare `a`
}
classOther<T> {
publicstaticint32 c = Box<T>.a + 2; // this instantiation's Box<T>.a
}
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
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;
classInner {
publicint32 x;
}
classHolder {
publicfnMake() : int32 {
Inneri = newInner(); // Inner is a sibling, and visible herereturn 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
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::TexturespublicclassTexture2D { /* ... */ }
publicfnLoadTexture(stringpath) : 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;
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 = newButton();
var gfxButton = newGfxButton();
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
importprotected 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’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.
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
errorFileError {
publicstring path;
publicstring message;
constructor(stringpath, stringmessage) {
this.path = path;
this.message = message;
}
// Errors can have methods to provide additional context or helpers.publicfnGetDisplayMessage() : string {
return"File Error on '${this.path}': ${this.message}";
}
}
// Errors can extend other errors to create a hierarchy.errorHttpErrorextendsNetworkError {
publicstring responseBody;
constructor(stringendpoint, intstatusCode, stringbody)
: base(endpoint, statusCode) {
this.responseBody = body;
}
}
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
fnLookup(stringkey) : 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.
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.
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.fnFetchData(stringurl) : stringthrowsNetworkError | 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
errorNetworkError { }
errorTimeoutErrorextendsNetworkError { }
fnFetch(stringurl) : stringthrowsNetworkError {
thrownewTimeoutError(); // a NetworkError, so the contract holds
}
fnLoad(stringurl) : stringthrowsNetworkError {
// Handle just the timeout; any other NetworkError propagates.returntryFetch(url) catchTimeoutError |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
fnWidened() : stringthrowsNetworkError | TimeoutError {
Errore = newTimeoutError();
throw e; // ✗ ERROR: only known to be `Error`, though it holds a member
}
fnBranching(booluseA) : stringthrowsNetworkError | TimeoutError {
throw useA ? newNetworkError() : newTimeoutError();
// ✗ ERROR: the two arms meet no lower than `Error`
}
fnFixed(booluseA) : stringthrowsNetworkError | TimeoutError {
if (useA) { thrownewNetworkError(); } // ✓ each throw names its own typethrownewTimeoutError();
}
Bare throws accepts all of these, because it promises nothing narrower than “some error” and so obliges the caller to handle everything.
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.fnRiskyOperation() throws {}
fnAnotherRiskyOperation() throwserror {}
A function with no throws declaration at all cannot throw an error. It must handle all potential errors from functions it calls internally.
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
fnGetValue() : string {
stringcontent = tryReadFile("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;
}
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.stringresult = tryReadFile("config.json") catch |e| {
yield"default_config"; // Returns this value from the try-catch expression.
};
// Conditional yield for more complex logic.stringdata = tryFetchData(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.tryDangerousOperation() catch |e| {
if (CanRecover(e)) {
yield; // done handling it; carry on after the try
}
panic("Unrecoverable error: ${e.message}");
};
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 tryexpression 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
fnLoad(stringurl) : stringthrowsNetworkError {
// TimeoutError is handled here; any other NetworkError is raised to our// caller, so `Decorate` is not reached for one.stringbody = tryFetch(url) catchTimeoutError |te| {
yield"";
};
returnDecorate(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.fnGetHomepage() : string {
stringdata = tryFetchData("api.example.com")
// This block only executes for a NetworkError.catchNetworkError |ne| {
yield"Site is down, please try again later. (Code: ${ne.statusCode})";
}
// This block only executes for a TimeoutError.catchTimeoutError |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;
}
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.fnLoadConfiguration() : stringthrowsFileError {
// Any FileError from ReadFile is propagated to the caller.returntryReadFile("app.config");
}
// Translating an errorfnProcessFile(stringpath) : stringthrowsProcessError {
stringcontent = tryReadFile(path)
// Catch the specific error from ReadFile...catchFileError |fe| {
// ...log it, and throw a new, more specific error.LogError("Underlying file error: ${fe.message}");
thrownewProcessError("Failed to process file contents from $path");
};
return content;
}
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
classDataProcessor {
fnLoadData() : DataProcessorthrowsFileError { /* ... */returnthis; }
fnValidateData() : DataProcessorthrowsValidationError { /* ... */returnthis; }
fnProcessData() : stringthrowsProcessError { /* ... */ }
}
// This function must declare all unhandled errors from the chain.fnRunProcessor() : stringthrowsProcessError {
stringresult = trynewDataProcessor()
.LoadData() // Can throw FileError
.ValidateData() // Can throw ValidationError
.ProcessData() // Can throw ProcessError// Handle FileError specificallycatchFileError |fe| {
yield"Default result: File error";
}
// Handle ValidationError specificallycatchValidationError |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;
}
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
abstractclassDataSource {
abstractfnLoad() : stringthrowsFileError | NetworkError;
}
classFileDataSourceextendsDataSource {
// ✓ VALID: the same error signature.overridefnLoad() : stringthrowsFileError | NetworkError {
// ...
}
}
classLocalFileSourceextendsDataSource {
// ✓ VALID: narrowing. Reading a local file cannot raise a NetworkError, and// saying so is safe for every caller.overridefnLoad() : stringthrowsFileError {
// ...
}
// ✗ 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.
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.
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.
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.
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.
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.
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.
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
jsonconfig = { database: { host: "localhost", port: 5432 } };
// This chain is safe. Since 'connection' does not exist,// the expression evaluates to a json value containing null.jsonpoolSize = 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;
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
jsonsettings = { 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 keystringid = "user_123";
settings[id] = { name: "guest" };
// To remove a property, call DeleteProperty on the value.
settings.DeleteProperty("fontSize");
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
jsonconfig = { theme: "dark", limits: { retries: 3 } };
config.Freeze();
boollocked = config.IsFrozen(); // truebooldeep = config["limits"].IsFrozen(); // true, because Freeze recursesjsoneditable = 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
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.
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 (int8…uint64, float, double, char). Any other target is a compile error.
Wax
jsondata = { name: "Alex", age: 30 };
stringname = data.name as! string; // "Alex"intage = data.age as! int; // 30int? maybeAge = data.age as? int; // 30int? 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
jsondata = { status: 2 };
intraw = data.status as! int; // json -> backing integerStatuss = raw as! Status; // integer -> enum, checked against the casesStatus? maybe = raw as? Status; // null if 2 is not a declared case
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
jsonuser1 = { name: "Alex", score: 100 };
jsonuser2 = { score: 100, name: "Alex" };
jsonuser3 = user1; // user3 is a reference to user1boolrefEqual = (user1 == user3); // true (both point to the same object)boolrefNotEqual = (user1 == user2); // false (different objects in memory)boolvalEqual = user1.DeepEquals(user2); // true (deep equality, key order ignored)
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.
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 stringstringtext = """{ "name": "API Data", "value": 42 }""";
// Parsing must be wrapped in a try-catch expressionjsonparsed = tryjson.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"stringserialized = parsed.ToString();
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.
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.
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.
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.
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.
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.
The Wax memory model provides several compile-time and runtime safety guarantees:
No Null Dereferences: Null safety is enforced by the type system.
No Buffer Overflows: All array access is bounds-checked.
No Use-After-Free: The garbage collector ensures objects are not deallocated while still in use.
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.
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.
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
fnFirst(Span<int32>? maybe) : int32 {
if (maybe) |view| { return view[0]; }
return0;
}
fnWiden(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.
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
varvalues = newint32[] { 10, 20, 30, 40 };
scopedSpan<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
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.
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.
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
vartmp = 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
varnames = new stackalloc string[] { "ann", "bea" };
varobjs = new stackalloc Widget[10] |i| { yieldnewWidget(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.
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.
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.intsum = 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 = newRange<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.
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 = newint32[] { 1, 2, 3, 4, 5 };
intsum = arr->Filter(|x| => x > 0)->Sum();
List<int32> list = newList<int32>();
intcount = list->Filter(|x| => x > 10)->Count();
// Spans can be used directly.Span<int32> span = arr.ToSpan();
intfirst = span->First() ?? 0;
// ReadOnlySpan sources can also be used directly.ReadOnlySpan<int32> readOnly = arr.ToReadOnlySpan();
intfound = 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.
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
structCounter {
int32 next;
int32 max;
int32 current;
constructor(int32max) {
this.max = max;
}
publicfnMoveNext() : bool {
if (next >= max) returnfalse;
current = next;
next = next + 1;
returntrue;
}
publicfnCurrent() : int32 {
return current;
}
}
int32 sum = newCounter(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:
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 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 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.)
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 SortedStableDescendingByare 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 sequencesint32? first = numbers->Filter(|x| => x > 0)->First();
int32? smallest = numbers->Min();
double? average = numbers->Average();
// OrElse variants provide a fallbackint32first = numbers->Filter(|x| => x > 0)->FirstOrElse(0);
int32smallest = numbers->MinOrElse(0);
doubleaverage = numbers->AverageOrElse(0.0);
// Force-unwrap when you know the sequence is non-emptyint32first = numbers->First()!;
// Null-coalesceint32first = numbers->First() ?? 0;
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);
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:
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| => ...`
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.
importRangefrom Wax;
newRange<int32>(0, 10).Step(2)->Sum(); // 0 + 2 + 4 + 6 + 8 = 20newRange<int32>(1, 10).Step(3)->ToList(); // [1, 4, 7]newRange<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:
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 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.
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
vardata = GetSomeData();
boolneedsSorting = CheckIfSortingIsNeeded();
// Each arm is a complete pipeline, ending in its own sink.varresults = 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.
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 concisevarvalidNumbers = stringInputs
->FilterMap(|s| => tryParseInt(s) catch { yieldnull; })
->ToList();
// Equivalent using Map + NotNullvar validNumbers = stringInputs
->Map(|s| => tryParseInt(s) catch { yieldnull; })
->NotNull()
->ToList();
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.structEmployeeRecord {
string name;
int age;
string department;
constructor(stringname, intage, stringdepartment) {
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.fnParseRecord(stringline) : EmployeeRecord? {
varparts = line.Split(",");
if (parts.size != 3) {
returnnull; // Invalid format
}
// Use a try-catch expression for failable parsing.int? age = tryParseInt(parts[1].Trim()) catch { yieldnull; };
if (age == null) {
returnnull; // Invalid age
}
returnnewEmployeeRecord(
.name = parts[0].Trim(),
.age = age!,
.department = parts[2].Trim()
);
}
// 3. Process the file using an iterator pipeline.fnGetEngineeringStaff(stringfilePath) : List<EmployeeRecord> {
returnFileLines(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);
}
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.
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
varsum = 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:varsum = 0;
for (inti = 0; i < numbers.size; i++) {
varitem = numbers[i];
// The Filter operation becomes an 'if' check.if (!(item > 0)) {
continue;
}
// The Map operation becomes an inline transformation.vartransformedItem = 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.
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 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.
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.
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 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.
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.statefnRenderUI() {
// ... stateful logic here ...
}
// A stateful lambda stored on a class field: the "component owns its state" pattern.classMyComponent {
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).
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
statefnCounterButton() {
// `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.stateint 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.
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.
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.
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.
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.
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.statestring data = tryReadFile("config.txt") catch |err| {
Log(err);
yield"default_data"; // Provide a valid fallback value.
};
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.stateforeach (userList) |user| key user.id {
// Each user gets their own independent `isExpanded` state, retained// across invocations while `user.id` keeps appearing in `userList`.statebool 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.
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.
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
statefnPanel() : fn<() : int32> {
if (visible) {
stateint32 clicks = 0;
// The scope's state outlives the scope through this closure.return () => { clicks++; return clicks; };
}
return () => { return0; };
}
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
statefnCounter() : fn<() : int32> {
if (active) {
stateint32 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.
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.
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.fnButton(stringlabel, fn<() : void> onClick) {
if (IsClicked(label)) {
onClick();
}
}
// A regular function can compose UI elements that use stateful lambdas.fnMyUI() {
// The state for this button's clickCount is tied to this specific line of code.Button("Click Me!", state () => {
stateint clickCount = 0;
clickCount++;
Log("Button clicked ${clickCount} times.");
});
}
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.
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.
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.hostfnLog(stringmessage);
// A failable host function: the host can signal errors via WxError.hostfnGetPrice(int32itemId) : floatthrows;
// Static classes group related host functions without changing the boundary model.staticclassConsole {
hostfnWrite(stringmessage);
}
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.
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?fnLogAnalytics(string eventName, json data);
if (LogAnalytics) |logFn| {
logFn("player_action", { timestamp: GetCurrentTime() });
}
// Equivalent one-shot conditional invocation.LogAnalytics?.("heartbeat", {});
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:
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.apifnGetVersion() : string {
return"1.0.0";
}
staticclassConsole {
apifnWriteLine(stringmessage) {
// ...
}
}
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);
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.publicapifnStartGame();
// This API is intended for internal testing tools or other modules// within the same project, but not for external consumers.internalapifnRunDiagnostics();
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:
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 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.
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;
hostchannelNet {
int32 threshold; // value channel -> boundary name Net_thresholdint32? limit; // nullable value channelReadOnlySpan<int32> seeds; // array-payload value channeleventint32 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.
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.
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.
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:
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.
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.
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.
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.
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.
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:
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
apichannelTelemetry {
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.
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.
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.
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.
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.apifnGetPlayerHP(int32playerId) : 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.
Marking an api fnreadonly 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.readonlyapifnGetScore() : int32 { return gScore; }
// Fresh scratch may be mutated and discarded; output is the only write that escapes.readonlyapifnShade(WriteOnlySpan<uint8> output) : bool {
uint8[] scratch = newuint8[output.size];
foreach (scratch) |_, i| { scratch[i] = (i & 255) asuint8; }
foreach (scratch) |value, i| { output[i] = value; }
returntrue;
}
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.
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.
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 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.
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.
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:
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.
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.
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.
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:
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.
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.
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 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.
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.
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.
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 namedsecret; a struct, class, or generic parameter may not).
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.
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.
Reading content out: legal, and it diverges on replay by design#
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.
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.
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.