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 stringPrecedence
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
- Are the spellings aliases for one type? Nothing to do.
- Is the source still a literal? Let the expected type check it.
- Is it
Optionlifting or a reference deref? It is already implicit. - Is it a primitive change with accepted loss? Use
as, or theto_*method directly when the name reads better. - Can the input be malformed or out of range? Use
to_int/to_float/from_string, or your own validating constructor. - Does the change establish domain meaning? Name it — a static constructor,
From, orInto. - 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