Casting

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


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.

Caution

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:

CastStatusProvenanceWhy
ptr as usizeAllowed, no unsafeDiscardedReading an address cannot cause UB on its own
ptr as u8 (any narrower int)Compile errorNarrowing an address is never intended write ptr as usize as u8
n as unsafe *TAllowed, requires unsafe blockFabricatedTether cannot verify an address that came from an integer
n as *TCompile errorSafe pointers require provenance an integer cannot supply
*T as unsafe *UAllowed, requires unsafe blockLostThe provenance-loss point keep it visible
unsafe *T as unsafe *UAllowed, no unsafeAlready absentNothing left to lose
*Derived as *BaseImplicitPreservedSame allocation, adjusted offset
*Base as *DerivedAllowed, runtime checkPreservedVtable check, crashes (as *Derived) or yields null (as *Derived?)
&self as *X in class <X derives B<X>> BAllowed, no runtime checkPreservedThe 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:

IntentWriteOn null
Supply a fallbackx ?? 0Yields the fallback
Assert non-nullunwrap!(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

CastSyntaxSafetyBehavior on failure
Numeric wideningImplicitSafeN/A
Numeric narrowingx as i8TruncatesLow bits kept
Float to intx as i32Truncates/saturatesSaturates on overflow
Int to floatx as f64May lose precisionRounded
Derived-to-base ptrImplicitSafeN/A
Base-to-derived ptr (asserting)ptr as *DerivedRuntime check, warnsCrashes
Base-to-derived ptr (checked)ptr as *Derived?Runtime checkReturns null
Safe to raw pointerptr as unsafe *TRequires unsafe blockProvenance loss
Raw to raw pointerraw as unsafe *TNo checkReinterpret
Pointer to usizeptr as usizeSafeAddress value
Pointer to narrower intptr as u8Compile errorUse ptr as usize as u8
Integer to pointern as unsafe *TRequires unsafe blockFabricated provenance
Plain enum to inte as u8SafeDiscriminant value
Int to closed plain enumn as DirectionError (constant) / crash (runtime) if no matchChecked; declare @open to skip
Int to @open or imported enumn as SignalSafeAny value of the underlying type
ADT enum to intN/ACompile errorUse extend method
User-definedx as TargetTypeDepends on op asCalls user code
Through a constructorx as TDepends on the constructorCalls T's one-parameter constructor
Slice to vectors as [T]SafeAllocates and copies
T to T?ImplicitSafeN/A
T? to TNot a castCompile errorUse ?? or unwrap!()

Casts Not in Kairo

C++ CastKairo 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

See also