Casting
All explicit type conversions in Kairo use the as keyword. There are no separate cast operators like C++'s static_cast, dynamic_cast, reinterpret_cast, and const_cast as handles all conversion categories, with safety determined by the source and target types.
For implicit conversions (numeric widening, T to T?, derived-to-base pointers), see Type System.
In this section
- Implicit Conversions: numeric widening and derived-to-base upcasts that need no
as - Conversion Lattice: the normative rules for primitive conversions
Numeric Casts
Implicit widening is covered in Implicit Conversions.
Narrowing (truncation)
Narrowing conversions require an explicit as. The cast truncates by keeping the low bits of the source value:
var x: i64 = 1000
var y: i8 = x as i8 // truncates to low 8 bits: -24
var big: u32 = 0xDEADBEEF
var small: u8 = big as u8 // 0xEF
Truncation never panics. The as keyword is the programmer explicitly accepting potential data loss.
Float to integer
Float-to-integer casts truncate toward zero, matching C++ behavior. Out-of-range values saturate instead of producing undefined behavior:
var f: f64 = 3.9
var i: i32 = f as i32 // 3 (truncates toward zero)
var neg: f64 = -2.7
var n: i32 = neg as i32 // -2
var huge: f64 = 1.0e18
var s: i32 = huge as i32 // i32 max (2147483647) saturates
Integer to float
Integer-to-float casts may lose precision for large values but never fail:
var x: i64 = 9007199254740993 // 2^53 + 1
var f: f64 = x as f64 // rounded f64 cannot represent this exactly
Signed/unsigned conversion
Casting between signed and unsigned integers of the same width reinterprets the bit pattern:
var s: i8 = -1
var u: u8 = s as u8 // 255 (same bits, different interpretation)
Pointer Casts
Implicit upcasting is covered in Implicit Conversions.
Downcasting (base to derived)
Base-to-derived casts come in two forms:
Asserting downcast crashes the program if the runtime type does not match, like unwrap! on null. A failed cast is a programmer error, not a panic: it is in no panic set and no try can catch it, so the function needs no panic specifier. The compiler warns on every asserting downcast and offers a fix-it rewriting it to the checked form below:
fn process(animal: *Animal) {
var *dog = animal as *Dog // crashes if animal is not a Dog
// warning: use 'animal as *Dog?' and test the result
dog->fetch()
}
On a miss the program writes one line to stderr naming the cast, the target type, the pointer's static type and where the cast is (the source file's full path, line and column), then aborts (SIGABRT). The line is built at compile time, so nothing is formatted while crashing:
animal as *Dog: the object is not a 'Dog' (static type '*Animal') at /home/me/app/app.k:2:16
A null pointer is not a miss: it stays null.
Checked downcast returns a nullable pointer *Derived? (a nullable pointer, (*Derived)?), which is null if the runtime type does not match:
fn process(animal: *Animal) {
var *dog? = animal as *Dog? // null if animal is not a Dog
if dog? {
dog->fetch()
}
}
Both forms perform a runtime type check using the vtable (the class must have at least one virtual method), compiled to C++'s dynamic_cast. Downcasting a non-polymorphic class is a compile error. Inside a generic body, a pointee that is a type parameter works only with the checked form (a as (*T)?); the asserting form is an error there for now.
The CRTP downcast is the one exception that needs no vtable. Inside a generic base whose parameter is bounded by derives on the base itself, &self as *X (or *const X) is allowed without unsafe: the bound proves every X derives the base, so the target is known statically, and the cast compiles to C++'s static_cast, which also adjusts the pointer when the base is not the first one:
class <X derives Counter<X> impl Weighted> Counter {
fn dyn(self) -> *X { return &self as *X } // static, no vtable
fn bump(self) -> i32 { return self.dyn()->weight() }
}
class Node derives Base, Counter<Node> impl Weighted {
fn weight(const self) -> i32 { return 5 }
}
The bound may also reach the source through a chain of bases (X derives Stmts<X>, Stmts<T> derives Rav<T> lets a *Rav<X> cast down to *X). Under a : type parameter the same cast is written as a static_cast and checked by the C++ compiler per instantiation. The cast cannot drop const; without such a bound (none, an impl bound, or a derives bound naming another class) a pointer cast between unrelated types is still an error. As in C++, the cast trusts that the object really is an X: a class that names Counter<Other> for some other Other that derives Counter<Other> makes it undefined.
Raw pointer cast
Casting to unsafe *T reinterprets the pointer with no type checking equivalent to C++'s reinterpret_cast. The compiler performs no validation.
Casting a safe pointer to a raw pointer *T as unsafe *U requires an unsafe block. This is the point where Tether loses provenance: after the cast the compiler can no longer relate the pointer to the allocation it came from, so the loss is made visible at the source:
var ptr: *i32 = &some_value
unsafe {
var unsafe *raw = ptr as unsafe *void // provenance loss: safe pointer to raw
var unsafe *back = raw as unsafe *i32 // reinterpret caller must ensure correctness
}
Casting between unsafe *T types needs no unsafe block there is no provenance left to lose. The result is the same pointer value with a different type no runtime check, no adjustment.
Raw pointer casts bypass Tether's safety guarantees. Casting to unsafe *void erases type information permanently the compiler cannot verify the correctness of a subsequent cast back. Use only for C/C++ interop, custom allocators, and other low-level scenarios.
Pointer to integer
Casting a pointer to an integer extracts the numeric address. This is safe and requires no unsafe block reading an address cannot violate memory safety on its own, and the resulting integer carries no provenance:
var ptr: *i32 = &some_value
var addr = ptr as usize // ok: numeric address
usize is the only permitted target it is the one integer type guaranteed to hold a full address. Casting a pointer directly to any other integer is a compile error, not a warning, u64 and isize included even on a target where they are as wide (SC026E, with a fix-it inserting usize as): silently discarding the high bits of an address is never what the programmer meant, and a cast that compiles on one target must not truncate on another.
// var truncated = ptr as u8 // compile error: cannot cast pointer to u8
var low = ptr as usize as u8 // ok: address, then explicit truncation
If you genuinely want the low byte of an address (tag bits, alignment checks), spell it as two casts. The second cast is an ordinary numeric narrowing and reads as deliberate.
Integer to pointer
Casting an integer to a pointer fabricates a pointer from a numeric address. The result must be an unsafe pointer safe pointers require provenance tracking that an integer cannot provide and the cast requires an unsafe block. The integer carries no provenance, so the cast invents one that Tether has no way to verify:
var addr: usize = 0x7FFE_0000_1000
unsafe {
var unsafe *ptr = addr as unsafe *i32 // ok: fabricating provenance
}
// var bad = addr as *i32 // compile error: cannot create safe pointer from integer
This is the inverse of ptr as usize, and the asymmetry is deliberate: discarding provenance is harmless, inventing it is not.
Pointer cast rules
Every pointer cast is classified by what it does to provenance the compiler's knowledge of which allocation a pointer belongs to. Casts that discard provenance are safe; casts that create or fabricate it are not:
| Cast | Status | Provenance | Why |
|---|---|---|---|
ptr as usize | Allowed, no unsafe | Discarded | Reading an address cannot cause UB on its own |
ptr as u8 (any narrower int) | Compile error | Narrowing an address is never intended write ptr as usize as u8 | |
n as unsafe *T | Allowed, requires unsafe block | Fabricated | Tether cannot verify an address that came from an integer |
n as *T | Compile error | Safe pointers require provenance an integer cannot supply | |
*T as unsafe *U | Allowed, requires unsafe block | Lost | The provenance-loss point keep it visible |
unsafe *T as unsafe *U | Allowed, no unsafe | Already absent | Nothing left to lose |
*Derived as *Base | Implicit | Preserved | Same allocation, adjusted offset |
*Base as *Derived | Allowed, runtime check | Preserved | Vtable check, crashes (as *Derived) or yields null (as *Derived?) |
&self as *X in class <X derives B<X>> B | Allowed, no runtime check | Preserved | The derives bound proves the relation; compiled to static_cast |
The two error rows are deliberate refusals rather than warnings. ptr as u8 has an honest spelling (ptr as usize as u8) that says the same thing in two steps, and n as *T has no honest spelling at all a safe pointer's guarantees cannot be reconstructed from a number.
unsafe blocks appear on exactly the rows where Tether stops being able to reason about the pointer. See Unsafe for the boundary model and Tether for what provenance tracking buys.
Enum Casts
Plain enums
Plain enums can be cast to their underlying integer type and back:
enum Direction derives u8 {
North = 0,
East = 1,
South = 2,
West = 3,
}
var raw = Direction::North as u8 // 0
var dir = 2u8 as Direction // Direction::South
A plain enum is closed unless it is declared @open, and casting an integer that is no variant's value into a closed enum fails: with a constant operand it is a compile error (SC001E), otherwise the program crashes when the cast runs (a message and an abort, like a failed bounds check; no try catches it). The check is a compare on the integer before the cast (v >= N when the values are 0 to N-1), which the compiler drops when it can see the value. An @open enum, and every enum imported from C or C++, takes any value of its underlying type unchecked.
The cast target must match the underlying type. Direction::North as i32 requires the enum to be backed by i32, or an intermediate cast: Direction::North as u8 as i32.
ADT enums
ADT enums cannot be cast to integers. The discriminant tag is an internal implementation detail. If you need the tag value, expose it through an extend method:
enum <T> ParseResult {
Success { value: T, consumed: i32 },
Error { message: string },
EndOfInput,
}
// ParseResult::Success { ... } as u32 // compile error: ADT enums cannot be cast to integers
extend <T> ParseResult<T> {
fn tag(const self) -> u32 {
match self {
case .Success { 0 }
case .Error { 1 }
case .EndOfInput { 2 }
}
}
}
Nullable to Non-Nullable
Casting a nullable value T? to its underlying type T is a compile error:
var x: i32? = null
// var y = x as i32 // compile error: cannot cast i32? to i32
// // use `x ?? <default>` or `unwrap!(x)`
There is no collapsing cast in Kairo. A cast that quietly produced a default-constructed value on null would fabricate a value the program never computed which is precisely what the nullable system exists to prevent. The null case has to be answered, not erased, and the two honest answers already have syntax:
| Intent | Write | On null |
|---|---|---|
| Supply a fallback | x ?? 0 | Yields the fallback |
| Assert non-null | unwrap!(x) | Aborts the program (not catchable) |
Both read at the call site as a decision about null. x as i32 reads as a type conversion and hides one.
var count: i32? = lookup_count()
var n = count ?? 0 // explicit: absent means zero
var m = unwrap!(count) // explicit: absent is a bug, abort
See Nullables for the rest of the nullable operations (?., ??, unwrap!(), null checking).
User-Defined Conversions (op as)
Types can define custom conversions by overloading the op as operator:
class Temperature {
var celsius: f64
fn Temperature(self, c: f64) { self.celsius = c }
fn op as(self) -> f64 {
return self.celsius
}
fn op as(self) -> string {
return f"{self.celsius}C"
}
}
var temp = Temperature(100.0)
var f = temp as f64 // 100.0
var s = temp as string // "100.0C"
op as can be overloaded for multiple target types. The compiler selects the overload based on the target type in the as expression, which must be exactly one overload's result: no second conversion follows a user one, so x as f64 with only op as -> i32 declared is an error. Two op as with the same target are one member declared twice. op as must take only self as a parameter and return the target type. In a generic class, fn op as(self) -> T converts to whatever the instance binds T to (Wrap<i64> as i64), and an interface may require several op as, one per target.
Marking an op as with @implicit lets it apply without as where its exact result type is wanted. See Implicit op as.
See Operators for the full operator overloading reference.
Conversion Through a Constructor
x as T is also a conversion when T declares a constructor with one parameter that accepts x's type. The cast calls that constructor:
class Celsius {
var value: f64
fn Celsius(self, v: f64) { self.value = v }
}
var c = 21.5 as Celsius // calls Celsius(21.5)
The standing example is slice to vector. A vector has a constructor that takes a slice, so the cast copies the elements into a new vector:
var s: [i32;] = [1, 2, 3]
var v = s as [i32] // allocates and copies
If the source type declares op as for T and T also has a matching constructor, op as wins.
Cast Summary
| Cast | Syntax | Safety | Behavior on failure |
|---|---|---|---|
| Numeric widening | Implicit | Safe | N/A |
| Numeric narrowing | x as i8 | Truncates | Low bits kept |
| Float to int | x as i32 | Truncates/saturates | Saturates on overflow |
| Int to float | x as f64 | May lose precision | Rounded |
| Derived-to-base ptr | Implicit | Safe | N/A |
| Base-to-derived ptr (asserting) | ptr as *Derived | Runtime check, warns | Crashes |
| Base-to-derived ptr (checked) | ptr as *Derived? | Runtime check | Returns null |
| Safe to raw pointer | ptr as unsafe *T | Requires unsafe block | Provenance loss |
| Raw to raw pointer | raw as unsafe *T | No check | Reinterpret |
Pointer to usize | ptr as usize | Safe | Address value |
| Pointer to narrower int | ptr as u8 | Compile error | Use ptr as usize as u8 |
| Integer to pointer | n as unsafe *T | Requires unsafe block | Fabricated provenance |
| Plain enum to int | e as u8 | Safe | Discriminant value |
| Int to closed plain enum | n as Direction | Error (constant) / crash (runtime) if no match | Checked; declare @open to skip |
Int to @open or imported enum | n as Signal | Safe | Any value of the underlying type |
| ADT enum to int | N/A | Compile error | Use extend method |
| User-defined | x as TargetType | Depends on op as | Calls user code |
| Through a constructor | x as T | Depends on the constructor | Calls T's one-parameter constructor |
| Slice to vector | s as [T] | Safe | Allocates and copies |
T to T? | Implicit | Safe | N/A |
T? to T | Not a cast | Compile error | Use ?? or unwrap!() |
Casts Not in Kairo
| C++ Cast | Kairo Equivalent |
|---|---|
static_cast<T>(x) | x as T |
dynamic_cast<T*>(p) | p as *T? (checked) or p as *T (asserting) |
reinterpret_cast<T*>(p) | p as unsafe *T |
const_cast<T*>(p) | Not supported const cannot be stripped at runtime |