Records
Records are shallowly immutable, structural types that group a fixed set of named fields. They provide lightweight data modeling with value semantics, compile-time field checking, and structural subtyping without class ceremony.
Records are in transition
Records are under active development as Zena moves toward true value types and row polymorphism:
- Value-type transition: While records currently use WebAssembly GC structs
with fat-pointer dispatch for width subtyping, identity has already been severed
(
===is a compile error) so that allocations can be sunk, exploded, or copied without breaking observable semantics. - Row polymorphism roadmap: Future work will introduce closed types by default,
monomorphized open polymorphism (
{x: i32, ...R}), and dense struct-of-arrays (SoA) layout. - Immutability checking: Record fields are semantically immutable, but the
current type checker contains a bug where field assignments (such as
p.x = 10) are not yet rejected. They will be strictly rejected in an upcoming compiler update.
Record literals ​
A record literal consists of comma-separated name: value pairs enclosed in
curly braces ({}):
let origin = {x: 0, y: 0};
let user = {id: 42, name: "Alice", active: true};
Property shorthand ​
When an expression is a variable whose name matches the field name, you can omit
the : value pair:
let x = 10;
let y = 20;
let point = {x, y}; // Equivalent to {x: x, y: y}
Property access ​
Access record fields using standard dot syntax:
let x = point.x;
let y = point.y;
Destructuring ​
Extract fields into local bindings using record destructuring patterns:
let {x, y} = point;
Destructuring can also rename fields during binding using as:
let {x as startX, y as startY} = point;
Record types ​
A record type declares the expected field names and types within curly braces, separated by commas or semicolons:
type Point = {x: i32, y: i32};
type User = {
id: i32,
name: String,
active: boolean,
};
Shallow immutability ​
Record fields are immutable. Reassigning a record field is intended to produce a compile-time error:
let p = {x: 1, y: 2};
// Intended compile error:
// p.x = 10;
Checker bug: assignments not rejected
Immutability checking currently contains a bug: the type checker does not yet
reject direct assignments to record fields (such as p.x = 10). Code should
treat record fields as strictly read-only; field assignments will be rejected in
a future compiler release.
Immutability is shallow: if a field holds a reference to a mutable object (such as an array or class instance), the referenced object can still be mutated, but the record field itself cannot be reassigned:
let container = {items: new Array<i32>()};
container.items.push(42); // Permitted: mutating referenced object
Optional fields ​
A field marked with a trailing ? is optional:
type RequestOptions = {
url: String,
timeout?: i32,
retries?: i32,
};
Presence vs nullability ​
In Zena, optional fields represent presence, not nullability:
- A field marked
timeout?: i32is either present (containing ani32) or absent (omitted entirely). - It is not nullable. An explicit
{timeout: 0}is present—the value0does not trigger a default. - For a field that is always present but may hold a null value, write
field: T | null. For optional values wrapped in an object, useOption<T>fromzena:core.
Direct access restriction ​
Accessing an optional field directly using dot syntax is a compile-time error:
let getTimeout = (opts: RequestOptions): i32 => {
return opts.timeout;
Error:
};
Because an optional field may be absent, code must inspect presence explicitly through pattern matching or destructure with a default value.
Defaults in destructuring ​
Destructuring an optional field in a variable declaration requires providing a default value for absent cases:
let send = (opts: RequestOptions): void => {
let {url, timeout = 5000, retries = 3} = opts;
// timeout and retries are guaranteed to be i32 values
};
send({url: "/api"}); // timeout = 5000, retries = 3
send({url: "/api", timeout: 200}); // timeout = 200, retries = 3
Presence patterns ​
In refutable contexts (if (let ...) or match), an optional field pattern
without a default tests for presence:
if (let {timeout} = opts) {
// Branch taken only if timeout is present; binds timeout as i32
console.log(`Custom timeout: ${timeout}`);
}
Absence patterns ​
Prefixing an optional field name with an exclamation mark (!) tests for
absence. The pattern matches only when the field was not provided, and binds
no variable:
let classify = (opts: RequestOptions): String => match (opts) {
case {timeout, retries}: "both present"
case {timeout, !retries}: "timeout only"
case {!timeout, retries}: "retries only"
case {!timeout, !retries}: "neither present"
case _: "other"
};
Testing absence on a required field produces a compile-time error, as required fields are never absent.
Spread ​
The spread operator (...) unpacks fields from an existing record into a new
record literal.
Copying and merging ​
Use spread to combine multiple records or add fields:
let base = {x: 10, y: 20};
let point3d = {...base, z: 30}; // {x: 10, y: 20, z: 30}
let size = {width: 100, height: 200};
let entity = {...base, ...size}; // {x: 10, y: 20, width: 100, height: 200}
Override precedence ​
When fields with the same name appear multiple times, the last definition wins:
let original = {x: 10, y: 20};
let updated = {...original, x: 99}; // {x: 99, y: 20}
let unchanged = {x: 99, ...original}; // {x: 10, y: 20}
Spreading class instances ​
Public fields of a class instance can be spread into a record literal:
class Point {
x: i32;
y: i32;
new(this.x, this.y);
}
let pt = new Point(10, 20);
let recordPoint = {...pt, label: "origin"}; // {x: 10, y: 20, label: "origin"}
Presence propagation ​
Spreading preserves the presence state of optional fields. If an optional field is present in the source record, it remains present in the target; if it was absent, it remains absent unless explicitly supplied:
type Opts = {timeout?: i32, retries?: i32};
let base: Opts = {timeout: 5};
let merged: Opts = {...base, retries: 2}; // timeout stays present (5), retries is present (2)
Structural typing ​
Records are typed structurally. Two record types with identical field names and types are the exact same type, regardless of how or where they are defined.
Width subtyping ​
Zena supports width subtyping (adaptation): a record with additional fields is a subtype of a record type that requires fewer fields:
type Point2D = {x: i32, y: i32};
type Point3D = {x: i32, y: i32, z: i32};
let getLength = (p: Point2D): i32 => p.x + p.y;
let p3: Point3D = {x: 3, y: 4, z: 5};
let result = getLength(p3); // Permitted: Point3D conforms to Point2D
Value equality ​
Records have value semantics. The == and != operators compare records by
their structural contents:
let a = {x: 1, y: 2};
let b = {x: 1, y: 2};
let areEqual = a == b; // true
let areDifferent = a != {x: 3, y: 4}; // true
Two records are equal if their fields match and each corresponding field value
is equal according to its own == operator.
Identity rejection ​
Because records and tuples are value types, reference identity is permanently
unobservable. Using the reference identity operators === or !== on record
operands produces a compile-time error:
let a = {x: 1, y: 2};
let b = {x: 1, y: 2};
let r1 = a === b;
Error:
let r2 = a !== b;
Error:
The prohibition of === ensures that the compiler is free to copy records,
dissolve them into scalar arguments, or re-layout their storage without
altering program behavior. Records cannot be used in identity-keyed structures
such as identity maps or weak references.
Representation ​
In the current compiler implementation, records stored on the heap compile to canonicalized WebAssembly GC structs. The compiler maintains a global registry of shapes used across the program:
;; Canonicalized struct for {x: i32, y: i32}
(type $Record_x_y (struct
(field $x i32)
(field $y i32)
))
Parameter explosion ​
When a function accepts a record parameter that does not escape to the heap, the compiler can "explode" the record into individual scalar parameters in the WebAssembly function signature:
let draw = (opts: {x: i32, y: i32}) => opts.x + opts.y;
Compiles to a WebAssembly function taking scalar arguments directly:
(func $draw (param $x i32) (param $y i32) (result i32)
local.get $x
local.get $y
i32.add
)
This optimization eliminates heap allocations for common patterns like named arguments and multi-field function inputs.