Skip to content

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.0

Field 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.0

Enums

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"])  // 95

See 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

  • pub makes 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.

See array entries example.

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.

DOCS / FEEDBACK

Did this page leave a question?

Tell us where the explanation lost you. Documentation is part of the language experience.

Global index

Find your way through Zolo

Try an idea

Start here

9 results

9 results

enespt-br