Skip to content

Conversions

How `as` works, which source/target pairs exist, what truncates, and how to parse, format, and build domain conversions.

Updated View as Markdown

Atoll converts almost nothing implicitly. Two spellings of the same type need no work, a literal can be typed by its context, Option lifts a bare value, and a &T dereferences — everything else is written out.

fn tour(): void {
    same: i64 = 42            // identity: `int` and `i64` are one type
    port: u16 = 8080          // contextual literal typing
    label: string? = "ready"  // Option lifting
    narrowed := 300 as u8     // explicit cast
    parsed := "42".to_int()   // fallible library conversion

    println("${same} ${port} ${label ?? "-"} ${narrowed} ${parsed ?? 0}")
}
Mechanism Example Cost
Identity i64 used as int none
Contextual literal typing port: u16 = 8080 none
Implicit lift name: string? = "A" wraps in Some
Implicit deref passing &Item where Item is expected reads through the reference
Explicit cast count as u8 calls the target’s to_* method
Library conversion "42".to_int() operation-specific, often fallible

That is the whole list. There is no “convert anything that fits” rule.

Identity

Alias spellings need no conversion, because they name one type.

fn aliases(): void {
    value: int = 42
    same: i64 = value

    octet: byte = 0xFF
    same_octet: u8 = octet

    println("${same} ${same_octet}")
}

A transparent type alias behaves the same way — it expands to its target, so the alias and the target interchange freely:

type Meters = float

fn extend(distance: Meters, delta: float): Meters => distance + delta

fn walk(): Meters => extend(1.5, 2.5)

A distinct type deliberately does not. It owns a nominal identity, so the underlying value does not flow into it:

distinct type UserId = int

fn make(raw: int): UserId {
    return raw
}

Contextual literal typing

A literal has no type of its own until something asks for one. When the expected type is numeric, the literal is checked directly against it.

fn write_port(port: u16): int => port.to_int()

fn call(): int {
    return write_port(443)
}

443 is checked as u16; no int is created and then narrowed. Out-of-range literals are therefore compile errors, never silent wraps:

fn call(): void {
    port: u16 = 70000
    println("${port}")
}

This applies only while the value is still a literal. Once it is bound, its type is fixed and a cast is required:

fn write_port(port: u16): int => port.to_int()

fn call(): int {
    port := 443           // int
    return write_port(port as u16)
}

There is no implicit numeric widening

Unlike many languages, Atoll does not widen i16 to int, u8 to u32, or f32 to float on its own.

fn widen(): int {
    small: i16 = 12
    wide: int = small
    return wide
}
fn widen_float(): float {
    pixel: f32 = 0.5
    calculation: float = pixel
    return calculation
}

Call the conversion method, which is total in the widening direction:

fn widen(): int {
    small: i16 = 12
    return small.to_int()
}

fn widen_float(): float {
    pixel: f32 = 0.5
    return pixel.to_float()
}

Mixed-width and mixed-family arithmetic is rejected for the same reason — convert one operand first:

fn scale(): float {
    n := 3
    r := 1.5
    return n * r
}
fn scale(): float {
    n := 3
    r := 1.5
    return n.to_float() * r
}

Implicit lifts

Two implicit rules do exist. A bare T lifts into an expected Option[T]:

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

fn lift(): string {
    display: string? = "Nori"     // becomes Some("Nori")
    return greet("Ada") + greet(display)
}

And a safe reference dereferences where a concrete value is expected:

struct Item { id: int }

fn inspect(value: Item): int => value.id

fn deref(): int {
    item := Item { id: 7 }
    item_ref := &item
    return inspect(item_ref)
}

Neither reverses. Option[T] does not become T, and Result[T, E] never becomes T — use a pattern, ??, ?, catch, or unwrap_or:

error CacheError { Missing }

fn cached_count(): int ! CacheError => 0

fn read(): int {
    return cached_count()
}
error CacheError { Missing }

fn cached_count(): int ! CacheError => 0

fn read(): int => cached_count().unwrap_or(0)

fn propagate(): int ! CacheError => cached_count()?

Casts

value as Target is explicit primitive conversion. It is not a bit reinterpretation: the compiler rewrites it to the source type’s to_<target> method, and the expression’s type is whatever that method returns.

fn casts(): void {
    count := 300

    println("${count as u8}")      // count.to_u8()   -> 44
    println("${count as i8}")      // count.to_i8()   -> 44
    println("${count as float}")   // count.to_float()-> 300
    println("${count as string}")  // count.to_string()

    println("${(0 - 1) as u32}")   // 4294967295
}

Two consequences follow directly from that desugaring.

A missing method is a missing cast. int declares no to_f32 and no to_char, so those casts do not exist:

fn missing(): void {
    n := 3
    println("${n as f32}")
}

The result type is the method’s return type. string.to_int() returns int?, so text as int is an optional int:

fn parse(text: string): int {
    value: int? = text as int
    return value ?? 0
}
fn parse(text: string): int {
    value: int = text as int
    return value
}

Which casts exist

Source Available as targets
int / i64 i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 usize isize float string
other integer widths int plus their neighbouring widths, float, string
float / f64 int i32 f32 string
f32 float int i32 string
byte / u8 int i8 u16 u32 u64 u128 char float string
char int u32 string
bool string
string int (as int?) · float (as float?) · bool (as bool?)
any type with Display string

Notably absent: bool as int, char as byte, float as u8, and any cast targeting a struct, enum, record, union, or collection.

fn nonexistent(): void {
    flag := true
    println("${flag as int}")
}

as string works on a user type once it implements Display:

struct Duration { millis: int }

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

fn render(d: Duration): string => d as string

Precedence

as binds tighter than unary and binary operators, and its right-hand side is a type, so ? after it is read as optional-type syntax. Parenthesize when in doubt.

fn precedence(): void {
    n := -1
    println("${n as u8}")             // (n) as u8 -> 255
    println("${-(1 as i8)}")          // negate after the cast

    text := "42"
    value := (text as int) ?? 0       // parentheses required
    println("${value}")

    chained := 300 as u8 as int       // left to right
    println("${chained}")             // 44
}

Writing -1 as u8 directly is a compile error, because as binds first and unary - is undefined on unsigned types:

fn bad_precedence(): void {
    println("${-1 as u8}")
}

Truncation and overflow

Narrowing casts wrap; they do not clamp and they do not fail. These are the actual runtime values:

fn truncation(): void {
    println("${300 as u8}")         // 44   (300 - 256)
    println("${300 as i8}")         // 44
    println("${(0 - 1) as u8}")     // 255
    println("${(0 - 1) as u32}")    // 4294967295
}

Float-to-integer casts truncate toward zero — they do not round:

fn to_integers(): void {
    x := 2.9
    println("${x as int}")           // 2
    println("${(0.0 - 2.9) as int}") // -2

    println("${x.floor()} ${x.ceil()} ${x.round()} ${x.truncate()}")  // 2 3 3 2
}

Use floor, ceil, or round when the rounding policy matters, and validate the range yourself when truncation would be wrong:

fn to_octet(value: int): byte? {
    if value < 0 || value > 255 { return None }
    return value as u8
}

fn main(): void {
    println("${to_octet(200) ?? 0u8} ${to_octet(300).is_none()}")
}

Every integer type also declares a checked family — try_to_u8, try_to_i32, and so on, returning T? — plus checked_add / saturating_add and their relatives:

fn checked(value: int): string {
    match value.try_to_u8() {
        Some(octet) => return "fits: ${octet}"
        None => return "does not fit in a byte"
    }
}

Parsing

Text parsing is not a cast, because malformed input is expected. The string methods return Option, and never manufacture a zero on failure.

fn parse_all(): void {
    println("${"42".to_int() ?? 0}")       // 42
    println("${"nope".to_int() ?? -1}")    // -1
    println("${"1.5".to_float() ?? 0.0}")  // 1.5
    println("${"true".to_bool() ?? false}")// true

    println("${int.from_string("42") ?? 0}")
    println("${float.from_string("1.5") ?? 0.0}")
}

Handle the absence rather than defaulting past it when the difference matters:

error ConfigError { BadPort }

fn read_port(text: string): u16 ! ConfigError {
    raw := text.to_int() ?? error BadPort
    if raw < 1 || raw > 65535 { error BadPort }
    return raw as u16
}

fn main(): void {
    match read_port("8080") {
        Ok(p) => println("port ${p}")
        Err(e) => println("bad port")
    }
}

Formatting

to_string() comes from the Display trait and is total. String interpolation calls it on every inserted expression.

struct Point { x: int, y: int }

impl Display for Point {
    fn to_string(self): string => "(${self.x}, ${self.y})"
}

fn format(): void {
    count := 300
    ratio := 2.5
    flag := true
    p := Point { x: 1, y: 2 }

    println("${count.to_string()} ${ratio.to_string()} ${flag.to_string()}")
    println("${count} items at ${ratio} — ${flag} — ${p}")
}

Bytes and text convert through to_bytes and string.from_bytes, which is fallible because arbitrary bytes are not necessarily valid UTF-8:

fn round_trip(text: string): string {
    encoded := text.to_bytes()
    return string.from_bytes(encoded) ?? ""
}

Domain conversions

When a conversion carries validation or meaning, give it a name. A one-field struct plus a static constructor makes the policy visible at the call site.

error IdError { Malformed, OutOfRange }

struct UserId {
    value: u64

    fn parse(text: string): UserId ! IdError {
        raw := text.to_int() ?? error Malformed
        if raw <= 0 { error OutOfRange }
        return UserId { value: raw as u64 }
    }
}

impl Display for UserId {
    fn to_string(self): string => "u${self.value}"
}

fn main(): void {
    match UserId.parse("41") {
        Ok(id) => println("ok ${id}")
        Err(e) => println("rejected")
    }
    match UserId.parse("nope") {
        Ok(id) => println("ok ${id}")
        Err(e) => println("rejected")
    }
}

The reader can see whether failure is possible, whether the units change, and whether a distinct identity is being established — none of which a bare cast communicates.

For total conversions between two of your own types, implement From[T] or Into[T] from the prelude:

struct Celsius { deg: float }
struct Fahrenheit { deg: float }

impl From[Celsius] for Fahrenheit {
    fn from(value: Celsius): Fahrenheit => Fahrenheit { deg: value.deg * 1.8 + 32.0 }
}

impl Into[Fahrenheit] for Celsius {
    fn into(self): Fahrenheit => Fahrenheit { deg: self.deg * 1.8 + 32.0 }
}

fn main(): void {
    boiling := Fahrenheit.from(Celsius { deg: 100.0 })
    freezing := Celsius { deg: 0.0 }.into()
    println("${boiling.deg} ${freezing.deg}")     // 212 32
}

Equal field shapes never make two nominal structs interchangeable — write the mapping.

Decision guide

  1. Are the spellings aliases for one type? Nothing to do.
  2. Is the source still a literal? Let the expected type check it.
  3. Is it Option lifting or a reference deref? It is already implicit.
  4. Is it a primitive change with accepted loss? Use as, or the to_* method directly when the name reads better.
  5. Can the input be malformed or out of range? Use to_int / to_float / from_string, or your own validating constructor.
  6. Does the change establish domain meaning? Name it — a static constructor, From, or Into.
  7. Does it reinterpret memory? Only an audited unsafe intrinsic does that.

If none applies, the mismatch is a modeling problem rather than a missing cast.

A complete unit

Every conversion mechanism on this page, in one runnable unit: contextual literal typing on the port field, fallible parsing from text, a range check before the narrowing cast, and Display for formatting.

error ConfigError { BadLine, BadPort }

struct Endpoint {
    host: string
    port: u16
}

impl Display for Endpoint {
    fn to_string(self): string => "${self.host}:${self.port}"
}

fn parse_endpoint(line: string): Endpoint ! ConfigError {
    parts := line.split(":")
    if parts.len() != 2 { error BadLine }

    host := parts.get(0) ?? ""

    // `to_int()` is fallible, so the failure becomes a typed error…
    raw := (parts.get(1) ?? "").to_int() ?? error BadPort
    // …and the range is checked before the cast, because `as u16` would wrap.
    if raw < 1 || raw > 65535 { error BadPort }

    return Endpoint { host: host, port: raw as u16 }
}

fn main(): void {
    for line in ["api.example.com:8080", "broken", "db:99999"] {
        match parse_endpoint(line) {
            Ok(e) => println("ok ${e} port=${e.port.to_int()}")
            Err(err) => println("rejected: ${line}")
        }
    }
}

That prints:

ok api.example.com:8080 port=8080
rejected: broken
rejected: db:99999
Navigation

Type to search…

↑↓ navigate↵ selectEsc close