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.