Guide 9 of 53
Data Structures
Structs, enums, traits, arrays and maps.
On this page
Structs¶
Structs define custom data types with named fields:
struct Point {
x: f64,
y: f64,
}
let p = Point { x: 1.0, y: 2.0 }
print(p.x) // 1.0Field Shorthand¶
When the variable name matches the field name:
let x = 3.0
let y = 4.0
let p = Point { x, y } // same as Point { x: x, y: y }Contextual Construction¶
When an annotation, function parameter or return type already specifies one
nominal struct, .{ ... } constructs that exact type:
let p: Point = .{ x: 1.0, y: 2.0 }
fn origin() -> Point { .{ x: 0.0, y: 0.0 } }
fn draw(point: Point) { print(point.x) }
draw(.{ x: 3.0, y: 4.0 })Puns, spread, defaults and field visibility follow ordinary struct literals.
Nested fields, typed collections and aliases supply their own exact expected
types. Generic arguments remain concrete. Initializers evaluate once in source
order; a user-defined new does not intercept this construction.
The compiler never chooses a struct by matching field names. A standalone
let p = .{ x: 1.0, y: 2.0 } reports TE161; write Point
or add a nominal annotation. Optional, union, map and unresolved generic
targets still need an unambiguous construction type.
Anonymous Records¶
Without a nominal target, .{ ... } infers a structural record. Each field
keeps its own type, including nested records, and field puns use local bindings.
let count = 3
let summary = .{ count, total: 12 }
let typed: { count: int, total: int } = .{ count, total: 15 }
let next = summary with { total: 18 }
print(summary.total, next.total)A structural spread copies an anonymous record: .{ extra: true, ..summary }.
Explicit fields replace spread values. Nominal values require their own
constructor or an explicit projection; structural construction never copies
hidden nominal fields. Copying a linear resource remains an error.
Default Field Values¶
struct Config {
host: str,
port: int,
debug: bool,
}
let cfg = Config { host: "localhost", port: 8080, debug: false }Implementations (impl)¶
Add methods to a struct:
impl Point {
// Method that takes self
fn distance(self) -> f64 {
(self.x ** 2.0 + self.y ** 2.0).sqrt()
}
// Associated function (no self) — constructor
fn new(x: f64, y: f64) -> Point {
Point { x, y }
}
// Method with additional parameters
fn translate(self, dx: f64, dy: f64) -> Point {
Point { x: self.x + dx, y: self.y + dy }
}
}
let p = Point::new(3.0, 4.0)
print(p.distance()) // 5.0Enums¶
Enums define types with named variants:
// Simple variants
enum Color {
Red,
Green,
Blue,
}
// Variants with data (tuple-style)
enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
// Variants with named fields
enum Event {
Click { x: int, y: int },
KeyPress(str),
Quit,
}Creating Enum Values¶
let c = Color::Red
let s = Shape::Circle(5.0)
let e = Event::Click { x: 10, y: 20 }Using Enums with Match¶
fn area(shape: Shape) -> f64 {
match shape {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rectangle(w, h) => w * h,
}
}Enums with Implementations¶
impl Shape {
fn describe(self) -> str {
match self {
Shape::Circle(r) => "Circle with radius {r}",
Shape::Rectangle(w, h) => "Rectangle {w}x{h}",
}
}
}Traits¶
Traits define shared behavior (interfaces):
trait Displayable {
fn display(self) -> str
// Default implementation
fn debug(self) -> str {
"debug: {self.display()}"
}
}Implementing Traits¶
impl Displayable for Point {
fn display(self) -> str {
"({self.x}, {self.y})"
}
}
let p = Point { x: 1.0, y: 2.0 }
print(p.display()) // "(1.0, 2.0)"
print(p.debug()) // "debug: (1.0, 2.0)"Trait Bounds (Generics)¶
fn print_all(items: [T]) where T: Displayable {
for item in items {
print(item.display())
}
}Arrays¶
Arrays are ordered, 0-indexed collections:
let numbers = [1, 2, 3, 4, 5]
print(numbers[0]) // 1
print(numbers[4]) // 5
// Array operations (via standard library)
numbers |> Array.push(6)
numbers |> Array.map(|x| x * 2)
numbers |> Array.filter(|x| x > 3)See Standard Library for full Array API.
Maps¶
Maps are key-value collections:
let scores = { "Alice": 95, "Bob": 87 }
print(scores["Alice"]) // 95See Standard Library for full Map API.
Modules¶
Declaring Modules¶
// In math/vectors.zolo
mod math::vectors
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
pub fn dot(a: Vec2, b: Vec2) -> f64 {
a.x * b.x + a.y * b.y
}Importing¶
use math::vectors::{Vec2, dot}
let v = Vec2 { x: 1.0, y: 0.0 }Visibility¶
pubmakes items visible outside the module- Without
pub, items are module-private
Immutable nested record updates¶
with returns a fresh record of the same type and retains its methods:
let next = user with { name: "Ana", address.city: "Recife" }The original user and address stay unchanged. The base is evaluated once, and
the root plus every changed record prefix is copied before any new value is
evaluated. New values then run once, in written order. Sibling paths share their
copied prefix. Unchanged referenced values remain shared: this is a shallow
update of the changed paths, not a recursive clone. Constructors, clone methods
and field setters do not run. An empty user with {} makes a fresh shallow
copy; user with { a: 1 } with { b: 2 } updates left to right.
Paths must refer to visible stored fields, including fields promoted by using. Duplicate and overlapping parent/child paths are errors; siblings are valid. The result keeps the exact nominal or anonymous type, including a type alias. Unknown values, unions and optionals require a type annotation, match, cast or unwrap first. Records containing linear resources cannot be copied this way. See TE160.
An update binds below pipes. Existing handle body with handler syntax
retains its separator; parenthesize an update used as a handle body. Use an
explicit lambda when updating a collection element:
users.map(|user| user with { active: true }).
Compile-time evaluation applies the same update checks before materializing the result. If it cannot prove the base record type and its defining module, it reports TE160. An explicit record annotation may supply that proof.
Conditional and Comprehension Array Entries¶
Values, filters, loops and spreads can share one array:
let menu = [
"Home",
if admin => "Admin",
for page in pages => if page.enabled => page.title,
]A false condition emits no item. A successful condition emits its value,
including nil, which occupies a position. Entries run eagerly from left to
right. Each loop evaluates its iterable once and evaluates its emission before
requesting the next element. Single bindings receive the element of a sequence
or iterator, and the key of a map. Two names receive zero-based position and
element for sequences/iterators, or key and value for maps. String iteration
follows str.chars().
Conditions support the same lazy comma clauses as if and guard:
[if let user = selected, user.active => user.name]. Successful bindings are
visible to later clauses and the emitted entry. Loop bindings are visible only
inside that loop's entry; neither kind leaks into sibling entries.
Entry loops nest and flatten their emitted values:
[for row in rows => for cell in row => cell]. An ordinary nested array remains
one value; use an explicit spread to insert all its elements. Ordinary
if condition { value } else { other } expressions retain their existing
meaning when used as array values.
An entry's for is a real loop. continue omits its current emission;
break ends that loop and later array entries still run. Labels follow normal
loop rules. return, ? and deferred cleanup retain the surrounding
function's lexical behavior. Linear resources cannot be stored in the result.
Explicit map and record types¶
Map types share the delimiter of map values. Bare braces always describe a record's named fields, independent of capitalization or primitive spelling.
let counts: #{str: int} = #{ ok: 2 }
let row: { str: int, User: str, type: bool } = #{ str: 7, User: "Ada", type: true }
let nested: #{str: [#{int: str}]} = #{ first: [#{ [1]: "one" }] }Empty braces in a type annotation mean an empty record. Use #{K: V} or the
existing Map<K, V> spelling to annotate a map. Type annotations change no map
storage or runtime behavior. Keys and values retain their existing restrictions
for each backend.
Reusable typed field paths¶
field names a stored field without reading an object:
let city = field User::address.city
let current = city.get(user)
let moved = city.set(user, "Recife")The path has type FieldPath<User, str>. Its root and every segment resolve to
real declared fields, including aliases, imported types and substituted generic
fields. The path value is immutable. Its get and set members are ordinary
functions, so users.map(city.get) reads the same field from each user.
Creating a path runs no constructor, getter or setter. Calling get evaluates
the object once and reads the path once. Calling set evaluates the object and
new value once, in written order, and returns a fresh root. It copies only the
records along the changed path using the same rules as user with { ... };
unaffected referenced values stay shared. The original records retain their
stored fields. The result retains the root's nominal type and methods.
Paths require visible stored fields. Unknown or dynamic roots, missing/private members, computed accessors without a supported path, and records containing linear resources receive TE167. A path cannot structurally update a validated scalar newtype or bypass its checked constructor. Updating a normal record's newtype leaf accepts an already valid leaf value.
An annotation, cast, dynamic value or writable record with members called
get and set cannot establish a field-path type. The callable members have
the actual root/leaf interface; the path itself is not an implicit getter.
Parenthesize a direct path before extracting its callable member:
(field User::address.city).get.
See the runnable example.
Field paths require a complete storage proof. Unresolved type parameters and
stored functions or handlers with unproved captures are rejected, along with
linear contents. Runtime type probes and checked casts into types containing
FieldPath are unsupported: a table shape cannot prove its root/leaf interface.
Compile-time field-path execution is also unsupported. These cases produce a
diagnostic instead of manufacturing a path from an annotation or dynamic value.
Related examples and reference¶
DOCS / FEEDBACK
Did this page leave a question?
Tell us where the explanation lost you. Documentation is part of the language experience.