Skip to content

Primitives

Every built-in scalar — integers, floats, bool, char, byte, string, void, and never — with ranges, literal spellings, overflow rules, and trap behaviour.

Updated View as Markdown

Primitive types are the values the compiler understands without a user declaration. Every one of them is spelled in lowercase.

enabled: bool = true
count: int = 42
ratio: float = 0.75
letter: char = 'λ'
octet: byte = 0xFF
message: string = "ready"

println("${enabled} ${count} ${ratio} ${letter} ${octet} ${message}")

Annotations are optional — the compiler still fixes one concrete primitive per binding before lowering.

enabled := true      // bool
count := 42          // int
ratio := 0.75        // float
letter := 'λ'        // char
message := "ready"   // string

println("${enabled} ${count} ${ratio} ${letter} ${message}")

Overview

Type Meaning Literal
bool Boolean truth value true, false
int Signed 64-bit default integer 0, 42, -7
float IEEE 754 64-bit default float 0.0, 1.25
byte Unsigned 8-bit integer 0xFF, 65 in a byte context
char One Unicode scalar value 'A', 'λ'
string Owned UTF-8 text "Atoll"
void / Unit Exactly one value, written () ()
! Never — control does not continue none

int and float are language defaults, not platform-sized aliases: they are 64 bits on every target.

Integers

Atoll has ten fixed-width integer types plus two machine-sized ones. int and i64 name the same type, and so do byte and u8.

Type Bits Range
i8 8 −128 … 127
i16 16 −32,768 … 32,767
i32 32 −2,147,483,648 … 2,147,483,647
int / i64 64 −9,223,372,036,854,775,808 … 9,223,372,036,854,775,807
i128 128 −2¹²⁷ … 2¹²⁷−1
byte / u8 8 0 … 255
u16 16 0 … 65,535
u32 32 0 … 4,294,967,295
u64 64 0 … 18,446,744,073,709,551,615
u128 128 0 … 2¹²⁸−1
usize target word sizes, lengths, indices
isize target word offsets, pointer differences

Every one of those bounds is a valid literal in its own type:

a: i8 = 127
b: i16 = 32767
c: i32 = 2147483647
d: i64 = 9223372036854775807
e: i128 = 170141183460469231731687303715884105727

f: u8 = 255
g: u16 = 65535
h: u32 = 4294967295
i: u64 = 18446744073709551615
j: u128 = 340282366920938463463374607431768211455

k: usize = 0
l: isize = -1

println("${a} ${b} ${c} ${d} ${e}")
println("${f} ${g} ${h} ${i} ${j}")
println("${k} ${l}")

Literal spellings

Integer literals accept decimal, hexadecimal, octal, and binary bases, and _ as a digit separator anywhere between digits.

decimal := 1_000_000
hex := 0xDEAD_BEEF
octal := 0o755
binary := 0b1010_1010

println("${decimal} ${hex} ${octal} ${binary}")   // 1000000 3735928559 493 170

An unsuffixed literal is int unless an expected type says otherwise. A suffix pins the type without needing an annotation, and works on every base.

tiny := 1u8
small := 2i8
port := 3u16
offset := 4i16
flags := 5u32
signed32 := 6i32
wide := 7u64
also_int := 8i64
huge := 9u128
signed128 := 10i128
size := 11usize
delta := 12isize

mask := 0xFFu8
nibble := 0b1111_0000u8

println("${tiny} ${small} ${port} ${offset} ${flags} ${signed32}")
println("${wide} ${also_int} ${huge} ${signed128} ${size} ${delta}")
println("${mask} ${nibble}")                      // 255 240

Range is checked at compile time

Contextual typing checks the literal against the target’s range before any runtime value exists. It never wraps or saturates silently.

fn overflowing(): void {
    a: i8 = 128
    println("${a}")
}

That reports ATOLL2002: integer literal 128 is out of range for type i8.

The - in -7 is unary negation applied to the literal 7, so an unsigned target rejects it too:

fn negative_unsigned(): void {
    a: u8 = -1
    println("${a}")
}

That compile-time guarantee applies only while the value is still a literal. Once arithmetic runs, the rules below take over.

Arithmetic wraps

Atoll programs cannot panic, so +, -, and * wrap on overflow using two’s-complement arithmetic rather than trapping. wrapping_add and its relatives spell that same behaviour explicitly.

max := 9223372036854775807

println("${max + 1}")                     // -9223372036854775808
println("${max.wrapping_add(1)}")         // -9223372036854775808

small: byte = 250
println("${small.wrapping_add(10 as u8)}")// 4

signed: i8 = 127
println("${signed.wrapping_add(1i8)}")    // -128

Wrapping applies to unsigned types too, so subtracting past zero produces the type’s maximum:

zero: u32 = 0
println("${zero.wrapping_sub(1u32)}")     // 4294967295

Division truncates — and traps on zero

Integer division truncates toward zero, and % takes the sign of the dividend:

println("${7 / 2} ${7 % 2}")     // 3 1
println("${-7 / 2} ${-7 % 2}")   // -3 -1

Integer division and remainder are the two arithmetic operations that do not wrap. Both trap — the WebAssembly instance aborts and the program stops — in exactly two situations:

Expression Runtime result
n / 0, n % 0 trap: integer divide by zero
i64 minimum / -1 (or %) trap: the true quotient is out of range

A trap is not catchable: catch, ?, and ?? all operate on typed values, and a trap never produces one. Guard the divisor yourself.

fn safe_div(n: int, d: int): int? {
    if d == 0 { return None }
    if d == -1 && n == -9223372036854775807 - 1 { return None }
    return n / d
}

fn main(): void {
    println("${safe_div(7, 2) ?? -1}")     // 3
    println("${safe_div(7, 0) ?? -1}")     // -1
}

Floating-point division has no such hazard — it produces inf, -inf, or NaN instead:

d := 0.0
println("${10.0 / d} ${(0.0 - 10.0) / d} ${(0.0 / d).is_nan()}")  // inf -inf true

Checked and saturating arithmetic are not implementable yet

Every integer type declares checked_add, checked_sub, checked_mul, checked_div (returning T?), and saturating_add / saturating_sub / saturating_mul (clamping at the type’s bounds). They resolve and type-check, so atoll check accepts them — but they have no lowering, so any program that calls one fails to build:

fn main(): void {
    max := 9223372036854775807
    match max.checked_add(1) {
        Some(v) => println("fits: ${v}")
        None => println("would overflow")
    }
}

That is ATOLL2004: builtin method \checked_add` has no lowering path — declared in the prelude but never implemented`.

Written out by hand, an overflow-checked add is a comparison:

fn checked_add(a: int, b: int): int? {
    max := 9223372036854775807
    if b > 0 && a > max - b { return None }
    if b < 0 && a < (0 - max - 1) - b { return None }
    return a + b
}

fn main(): void {
    println("${checked_add(2, 3) ?? -1}")                      // 5
    println("${checked_add(9223372036854775807, 1).is_none()}")// true
}

Bitwise operators

&, |, ^, ~, <<, and >> are defined for every integer type through the BitAnd, BitOr, BitXor, BitNot, Shl, and Shr prelude traits.

println("${0b1010 & 0b0110}")   // 2
println("${0b1010 | 0b0110}")   // 14
println("${0b1010 ^ 0b0110}")   // 12
println("${1 << 10}")           // 1024
println("${1024 >> 3}")         // 128
println("${~0}")                // -1

Methods that do lower

println("${(-5).abs()}")        // 5
println("${3.min(9)}")          // 3
println("${3.max(9)}")          // 9
println("${7.to_float()}")      // 7
println("${300.to_string()}")   // 300
println("${300.to_u8()}")       // 44 — narrowing truncates

Choosing a width

Use int for ordinary arithmetic and byte for octets. Reach for an explicit width when a wire format, foreign ABI, or numeric protocol fixes it. Use usize and isize only for sizes, offsets, and host-facing addressing — they are target-dependent, so they do not belong in a serialized format.

struct FrameHeader {
    version: u8
    flags: u16
    payload_len: u32
    stream_id: u64
}

fn declared_size(h: FrameHeader): int {
    return h.version.to_int() + h.flags.to_int()
         + h.payload_len.to_int() + h.stream_id.to_int()
}

fn main(): void {
    h := FrameHeader { version: 1, flags: 0x0F, payload_len: 1024, stream_id: 7 }
    println("${declared_size(h)}")    // 1047
}

Floats

float is 64-bit IEEE 754 and f64 is an alias for it. f32 is the 32-bit form. A floating literal is float unless context or a suffix says otherwise.

temperature := 21.5      // float
exponent := 1.5e10       // float
pixel: f32 = 0.5
exact := 3.25f32
explicit := 1.25f64

println("${temperature} ${exponent} ${pixel} ${exact} ${explicit}")

The rounding, sign, comparison, and classification methods lower directly:

x := 2.9

println("${x.floor()} ${x.ceil()} ${x.round()} ${x.truncate()}")  // 2 3 3 2
println("${x.abs()} ${x.sign()} ${x.sqrt()}")                     // 2.9 1 1.70293863659264
println("${x.is_nan()} ${x.is_finite()}")                         // false true
println("${x.min(1.0)} ${x.max(1.0)}")                            // 1 2.9

Note that floor, ceil, round, and truncate return int, not float — they are the rounding conversions. round rounds half away from zero and truncate cuts toward zero, so they disagree on negatives:

x := -2.9
println("${x.floor()} ${x.round()} ${x.truncate()}")  // -3 -3 -2

Integer and floating literals are separate categories. An integer literal is not accepted in a float position just because its value is representable:

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

Write 2.0, or convert explicitly with to_float().

fn average(values: []int): float {
    if values.len() == 0 { return 0.0 }
    mut total := 0
    for v in values { total = total + v }
    return total.to_float() / values.len().to_float()
}

fn main(): void {
    println("${average([1, 2, 6])}")     // 3
    println("${average([])}")            // 0
}

f32 and float are distinct types with no implicit widening between them; f32.to_float() converts.

small: f32 = 1.5
wide: float = small.to_float()
println("${wide + 0.25}")     // 1.75

Booleans

bool has exactly two values and is the only type a condition accepts.

fn gate(enabled: bool, attempts: int, name: string): string {
    if enabled && attempts < 3 { return "retry" }
    if !name.is_empty() || attempts == 0 { return "greet" }
    return "stop"
}

fn main(): void {
    println("${gate(true, 1, "ada")} ${gate(false, 9, "")} ${gate(false, 9, "x")}")
}

There is no truthiness conversion — an integer, string, collection, or option in condition position is a type error:

fn truthy(count: int): string {
    if count { return "some" }
    return "none"
}

Compare or inspect explicitly instead:

fn checks(attempts: int, name: string, selected: string?): int {
    mut score := 0
    if attempts != 0 { score = score + 1 }
    if !name.is_empty() { score = score + 1 }
    if selected.is_some() { score = score + 1 }
    return score
}

fn main(): void {
    println("${checks(1, "ada", "x")} ${checks(0, "", None)}")   // 3 0
}

&& and || short-circuit from left to right. The bitwise operators &, |, and ^ are also defined on bool and do not short-circuit:

println("${true & false} ${true | false} ${true ^ true} ${!true}")
// false true false false

bool also satisfies Display, so "${flag}" renders "true" or "false".

char, byte, and string

These are three different domains: one Unicode scalar value, one numeric octet, and owned UTF-8 text.

initial: char = 'A'
octet: byte = 0x41
message: string = "Atoll"

println("${initial} ${octet} ${message}")     // A 65 Atoll

They do not interchange. A char literal is not a byte:

fn confusion(): byte {
    b: byte = 'A'
    return b
}

Convert explicitly. char carries codepoint conversions and classification predicates; byte carries to_char and the numeric widenings.

c := 'A'
println("${c.to_int()} ${c.to_u32()} ${c.to_string()}")   // 65 65 A
println("${'7'.is_digit()} ${'a'.is_alphabetic()} ${' '.is_whitespace()}")

b: byte = 65
println("${b.to_char()} ${b.to_int()} ${b.to_float()}")   // A 65 65

match char.from_u32(0x03BBu32) {
    Some(lambda) => println("${lambda}")                  // λ
    None => println("not a scalar value")
}

len counts bytes; length counts characters

string follows the Go/Rust convention: len() is the O(1) UTF-8 byte count read from the block header, and length() is the O(n) codepoint count. They agree on ASCII and diverge the moment text leaves it.

ascii := "hello"
accented := "héllo"

println("${ascii.len()} ${ascii.length()}")        // 5 5
println("${accented.len()} ${accented.length()}")  // 6 5

byte_length() is a deprecated alias for len() — it also returns bytes, so do not reach for it expecting the codepoint count.

Indexing follows the same split. char_at is codepoint-indexed, while substring and slice are byte-indexed and return a borrowed substring?:

accented := "héllo"

println("${accented.char_at(1) ?? '?'}")     // é — codepoint index 1
match accented.substring(0, 3) {
    Some(view) => println("${view.to_string()}")   // hé — bytes 0..3
    None => println("byte range out of bounds")
}

Both return Option, so an out-of-range index is a value you handle rather than a trap.

The common inspection methods lower and are total:

message := "  Atoll Lang  "

println("${message.len()} ${message.is_empty()} ${message.contains("to")}")
println("[${message.trim()}] ${message.trim().starts_with("At")}")
println("${message.to_lower_ascii()}|${message.to_upper_ascii()}|")
println("${"a,b,c".split(",").len()} ${"ab".repeat(3)}")
println("${"hello".index_of("ll")}")          // Some(2) — index_of returns an Option

string is a primitive but not a register-sized scalar — its contents are managed storage. Ownership rules are covered in Values.

Interpolation calls Display.to_string() on each inserted expression, so any type with a Display implementation can appear inside ${...}:

struct Duration { millis: int }

impl Display for Duration {
    fn to_string(self): string => "${self.millis}ms"
}

fn main(): void {
    d := Duration { millis: 250 }
    println("3 items in ${d}")     // 3 items in 250ms
}

Unit

void is the unit type: exactly one value, written (). Unit is an accepted spelling of the same type, and a function may omit its return type entirely.

fn with_void(): void { println("a") }
fn with_unit(): Unit { println("b") }
fn omitted() { println("c") }

fn main(): void {
    with_void()
    with_unit()
    omitted()
    u: void = ()
    v: Unit = ()
    println("done")
}

Unit means “completed with no payload”. It is not Option’s absence, and it is not the never type.

Never

! is the type of an expression that does not complete normally. A function annotated ! must never return.

fn spin(): ! {
    for {}
}

for { } is the bare form of Atoll’s only loop construct; there is no loop keyword, and there is no prelude panic function to write a diverging function with. The other way to reach ! is an expression that leaves the enclosing function — return, error, break, or continue.

Never joins with any other type, which is why a branch that leaves the function can sit beside a branch that produces a value:

fn pick(ok: bool): int {
    // The `else` arm has type `!`, so the join is `int`.
    value := if ok { 1 } else { return -1 }
    return value
}

fn main(): void {
    println("${pick(true)} ${pick(false)}")   // 1 -1
}

Never is a static fact used to exclude an impossible continuation. Nothing of type ! is ever stored at runtime.

Internal types

The compiler also knows ptr[T], rawptr[T], and the SIMD type Vec128. They exist for unsafe runtime and code-generation interfaces — see Unsafe — and are not application primitives. Prefer structs, collections, and safe references in ordinary code.

Putting it together

A small unit-conversion module that touches most of the surface above: integer widths on the wire fields, float rounding for display, byte-versus-codepoint awareness in the label, and a guarded division.

struct Reading {
    sensor_id: u16
    celsius: float
    sample_count: int
    label: string
}

fn to_fahrenheit(c: float): float => c * 1.8 + 32.0

/// One decimal place, without relying on a formatter.
fn tenths(value: float): string {
    scaled := (value * 10.0).round()
    return "${scaled / 10}.${(scaled % 10).abs()}"
}

fn mean(total: int, count: int): float {
    if count == 0 { return 0.0 }        // guard: integer / 0 would trap
    return total.to_float() / count.to_float()
}

fn summarize(readings: []Reading): string {
    if readings.len() == 0 { return "no readings" }

    mut hottest := 0.0
    mut total_samples := 0
    for r in readings {
        f := to_fahrenheit(r.celsius)
        if f > hottest { hottest = f }
        total_samples = total_samples + r.sample_count
    }

    return "peak ${tenths(hottest)}F over ${readings.len()} sensors, "
         + "${tenths(mean(total_samples, readings.len()))} samples each"
}

fn main(): void {
    readings := [
        Reading { sensor_id: 1, celsius: 21.5, sample_count: 120, label: "café" },
        Reading { sensor_id: 2, celsius: 30.0, sample_count: 90, label: "roof" },
    ]
    println(summarize(readings))
    first := readings.get(0) ?? Reading { sensor_id: 0, celsius: 0.0, sample_count: 0, label: "" }
    println("${first.label}: ${first.len_note()}")
}

fn Reading.len_note(self): string =>
    "${self.label.len()} bytes / ${self.label.length()} chars"

Continue with Inference for when an annotation is required, and Conversions for moving between these types.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close