Skip to content

Bindings

Declare inferred or annotated locals, opt into mutation, destructure values, and shadow earlier names.

Updated View as Markdown

A binding gives a value a local name. Atoll spells inference with := and an explicit type with : plus =. Bindings are immutable unless they begin with mut.

fn f(): void {
    name := "Ada"            // inferred, immutable
    attempts: int = 3        // annotated, immutable
    mut total := 0           // inferred, mutable
    total += attempts
    println("$name ${total}")
}

The six forms

Form Type Initial value Reassignable
name := value inferred required no
mut name := value inferred required yes
name: T = value T explicit no
mut name: T = value T explicit yes
name: T T type default no
mut name: T T type default yes
fn f(): int {
    count := 0
    label: string = "worker"
    mut total: int = 0
    mut seen: []int

    total = total + count
    seen.add(total)
    println(label)
    return seen.len()
}

:= always introduces a new binding. = either initializes an annotated binding or assigns to an existing mutable place. Keeping the two spellings distinct is what lets a reader tell, at a glance, whether a line creates a name or overwrites one.

Assigning to a name that was never declared is an unresolved-name error:

fn f(): void {
    count = 0
    println("${count}")
}

And an annotated binding must use =, not :=:

fn f(): void {
    count: int := 0
    println("${count}")
}

The diagnostic is ATOLL1032: Use = after a type annotation, not :=.

Immutable by default

An immutable binding cannot be reassigned.

fn f(): void {
    name := "Ada"
    name = "Grace"
    println(name)
}

Immutability is a property of the binding, and it also blocks mutation through the binding. A struct field cannot be written via an immutable local even though the field itself carries no modifier:

struct Counter { n: int }

fn f(): int {
    c := Counter { n: 0 }
    c.n = 1
    return c.n
}

Both diagnostics are ATOLL2001. Adding mut to the binding fixes both:

struct Counter { n: int }

fn f(): int {
    mut c := Counter { n: 0 }
    c.n = 1
    c.n += 1
    return c.n
}

Mutable bindings

mut marks a local you intend to update — accumulators, cursors, and buffers.

fn sum(values: []int): int {
    mut total := 0
    for value in values {
        total += value
    }
    return total
}

fn longest(words: []string): string {
    mut best := ""
    for word in words {
        if word.len() > best.len() {
            best = word
        }
    }
    return best
}

Parameters take the same modifier:

fn increment(mut value: int): int {
    value += 1
    return value
}

A for binding can be mut too, and then writes go back into the source collection:

fn doubled(): int {
    mut xs := [1, 2, 3]
    for mut v in xs {
        v = v * 2
    }
    mut total := 0
    for v in xs {
        total += v
    }
    return total
}

Inference and annotations

An initializer usually says everything the compiler needs:

fn f(): void {
    enabled := true
    port := 8080
    message := "ready"
    coordinates := (12, 34)
    lookup := Map.new[string, int]()
    println("${enabled} ${port} ${message} ${coordinates.0} ${lookup.len()}")
}

Annotate when the value alone cannot pick the type, or when the type is part of the contract you want a reader to see:

fn f(): void {
    port: u16 = 8080                  // narrower than the default `int`
    names: []string = []              // nothing to infer the element from
    result: int? = None               // nothing to infer the payload from
    ratio: f32 = 0.5                  // narrower than the default `float`
    println("${port} ${names.len()} ${result ?? 0} ${ratio}")
}

Scalar types are lowercase (int, float, bool, char, byte, string); composite and user-defined types are PascalCase (List, Map, Option, and your own structs and enums).

Declare now, assign later

An annotated binding may omit its initializer. The type supplies a default, and the binding is ready to be assigned in whichever branch computes it.

fn pick(ready: bool): int {
    mut result: int
    if ready {
        result = 1
    } else {
        result = 2
    }
    return result
}
Type shape Default
integer and floating-point types numeric zero
bool false
char '\0'
string empty string
List, Map, Set empty collection
fixed array [N]T N copies of T’s default
struct field-by-field defaults

The defaults are real, so a collection declared this way is usable immediately:

struct Point { x: int, y: int }

fn f(): void {
    mut names: []string
    mut counts: Map[string, int]
    mut origin: Point

    names.add("first")
    counts.put("first", 1)

    println("${names.len()} ${counts.len()} ${origin.x}")
}

Not every type has an unambiguous default — an enum needs a chosen variant and a resource handle needs a real acquisition. Prefer an explicit initializer when the default would hide a decision that matters.

This form requires the annotation. An inferred binding has nothing to infer from, so value := with no right-hand side is not a form.

Destructuring

A pattern on the left introduces several names at once.

fn divide(n: int, d: int): (int, int) {
    return (n / d, n % d)
}

fn f(): void {
    (quotient, remainder) := divide(17, 5)
    println("${quotient} rem ${remainder}")
}

The outer parentheses are optional for a tuple:

fn f(): void {
    first, second := (1, 2)
    println("${first} ${second}")
}

mut applies to every name the pattern introduces:

fn f(): int {
    mut (left, right) := (10, 20)
    left += 1
    right += 1
    return left + right
}

Struct patterns work the same way, and they nest:

struct Point { x: int, y: int }
struct Line { a: Point, b: Point }

fn f(): int {
    Line { a: Point { x, y }, b } := Line {
        a: Point { x: 1, y: 2 },
        b: Point { x: 3, y: 4 },
    }
    return x + y + b.x
}

Destructuring in a binding is only legal for an irrefutable pattern — one that always matches. A list pattern, an enum variant, or an Option variant can fail, so those go through match or the else form below. The complete pattern language is in Patterns.

Refutable bindings with else

When a pattern might not match, attach an else block. The block must diverge — return, error, break, or continue.

fn f(maybe: int?): int {
    Some(value) := maybe else {
        return 0
    }
    return value * 2
}

The names the pattern introduces are in scope for the rest of the block, so this reads as a guard clause rather than a nested if:

error AuthError { Denied }

fn authenticate(request: int): (int, string) ! AuthError {
    if request < 0 { error Denied }
    return (request, "token")
}

fn handle(request: int): string {
    Ok((user, token)) := authenticate(request) else {
        return "unauthorized"
    }
    return "user ${user} with ${token}"
}

The initializer runs exactly once, before matching. On success every pattern name is initialized together; on failure none of them enter scope and the else branch must leave. Use match when both outcomes should continue with a value — binding-else is for the case whose failure path cannot fall through.

Shadowing and scope

A later declaration may reuse an earlier name. Shadowing creates a new binding; the old one is untouched, and the type may change.

fn f(): int {
    value := "42"
    value := value.to_int() ?? 0
    return value + 1
}

The initializer of a shadowing declaration sees the previous binding — which is what makes the “clean up in stages” idiom safe:

fn normalize(raw: string): string {
    input := raw.trim()
    input := input.to_lower_case()
    input := input.replace(" ", "_")
    return input
}

Each binding is visible from its declaration to the end of its enclosing block. Atoll does not hoist locals, so a name is unusable above its declaration, and an inner block’s shadow disappears at the closing brace:

fn f(): int {
    x := 1
    {
        x := 2
        println("inner ${x}")
    }
    return x        // still 1
}

Bindings versus places

Assignment needs two things: a mutable access path, and a type-compatible value. A mut local is a place; so is a field or index reached through one.

struct Point { x: int, y: int }

fn f(): int {
    mut point := Point { x: 1, y: 2 }
    mut grid := [10, 20, 30]

    point.x = 3
    point.y += 4
    grid[0] = 99

    return point.x + point.y + (grid[0] ?? 0)
}

Binding immutability is not a deep freeze. When a type has reference semantics, an immutable binding still names shared storage — what mut controls is whether this name can be repointed and whether writes may travel through it. See Values for copying and aliasing.

Discarding a value

_ is a wildcard that accepts a value without naming it. Use it when a call is made for its effect and the result is genuinely unwanted.

fn compute(): int { return 41 }

fn f(): void {
    _ := compute()
    println("done")
}

Discarding suppresses nothing else: effects still happen, and a fallible call still has to be handled.

Naming

Locals and parameters use snake_case.

fn reserve(seat_count: int, hold_minutes: int): bool {
    max_hold := 30
    return seat_count > 0 && hold_minutes <= max_hold
}

See the naming conventions for the complete table.

Composed example

struct Reading {
    sensor: string
    value: float
}

error FeedError { Empty }

fn parse_reading(line: string): Reading ! FeedError {
    parts := line.split(":")
    if parts.len() < 2 { error Empty }
    sensor := parts[0] ?? ""
    value := (parts[1] ?? "").to_float() ?? 0.0
    return Reading { sensor, value }
}

fn hottest(lines: []string): string {
    mut best: string
    mut best_value := 0.0
    mut parsed := 0

    for line in lines {
        Ok(reading) := parse_reading(line) else {
            continue
        }
        parsed += 1
        if reading.value > best_value {
            best = reading.sensor
            best_value = reading.value
        }
    }

    if parsed == 0 {
        return "no readings"
    }
    return "${best} at ${best_value} (${parsed} parsed)"
}

fn main(): void {
    println(hottest(["cpu:71.5", "malformed", "gpu:83.25", "mem:44.0"]))
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close