Skip to content

Operators

Every Atoll operator with worked examples — arithmetic, comparison, logic, bitwise, ranges, membership, casts, option handling, assignment, precedence, and the prelude traits that back overloading.

Updated View as Markdown

Operators are the compact half of Atoll’s expression grammar. Parsing decides how they group; the checker decides whether the operand types support them. Nothing here is dynamic — every operator resolves to a primitive lowering or to one named prelude trait at compile time.

Here is most of the surface in one unit you can paste into a file and run:

fn main(): void {
    // arithmetic and comparison
    price := 250
    quantity := 4
    subtotal := price * quantity
    discounted := subtotal - subtotal / 10

    // logic — short-circuiting, no truthiness
    in_stock := quantity > 0
    affordable := discounted <= 1000
    can_order := in_stock and affordable

    // bitwise
    flags := 0b1010
    readable := flags & 0b0010 != 0

    // ranges and membership
    tiers := [1, 2, 3]
    known_tier := tiers.contains(2)
    in_window := 250 in 100..500

    // option operators
    first: int? = tiers.get(0)
    head := first ?? 0

    // casts
    as_bytes := discounted as u16

    println("${subtotal} ${discounted} ${can_order} ${readable}")
    println("${known_tier} ${in_window} ${head} ${as_bytes}")
}

Arithmetic

Operator Meaning Trait on user types
a + b addition Numeric.add
a - b subtraction Numeric.sub
a * b multiplication Numeric.mul
a / b division Numeric.div
a % b remainder primitive only
-a negation Neg.neg

Division keeps the operand category: int / int truncates, float / float does not. % is a primitive integer/float operation — Numeric does not supply it, so it is not available on user types.

fn main(): void {
    println("${7 / 2}")        // 3   — integer division truncates
    println("${7.0 / 2.0}")    // 3.5 — float division does not
    println("${7 % 2}")        // 1
    println("${-7 % 2}")       // -1  — sign follows the dividend

    position := 12
    println("${-position}")
}

There is no implicit numeric promotion. Mixing an int and a float in one arithmetic expression is an error, not a widening:

fn main(): float {
    return 1 + 2.5
}

Write the conversion you mean:

fn main(): float {
    n := 1
    return n.to_float() + 2.5
}

+ also concatenates strings.

fn greeting(name: string): string {
    return "hello, " + name
}

fn main(): void {
    println(greeting("ada"))
}

Comparison

Operator Meaning Trait on user types
a == b equal Equatable.equals
a != b not equal Equatable.equals
a < b less than Comparable[T].compare_to
a <= b less or equal Comparable[T].compare_to
a > b greater than Comparable[T].compare_to
a >= b greater or equal Comparable[T].compare_to

Structs and enums whose fields are all comparable get structural equality, ordering, and hashing at the operator level without any declaration. The compiler compares them field by field in declaration order, so ==, <, and use as a Set or Map key all work on a bare struct:

struct Version {
    major: int
    minor: int
}

fn main(): void {
    a := Version { major: 1, minor: 5 }
    b := Version { major: 2, minor: 0 }

    println("${a == b} ${a != b} ${a < b}")   // false true true

    mut seen := Set.new[Version]()
    seen.add(a)
    seen.add(a)
    println("${seen.size()}")                 // 1 — structural hashing
}

That synthesis covers operator syntax only. A generic bound such as the T: Comparable[T] that sorted() requires is checked against declared implementations, and the structural fallback does not satisfy it:

struct Version {
    major: int
    minor: int
}

fn main(): void {
    releases := [Version { major: 2, minor: 0 }, Version { major: 1, minor: 5 }]
    oldest := releases.sorted().get(0)
    println("${oldest?.major ?? 0}")
}

Declare the implementation when a generic function has to see it. compare_to returns a negative number, zero, or a positive number:

struct Version {
    major: int
    minor: int
}

impl Equatable for Version {
    fn equals(self, other: Version): bool {
        return self.major == other.major and self.minor == other.minor
    }
}

impl Comparable[Version] for Version {
    fn compare_to(self, other: Version): int {
        if self.major != other.major { return self.major - other.major }
        return self.minor - other.minor
    }
}

fn main(): void {
    releases := [Version { major: 2, minor: 0 }, Version { major: 1, minor: 5 }]
    ordered := releases.sorted()
    oldest := ordered.get(0)
    println("${oldest?.major ?? 0}.${oldest?.minor ?? 0}")   // 1.5
}

Comparisons do not chain. a < b < c parses as (a < b) < c, and the checker rejects comparing a bool with an int:

fn main(): bool {
    a := 1
    b := 2
    c := 3
    return a < b < c
}

Write the conjunction explicitly:

fn between(value: int, low: int, high: int): bool {
    return low <= value and value <= high
}

fn main(): void {
    println("${between(2, 1, 3)} ${between(9, 1, 3)}")
}

Logic

and / && and or / || are the same operators spelled two ways; not and ! are the same negation. Both operands must already be bool. Atoll has no truthiness — an int, a string, a collection, or an Option is never implicitly a condition.

fn main(): void {
    n := 5
    if n { println("nonzero") }
}
fn main(): void {
    n := 5
    if n != 0 { println("nonzero") }

    ready := true
    println("${not ready} ${!ready} ${ready && !ready} ${ready || false}")
}

Both operators short-circuit. This unit records what actually ran, so you can see the right operand being skipped:

fn main(): void {
    mut log: []string = []
    note := (label: string, value: bool) => {
        log.add(label)
        value
    }

    a := note("or-left", true) or note("or-right", false)    // right skipped
    b := note("and-left", false) and note("and-right", true) // right skipped
    c := note("or-left2", false) or note("or-right2", true)  // right runs
    d := note("and-left2", true) and note("and-right2", true)// right runs

    println("${a} ${b} ${c} ${d}")
    for entry in log { println(entry) }
}

The log holds or-left, and-left, or-left2, or-right2, and-left2, and-right2 — the two skipped calls never appear.

Prefix ! and not dispatch through the Not trait on a user type.

Bitwise and shifts

Operator Meaning Trait on user types
a & b bitwise AND BitAnd.bit_and
a | b bitwise OR BitOr.bit_or
a ^ b bitwise XOR BitXor.bit_xor
~a bitwise complement BitNot.bit_not
a << n left shift Shl.shl
a >> n right shift Shr.shr
const READ: int = 0b0001
const WRITE: int = 0b0010
const EXEC: int = 0b0100

fn main(): void {
    mut perms := READ | WRITE
    println("${perms}")

    perms = perms | EXEC          // grant
    perms = perms & ~WRITE        // revoke
    println("${perms}")

    println("${perms & READ != 0}")   // still readable
    println("${perms ^ EXEC}")        // toggle
    println("${1 << 4} ${256 >> 3}")
}

Note the grouping in perms & READ != 0. Unlike C, Atoll binds &, ^, and | tighter than the comparison operators, so that reads as (perms & READ) != 0 and needs no parentheses. Shifts, on the other hand, bind looser than +: 1 << 2 + 1 is 1 << 3, which is 8.

Prefix & is a different operator from infix & — it takes a reference. The parser never confuses them because a prefix & can only appear where an expression starts.

struct Packet {
    id: int
    payload: string
}

fn describe(view: &Packet): string {
    return "packet ${view.id}"
}

fn main(): void {
    packet := Packet { id: 7, payload: "ping" }
    view: &Packet = &packet
    println(describe(view))
}

Ranges

lo..hi is half-open; lo..=hi includes the upper endpoint. Both are ordinary expressions producing Range[T] and RangeInclusive[T], each with start and end fields.

fn width(r: Range[int]): int {
    return r.end - r.start
}

fn main(): void {
    half_open := 2..7
    closed := 2..=7

    println("${half_open.start} ${half_open.end}")
    println("${closed.start} ${closed.end}")
    println("${width(3..9)}")

    mut total := 0
    for i in 0..4 { total = total + i }        // 0 1 2 3
    for i in 1..=3 { total = total + i }       // 1 2 3
    println("${total}")
}

Ranges bind looser than arithmetic, so 0..n + 1 means 0..(n + 1) — the common “one past the end” idiom needs no parentheses.

Range patterns are their own thing in match:

fn classify(status: int): string {
    return match status {
        200..=299 => "success"
        300..=399 => "redirect"
        400..=499 => "client error"
        500..=599 => "server error"
        _ => "unknown"
    }
}

fn main(): void {
    println(classify(204))
    println(classify(404))
    println(classify(101))
}

Membership

value in range is a bounds test that lowers to a pair of comparisons — no Range value is built.

fn is_printable_ascii(code: int): bool {
    return code in 32..127
}

fn main(): void {
    println("${5 in 0..10}")
    println("${10 in 0..10}")     // false — the upper bound is excluded
    println("${10 in 0..=10}")    // true
    println("${is_printable_ascii(65)}")
}

in against a collection type-checks, but it does not yet lower — the code generator rejects it. Use the collection’s own membership method, which is also the shorter read:

fn main(): void {
    tiers := [1, 2, 3]
    mut ids := Set.new[int]()
    ids.add(42)
    mut labels := Map.new[string, int]()
    labels.put("gold", 3)

    println("${tiers.contains(2)}")
    println("${ids.contains(42)}")
    println("${labels.contains_key("gold")}")
    println("${"hello".contains("ell")}")
}

The same in token separates the pattern from the iterable in a for head; the loop parser suppresses the expression-level meaning while it looks for its own in.

Option operators

?? supplies a fallback for an absent Option, and only evaluates its right side when the left is None. ?. walks a member chain, propagating None instead of failing.

struct Profile {
    city: string
}

struct Account {
    profile: Profile?
}

fn city_of(account: Account?): string {
    return account?.profile?.city ?? "unknown"
}

fn main(): void {
    filled := Account { profile: Some(Profile { city: "oslo" }) }
    hollow := Account { profile: None }

    println(city_of(Some(filled)))
    println(city_of(Some(hollow)))
    println(city_of(None))
}

The right operand of ?? is the unwrapped type, not another Option, and ?? is left-associative. A chain of optional sources therefore needs parentheses:

fn main(): string {
    a: string? = None
    b: string? = None
    return a ?? b ?? "fallback"
}
fn main(): string {
    a: string? = None
    b: string? = None
    return a ?? (b ?? "fallback")
}

?? is an Option operator only. It does not discharge a Result; use catch, ?, or unwrap_or for that.

error LoadError { Missing }

fn load(): int ! LoadError { error Missing }

fn main(): int {
    return load() ?? 0
}
error LoadError { Missing }

fn load(): int ! LoadError { error Missing }

fn main(): int {
    return load().unwrap_or(0)
}

?. requires an optional receiver — applying it to a plain value is an error, because there is no absence to propagate:

struct Point { x: int }

fn main(): int {
    p := Point { x: 1 }
    return p?.x
}

Propagation

Postfix ? unwraps the success side and returns the failure side to the caller. It works on Result in a fallible function and on Option in a function that returns an Option.

error LoadError { NotFound }

fn load_user(id: int): string ! LoadError {
    if id < 0 { error NotFound }
    return "ada"
}

fn greet(id: int): string ! LoadError {
    name := load_user(id)?
    return "hello, ${name}"
}

fn doubled_head(values: []int): int? {
    head := values.get(0)?
    return Some(head * 2)
}

fn main(): void {
    println("${doubled_head([4, 5]) ?? -1}")
    println("${doubled_head([]) ?? -1}")
}

Casts

value as T requests an explicit conversion. Between primitives it is a representation change; on a user type it dispatches to a method named after the target — to_int, to_float, to_string.

fn main(): void {
    n := 300
    println("${n as u8} ${n as i8} ${n as u16} ${n as i64} ${n as usize}")

    ratio := 2.75
    println("${ratio as int} ${ratio as f32}")

    letter: byte = 65
    println("${letter as int}")
    println("${42 as string}")
}
struct Celsius {
    degrees: float
}

impl Celsius {
    fn to_int(self): int { return self.degrees.to_int() }
    fn to_float(self): float { return self.degrees }
    fn to_string(self): string { return "${self.degrees}C" }
}

fn main(): void {
    reading := Celsius { degrees: 21.5 }
    println("${reading as int} ${reading as float} ${reading as string}")
}

Writing an unrelated target type does not make the conversion legal — the checker looks for the conversion method and reports its absence:

struct Packet { id: int }

fn main(): int {
    p := Packet { id: 1 }
    return p as int
}

There is no value is Type runtime type test in Atoll. is is a reserved word used by the query grammar, and it does not parse as an expression operator:

fn check(v: int | string): bool {
    return v is int
}

Refine a closed set of alternatives with match instead.

Assignment

:= declares; = updates an existing mutable place. The two are not interchangeable.

fn main(): void {
    mut count := 0        // declare
    count = count + 1     // update
    println("${count}")
}

Compound assignment reads, operates, and writes one place.

Group Operators
arithmetic += -= *= /= %=
bitwise &= |= ^= <<= >>=
option ??=
struct Counter {
    hits: int
}

fn main(): void {
    mut total := 10
    total += 5
    total -= 2
    total *= 3
    total /= 2
    total %= 7

    mut flags := 0b1010
    flags &= 0b1100
    flags |= 0b0001
    flags ^= 0b1111
    flags <<= 2
    flags >>= 1

    mut c := Counter { hits: 0 }
    c.hits += 1

    mut cached: string? = None
    cached ??= "computed"

    println("${total} ${flags} ${c.hits} ${cached ?? "-"}")
}

??= stores the fallback only when the target is None, and evaluates the right side only in that case.

Assignment is a statement, not an expression: it produces void, so it cannot be chained or embedded.

fn main(): int {
    mut a := 0
    mut b := 0
    a = b = 5
    return a + b
}

For an element of a list, prefer the explicit read-modify-write form; it is also clearer about the Option that indexing returns.

fn main(): void {
    mut items := [1, 2, 3]
    items[0] = 99
    items[1] = (items[1] ?? 0) + 10
    println("${items[0] ?? -1} ${items[1] ?? -1}")
}

Precedence

From tightest to loosest, as the parser groups them:

Level Forms Associativity
14 postfix . ?. () [] ? as catch left
13 prefix - ! not ~ & right
12 ?? left
11 * / % left
10 + - left
9 << >> left
8 & left
7 ^ left
6 | left
5 .. ..= non-chaining
4 == != < <= > >= in non-chaining
3 and && left
2 or || left
1 |> (parses, does not check — see below) left
0 = and every compound assignment right

Three levels differ from C-family languages and are worth committing to memory:

  • ?? binds tighter than *, so maybe ?? 2 * 3 is (maybe ?? 2) * 3.
  • the bitwise operators bind tighter than comparison, so flags & MASK != 0 is (flags & MASK) != 0.
  • ranges bind looser than arithmetic, so 0..n + 1 is 0..(n + 1).
fn main(): void {
    missing: int? = None
    present: int? = Some(7)
    println("${missing ?? 2 * 3}")     // (0 ?? 2) * 3  ==  6
    println("${present ?? 2 * 3}")     // 7 * 3         == 21

    flags := 0b1010
    println("${flags & 0b0100 != 0}")  // (flags & 4) != 0  == false

    n := 3
    r := 0..n + 1
    println("${r.start} ${r.end}")     // 0 4

    println("${1 << 2 + 1}")           // 1 << 3        == 8
}

When a mixed expression is not obvious at a glance, parenthesise it. The compiler does not need the parentheses; the next reader does.

Overloading

Operator syntax is statically dispatched. The checker handles the primitive cases first, then looks for one specific prelude trait implemented for the operand type. It never searches for an arbitrary similarly named method.

Operator Trait Method
+ - * / Numeric add sub mul div
-a Neg neg
!a / not a Not not
~a BitNot bit_not
& | ^ BitAnd / BitOr / BitXor bit_and / bit_or / bit_xor
<< >> Shl / Shr shl / shr
== != Equatable equals
< <= > >= Comparable[T] compare_to
xs[k] Index[K, V] get
xs[k] = v IndexMut[K, V] set
"${x}" Display to_string
map/set keys Hashable hash_code

There is no Add trait — arithmetic is one contract, Numeric, and it also carries the to_int / to_float conversions that back as int and as float. Because Numeric extends Comparable[Self], which extends Equatable, a type that wants + must supply ordering and equality too:

struct Money {
    cents: int
}

impl Equatable for Money {
    fn equals(self, other: Money): bool {
        return self.cents == other.cents
    }
}

impl Comparable[Money] for Money {
    fn compare_to(self, other: Money): int {
        return self.cents - other.cents
    }
}

impl Numeric for Money {
    fn to_int(self): int { return self.cents }
    fn to_float(self): float { return self.cents.to_float() }
    fn add(self, other: Money): Money { return Money { cents: self.cents + other.cents } }
    fn sub(self, other: Money): Money { return Money { cents: self.cents - other.cents } }
    fn mul(self, other: Money): Money { return Money { cents: self.cents * other.cents } }
    fn div(self, other: Money): Money { return Money { cents: self.cents / other.cents } }
}

impl Display for Money {
    fn to_string(self): string {
        return "$${self.cents / 100}.${self.cents % 100}"
    }
}

fn main(): void {
    price := Money { cents: 250 }
    fee := Money { cents: 125 }

    println("${price + fee}")
    println("${price - fee}")
    println("${price > fee} ${price == fee}")
    println("${price as int}")
}

Indexing is two traits, one per direction. Index[K, V] backs the read xs[k]; note that unlike a builtin List, a user Index implementation returns V directly rather than V?, so the implementation decides what an out-of-range key means.

struct Grid {
    cells: []int
    width: int
}

impl Index[int, int] for Grid {
    fn get(self, key: int): int {
        return self.cells[key] ?? 0
    }
}

impl Grid {
    fn at(self, row: int, col: int): int {
        return self[row * self.width + col]
    }
}

fn main(): void {
    g := Grid { cells: [1, 2, 3, 4], width: 2 }
    println("${g[0]} ${g[3]}")
    println("${g.at(1, 0)}")
    println("${g[99]}")   // the impl's own fallback, not a trap
}

IndexMut[K, V] backs the write xs[k] = v, routing it to set:

struct Grid {
    cells: []int
}

impl Index[int, int] for Grid {
    fn get(self, key: int): int {
        return self.cells[key] ?? 0
    }
}

impl IndexMut[int, int] for Grid {
    fn set(mut self, key: int, value: int): void {
        self.cells[key] = value
    }
}

fn fill(mut g: Grid, value: int): void {
    for i in 0..4 {
        g[i] = value
    }
}

fn main(): void {
    mut g := Grid { cells: [1, 2, 3, 4] }
    g[2] = 30
    println("${g[2]}")
    fill(g, 7)
    println("${g[0]} ${g[3]}")
}

The unary and bitwise traits follow the same shape:

struct Mask {
    bits: int
}

impl BitAnd for Mask {
    fn bit_and(self, other: Mask): Mask { return Mask { bits: self.bits & other.bits } }
}

impl BitOr for Mask {
    fn bit_or(self, other: Mask): Mask { return Mask { bits: self.bits | other.bits } }
}

impl BitXor for Mask {
    fn bit_xor(self, other: Mask): Mask { return Mask { bits: self.bits ^ other.bits } }
}

impl BitNot for Mask {
    fn bit_not(self): Mask { return Mask { bits: ~self.bits } }
}

impl Shl for Mask {
    fn shl(self, n: int): Mask { return Mask { bits: self.bits << n } }
}

impl Shr for Mask {
    fn shr(self, n: int): Mask { return Mask { bits: self.bits >> n } }
}

fn main(): void {
    a := Mask { bits: 0b1100 }
    b := Mask { bits: 0b1010 }
    combined := ((a & b) | (a ^ b)) & ~b
    println("${(combined << 1).bits} ${(combined >> 1).bits}")
}

An implementation’s return type need not be the left operand’s type — it must only satisfy the trait signature and the expected type at the use site. Keep the usual laws: equality reflexive, symmetric, and transitive; ordering consistent with equality; equal values hashing equally; and a += b meaning exactly a = a + b.

Effects are not waived by operator syntax. If an implementation can fail or suspend, that shows up in the effect row of every function that uses the operator.

Pipe-forward

|> occupies a precedence slot in the lexer and parser, but no later pass reshapes it into a call. Sema types the whole expression as the right-hand operand — the function value itself — and MIR lowers it to nothing. Every pipeline is therefore a type error today, with a diagnostic that mentions the function type rather than the pipe:

fn double(x: int): int { return x * 2 }

fn main(): int {
    return 5 |> double
}

The error reads type fn(int) -> int does not implement ..., which is the tell: the pipe contributed nothing and the left operand was discarded.

Write the nested call, or bind the intermediate steps, until the feature lands; see Feature Status.

fn parse_count(raw: string): int { return raw.len() }
fn normalize(n: int): int { return n * 10 }

fn main(): void {
    counted := parse_count("abc")
    println("${normalize(counted)}")
    println("${normalize(parse_count("abcd"))}")
}

Putting it together

One function that uses most of the page: bitwise flags, compound assignment, range patterns, ??, integer division, and ? propagation through a fallible call discharged with unwrap_or.

const FLAG_PAID: int = 0b0001
const FLAG_RUSH: int = 0b0010

error OrderError { EmptyCart }

struct Line {
    unit_cents: int
    quantity: int
}

impl Line {
    fn total_cents(self): int { return self.unit_cents * self.quantity }
}

struct Order {
    lines: []Line
    flags: int
    coupon: string?
}

fn discount_bps(order: Order): int {
    code := order.coupon ?? ""
    return match code.len() {
        0 => 0
        1..=4 => 500
        _ => 1000
    }
}

fn settle(order: Order): int ! OrderError {
    if order.lines.len() == 0 { error EmptyCart }

    mut gross := 0
    for line in order.lines {
        gross += line.total_cents()
    }

    // `&` binds tighter than `!=`, so no parentheses are needed here.
    net := gross - gross * discount_bps(order) / 10000
    rush := if order.flags & FLAG_RUSH != 0 { 900 } else { 0 }
    return net + rush
}

fn main(): void {
    order := Order {
        lines: [Line { unit_cents: 250, quantity: 4 }, Line { unit_cents: 1200, quantity: 1 }],
        flags: FLAG_PAID | FLAG_RUSH,
        coupon: Some("SAVE"),
    }

    println("${settle(order).unwrap_or(-1)}")
    println("${settle(Order { lines: [], flags: 0, coupon: None }).unwrap_or(-1)}")
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close