Skip to content

Inference

What the compiler works out for you, what it cannot, and exactly where an annotation becomes mandatory.

Updated View as Markdown

Every expression in Atoll has a type known before lowering. Inference removes redundant annotations; it never defers a type error to runtime.

fn basics(): void {
    answer := 42        // int
    name := "Atoll"     // string
    enabled := true     // bool
    ratio := 0.5        // float
    letter := 'A'       // char

    println("${answer} ${name} ${enabled} ${ratio} ${letter}")
}

Inference is bidirectional. Information flows outward from an initializer into the binding, and inward from an expected type into an expression that has not committed yet.

Expected types flow inward

An annotation, a parameter, a return position, or a field declaration supplies an expected type, and a literal is checked directly against it.

fn accepts_byte(value: u8): int => value.to_int()

fn call_site(): int {
    port: u16 = 8080
    names: []string = []
    selected: string? = None

    return accepts_byte(200) + port.to_int() + names.len()
         + (if selected.is_some() { 1 } else { 0 })
}

200 is checked as u8 at the call site. No int is created and then narrowed — that is why an out-of-range literal is a compile error rather than a silent wrap.

Struct fields are expected types too:

struct Server {
    port: u16
    backlog: u32
    hostname: string
}

fn default_server(): Server {
    // 8080 and 128 are checked as u16 and u32 directly.
    return Server { port: 8080, backlog: 128, hostname: "localhost" }
}

Inference stops at the binding

This is the single most common surprise. Once a value is bound, its type is fixed; the expected type at a later use cannot reach back and retype it.

fn accepts_byte(value: u8): int => value.to_int()

fn indirect(): int {
    v := 200        // committed as `int`
    return accepts_byte(v)
}

Fix it by annotating the binding, or by converting at the use site.

fn accepts_byte(value: u8): int => value.to_int()

fn annotated(): int {
    v: u8 = 200
    return accepts_byte(v)
}

fn converted(): int {
    v := 200
    return accepts_byte(v as u8)
}

The same rule explains why there is no implicit numeric widening in Atoll. An i16 binding stays i16:

fn widening(): int {
    small: i16 = 12
    wide: int = small
    return wide
}

Call the conversion method — small.to_int() — or annotate the source binding as int in the first place. See Conversions.

Numeric literals

An unsuffixed integer literal is int and an unsuffixed floating literal is float, unless an expected type or a suffix says otherwise.

fn literals(): void {
    ordinary := 12          // int
    ratio := 0.5            // float

    small: u8 = 12          // context
    mask := 0xFFu16         // suffix
    precise := 1.25f32      // suffix

    println("${ordinary} ${ratio} ${small} ${mask} ${precise}")
}

Integer and floating literals are different categories, and inference will not bridge them:

fn category(): float {
    ratio: float = 2
    return ratio
}

Empty collections

An empty list literal carries no element type. The checker will take that type from anywhere inside the function — a later element operation, or the return position — but it must come from somewhere.

fn unconstrained(): int {
    mut values := []
    return values.len()
}

That reports ATOLL2002: empty list literal requires a type annotation.

Any of these resolve it:

fn from_annotation(): int {
    names: []string = []
    return names.len()
}

fn from_a_later_add(): int {
    mut values := []
    values.add(42)          // element type becomes int
    return values.len()
}

fn from_the_return_type(): string? {
    mut candidates := []
    return candidates.first()   // Option[string] flows back into the element
}

Maps and sets are constructed rather than written as an empty literal — {} is an empty block, not an empty map, so it types as void:

struct User { id: int }

fn empty_map(): int {
    by_name: Map[string, User] = {}
    return by_name.len()
}

Use the explicit constructors, which take their type arguments in square brackets:

struct User { id: int }

fn build(): int {
    mut by_name := Map.new[string, User]()
    by_name.put("ada", User { id: 1 })

    mut seen := Set.new[string]()
    seen.add("ada")

    return by_name.len() + seen.len()
}

Branch joins

if and match produce a value, and the normally completing arms are joined.

fn tier(score: int): string {
    label := if score > 90 {
        "gold"
    } else if score > 50 {
        "silver"
    } else {
        "bronze"
    }
    return label
}

An arm that leaves the function has type ! and contributes nothing to the join, so it can sit beside an arm that produces a value:

fn parse_or_bail(text: string): int {
    value := match text.to_int() {
        Some(v) => v
        None => return -1     // type `!`
    }
    return value * 2
}

Return inference

Parameters always carry declared types. Return types may be written or left to the compiler, which collects every return and the tail expression.

fn explicit(value: int): int => value * value

fn inferred(value: int) {
    return value * value
}

fn recursive(n: int) {
    if n <= 1 { return 1 }
    return n * recursive(n - 1)
}

fn caller(): int => explicit(2) + inferred(3) + recursive(5)

Auto return types compose with anonymous records, which is the idiomatic way to return several values without declaring a struct:

fn metrics(values: []int) {
    mut total := 0
    mut largest := 0
    for v in values {
        total = total + v
        if v > largest { largest = v }
    }
    return { total: total, largest: largest, count: values.len() }
}

fn main(): void {
    m := metrics([3, 9, 2])
    println("${m.count} values, total ${m.total}, max ${m.largest}")
}

The caller gets full field-by-field checking on m even though no struct was declared. To name that inferred type elsewhere, use the type projection metrics::result (and metrics::err for the error side) — see Projections.

Write the return type explicitly on anything exported: an inferred signature changes whenever the body does.

Generic inference

Type arguments are solved from the ordinary arguments where possible, and can be given explicitly in square brackets when they are not.

fn first_or[T](xs: []T, fallback: T): T => xs.get(0) ?? fallback

fn wrap[T](value: T): []T => [value]

fn use_generics(): int {
    a := first_or([1, 2, 3], 0)      // T = int, from the arguments
    b := wrap[int](7)                // T given explicitly
    return a + b.len()
}

Bounds participate in inference by restricting which operations are available:

fn largest[T: Comparable[T]](xs: []T, seed: T): T {
    mut best := seed
    for v in xs {
        if best.compare_to(v) < 0 { best = v }
    }
    return best
}

fn use_bound(): int => largest([3, 9, 2], 0)

Closures

A closure parameter’s type comes from the position the closure is passed into.

fn pipeline(xs: []int): int {
    evens := xs.filter(v => v % 2 == 0)      // v: int
    doubled := xs.map(v => v * 2)
    has_big := xs.any { v => v > 100 }       // brace form

    return evens.len() + doubled.len() + (if has_big { 1 } else { 0 })
}

Annotate a parameter when no position constrains it, or when the annotation documents intent:

fn standalone(): int {
    increment := (x: int) => x + 1
    return increment(41)
}

Option lifting

A bare T is lifted into an expected Option[T] as Some(value). The reverse is never implicit.

fn greet(name: string?): string => name ?? "anonymous"

fn lifting(): string {
    display: string? = "Nori"          // Some("Nori")
    missing: string? = None
    return greet("Ada") + greet(display) + greet(missing)
}

Getting the value back out requires a pattern, ??, ?., or an Option method — inference will not silently unwrap.

Where an annotation is required

Situation Why
Empty list with no other constraint in the function no element type exists
Map / Set values use Map.new[K, V]() / Set.new[T]()
A literal that must not be int or float annotate the binding or add a suffix
A value that must reach a narrower parameter inference stops at the binding
Anything exported an inferred signature is not a stable contract
f32 versus float, u32 versus int there is no implicit widening

Everywhere else, := is the idiomatic form.

Diagnostics

A mismatch names the expected and actual types and blames the construct that introduced the constraint — an annotation, an argument, a field, a branch, or a return.

fn wrong(): void {
    count: int = "many"
    println("${count}")
}

That is ATOLL2002: type mismatch: expected int, got string (from a type annotation). Inference never adjusts runtime behaviour to make a mismatch go away; fix the expression, state the intended type, or perform a supported conversion.

When the diagnostic lands far from the initializer, annotate the value at the point where its role becomes clear. That shortens the constraint path and records the intent for the next reader.

Worked example

struct Order {
    id: int
    total: float
    priority: u8
}

fn parse_order(line: string) {
    parts := line.split(",")
    id := (parts.get(0) ?? "").to_int() ?? 0
    total := (parts.get(1) ?? "").to_float() ?? 0.0
    return { id: id, total: total }
}

fn to_order(line: string): Order {
    parsed := parse_order(line)
    // `priority` is checked as u8 because the field declares it.
    return Order { id: parsed.id, total: parsed.total, priority: 5 }
}

fn total_of(lines: []string): float {
    mut sum := 0.0
    for line in lines {
        sum = sum + to_order(line).total
    }
    return sum
}

fn main(): void {
    println("${total_of(["1,10.5", "2,4.25"])}")
}

No single line here states every type. split fixes parts as []string, to_int() and to_float() produce int? and float? which ?? discharges, the anonymous record’s field types come from those bindings, and the Order literal checks 5 against the declared u8. Everything meets inside the functions, and the whole unit still lowers to one fully resolved program.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close