Language Ref (1 Page)

Kairo Language Reference

The complete Kairo language reference, assembled into one page. Use the Language/ section for each topic seprated.

Source: https://www.kairolang.org/docs/ · Generated: 2026-06-05

Contents

  • Primitives Built-in data types in Kairo integers, floats, booleans, characters, strings, pointers, collections, and their semantics.
  • Variables & Bindings Variable declarations, constants, static, type inference, shadowing, destructuring, const semantics, and scope rules in Kairo.
  • Operators Arithmetic, comparison, logical, bitwise, assignment, range, null-safe access, operator overloading, and precedence rules in Kairo.
  • Control Flow Conditionals, match, loops, labeled breaks, try/catch/finally, panic, assert, jumps, compile-time branching, and branch hints in Kairo.
  • Functions Function declarations, parameters, return types, overloading, modifiers, generics, variadic functions, and calling conventions in Kairo.
  • Closures Anonymous functions, capture modes, lambda syntax, and how closures interact with AMT in Kairo.
  • Classes Class declarations, constructors, destructors, lifecycle categories, inheritance, virtual dispatch, abstract classes, generics, visibility, memory layout, and out-of-line definitions in Kairo.
  • Structures Struct declarations, aggregate initialization, field visibility, generics, extends, layout control, and how structs differ from classes in Kairo.
  • Enums Enum declarations, discriminants, underlying types, ADT variants with payloads, generics, extends, and enum semantics in Kairo.
  • Unions Untagged union declarations, memory overlay semantics, trivial-type restriction, generics, and layout rules in Kairo.
  • Interfaces Interface declarations, structural conformance, generic interfaces, interface inheritance, operator and constructor requirements, and zero-cost contract semantics in Kairo.
  • Type System Type aliases, type inference, implicit conversions, subtyping, TypeInfo, typeof, nullable nesting, void and never types, function types, and type identity in Kairo.
  • Casting Explicit type conversions with as, numeric truncation, pointer casts, downcasting, nullable collapsing, user-defined conversions, and cast safety rules in Kairo.
  • Requires Clauses Compile-time constraints on functions, types, and interfaces in Kairo.
  • Where Clauses Where clauses attach runtime-conditional constraints to declarations. They are evaluated at runtime, and can be used for dispatching on values or types.
  • Pointers & Raw Pointers Safe pointers, unsafe raw pointers, null semantics, pointer arithmetic, smart pointer promotion, void pointers, double pointers, and pointer safety rules in Kairo.
  • Ownership Transfer semantics, pointer aliasing, closure captures, and destruction order in Kairo’s ownership model.
  • AMT Automatic Memory Tracking full-program lifetime analysis, smart pointer promotion, allocator integration, and compile-time safety guarantees in Kairo.
  • Unsafe Unsafe blocks, unsafe pointers, unsafe function overloads, AMT suspension, forget, and the safety boundary model in Kairo.
  • Panic The panic specifier, Panickable return type, try/catch exhaustiveness, panic propagation, error types, and zero-cost codegen in Kairo.
  • Compile-Time Eval Eval variables, eval functions, eval if, eval for, compile-time evaluation rules, restrictions, and interaction with generics in Kairo.
  • Modules Module system, imports, file-to-module mapping, directories as libraries, namespaces, visibility, module extending, and C++ header imports in Kairo.
  • Extends Extend blocks for adding methods, operators, static functions, and interface conformance to structs, enums, and classes in Kairo.
  • Attributes AST-level code transformations, attribute definitions, arguments, expansion order, overloading, built-in attributes, and the std::AST API in Kairo.
  • Macros Token-level macros, macro definitions, built-in macros, variadic helpers, source location, diagnostics, code generation, and macro hygiene in Kairo.
  • Concurrency Async/await, spawn, yield, coroutines, atomic types, thread-local storage, and synchronization primitives in Kairo.
  • C & C++ Interoperability Native bidirectional interop between Kairo and C/C++ FFI declarations, the kcc driver, inline C++, pointer safety, templates, exceptions, and ABI details.

Primitives

https://www.kairolang.org/docs/language/primitives/

Primitives

Kairo’s primitive types are built into the language and available without imports. They map directly to hardware-supported representations where possible, falling back to software emulation for extended-width types.


Integers

All integer types have a fixed, guaranteed size. The default integer type is i32 if a literal doesn’t fit in i32, the compiler promotes it to the smallest signed type that can hold the value, up to i512.

TypeSizeDescriptionC++ Equivalent
u81 byteUnsigned 8-bit integeruint8_t
u162 bytesUnsigned 16-bit integeruint16_t
u324 bytesUnsigned 32-bit integeruint32_t
u648 bytesUnsigned 64-bit integeruint64_t
u12816 bytesUnsigned 128-bit integer__uint128_t
u25632 bytesUnsigned 256-bit integer
u51264 bytesUnsigned 512-bit integer
i81 byteSigned 8-bit integerint8_t
i162 bytesSigned 16-bit integerint16_t
i324 bytesSigned 32-bit integerint32_t
i648 bytesSigned 64-bit integerint64_t
i12816 bytesSigned 128-bit integer__int128_t
i25632 bytesSigned 256-bit integer
i51264 bytesSigned 512-bit integer
usizePlatform-dependentUnsigned, pointer-width integersize_t
isizePlatform-dependentSigned, pointer-width integerptrdiff_t

Integer literals default to signed. Use a type suffix to specify:

var a = 42          // i32 (default)
var b = 42u8        // u8
var c = 42i64       // i64
var d = 1_000_000   // i32 underscores are ignored, use freely as separators
var e = 0xFF        // i32 hexadecimal
var f = 0b1010_0011 // i32 binary
var g = 0o77        // i32 octal

Overflow behavior

Unsigned integer overflow wraps around (modular arithmetic). Signed integer overflow behavior depends on the build mode:

  • Debug: crashes with a diagnostic.
  • Release: wraps around silently.

This matches Rust’s overflow model and catches bugs during development without paying for checks in production.

Extended-width integers (u128-u512, i128-i512)

If the target hardware supports wide registers (e.g., AVX-512), these types map directly to hardware. Otherwise, the compiler stores them as structs of smaller integers and emits SIMD-accelerated arithmetic when available, falling back to scalar multi-word operations.

Extended-width integers are always stack-allocated they are value types, not heap-allocated objects.


Floating-Point

All floating-point types follow the IEEE 754 standard. The default float type is f64 if a literal doesn’t fit in f64, the compiler promotes to the smallest float type that can hold the value, up to f512.

TypeSizePrecisionC++ Equivalent
f162 bytesHalf (IEEE 754-2008)_Float16
f324 bytesSinglefloat
f648 bytesDoubledouble
f12816 bytesQuadruple__float128
f25632 bytesExtended (software)
f51264 bytesExtended (software)
var x = 3.14        // f64 (default)
var y = 3.14f32     // f32
var z = 1.0e-10     // f64 scientific notation

Overflow produces inf, underflow produces 0.0. Operations that produce NaN (e.g., 0.0 / 0.0, sqrt(-1.0)) propagate NaN per IEEE 754 no crash, no trap. Check for NaN explicitly with std::is_nan() when needed.

Note

f256 and f512 are not natively supported on any current hardware and are implemented entirely in software, using SIMD instructions when available. Like extended-width integers, they are stack-allocated value types. Expect significantly lower performance compared to hardware-backed float types.


Implicit Conversions

Integer and float types can be implicitly widened i32 to i64, f32 to f64 but narrowing conversions require an explicit cast. See Casting for details.

var a: i32 = 42
var b: i64 = a      // ok: implicit widening

var c: i64 = 1000
var d: i8 = c        // compile error: narrowing requires explicit cast
var e: i8 = c as i8  // ok: explicit, may truncate

Bool

TypeSizeC++ Equivalent
bool1 bytebool
var flag = true
var other = false

bool is 1 byte in memory (not 1 bit) for addressability. Only true and false are valid values no implicit conversion from integers.


Char

TypeSizeDescriptionC++ Equivalent
char4 bytesUnicode scalar value (U+0000-U+10FFFF)char32_t

A char holds a single decoded Unicode codepoint. It is always 4 bytes regardless of which codepoint it represents.

var letter = 'A'
var emoji = '😶‍🌫'
var cjk = '漢'
Note

char is the decoded representation of a single codepoint. Strings store text as UTF-8 bytes internally, not as arrays of char. See Strings below.


Byte

TypeSizeC++ Equivalent
byte1 bytestd::byte

byte is semantically identical to u8 in size and representation but restricted to bitwise operations and comparisons no arithmetic. It represents raw data where the value is not meant to be interpreted as a number.

var b: byte = 0xFF
var mask: byte = 0x0F
var result = b & mask   // ok: bitwise AND
// var bad = b + mask   // compile error: arithmetic not allowed on byte

Strings

TypeSizeEncodingC++ Equivalent
string32 bytesUTF-8std::string

Strings are UTF-8 encoded byte sequences. The string type uses small string optimization (SSO) strings up to 23 bytes are stored inline without a heap allocation. Longer strings are heap-allocated.

var greeting = "Hello, Kairo! 📣"   // 18 UTF-8 bytes fits in SSO
var name = "Dhruvan"                 // 7 bytes SSO

Because UTF-8 is a variable-width encoding, indexing by codepoint (s[i]) is O(1) amortized (since it looks up the nearest codepoint boundary and decodes from there), while indexing by byte (s.bytes[i]) is O(1) always, but returns raw bytes, not characters.

var s = "Hello 📣"
s.bytes[0]    // byte: 0x48 ('H') O(1)
s[6]          // char: '📣' codepoint indexing, O(1) **amortized**

for ch in s {
    // ch is char decoded codepoint, yielded sequentially
}
Important

The stdlib API for strings is still being finalized. Detailed documentation for string methods will be added in a future update.


Void

TypeSizeC++ Equivalent
void0 bytesvoid

void indicates the absence of a value. It can be used as a function return type and as the target of an unsafe pointer (unsafe *void), but it cannot be used as a type parameter or variable type.

fn log(msg: string) -> void {
    // ...
}

var opaque: unsafe *void = get_handle()  // raw, untyped pointer

Pointers

TypeSizeDescription
*T8 bytesSafe pointer non-null, compiler-tracked
unsafe *T8 bytesRaw pointer nullable, no safety checks

*T is a thin pointer (8 bytes). It is non-null by construction and supports pointer arithmetic when the compiler can track its provenance via AMT. See Pointers for full details.

unsafe *T is a raw C-style pointer with no compiler tracking. It can be null, and dereferencing a null

unsafe *T is undefined behavior. Use unsafe *T for C/C++ interop, custom allocators, and other low-level scenarios. See Pointers and Unsafe for full details.

var x = 42
var p: *i32 = &x                // safe pointer to x
var q: unsafe *i32 = unsafe &x  // raw pointer, no tracking

Collections

Collections are built-in generic types with literal syntax. All are heap-allocated except fixed-size arrays.

Vectors [T]

A growable, owning, contiguous array. Layout: ptr + len + cap (24 bytes).

var nums: [i32] = [1, 2, 3]
nums.push(4)
nums[0]    // 1 bounds-checked

When borrowed as const [T], a vector acts as a non-owning view with cap set to zero no growth permitted, no deallocation on drop. See Ownership for borrowing semantics.

Arrays [T; N]

A fixed-size array allocated inline (stack or struct). N must be a compile-time constant.

var rgb: [u8; 3] = [255, 128, 0]
// rgb.push(42)  // compile error: fixed size

Maps {K: V}

A hash map from keys of type K to values of type V.

var ages: {string: i32} = {"Alice": 30, "Bob": 25}
ages["Charlie"] = 35

Sets {T}

A hash set of unique elements.

var primes: {i32} = {2, 3, 5, 7, 11}

Tuples (T1, T2, ...)

A fixed-size, heterogeneous, ordered group of values. Stored contiguously with padding for alignment.

var point: (f64, f64) = (1.0, 2.0)
var record: (i32, string, bool) = (42, "Answer", true)

Function Pointers fn (T1, T2, ...) -> R

A pointer to a function with the given signature. Platform-dependent size.

fn add(a: i32, b: i32) -> i32 { return a + b }
var operator: fn (i32, i32) -> i32 = add
op(3, 4)  // 7
Important

The stdlib API for vectors, maps, and sets is still being finalized. Detailed method documentation will be added in a future update.


Platform-Dependent Sizes

usize and isize match the target platform’s pointer width:

Platformusize / isize
64-bit8 bytes
32-bit4 bytes
16-bit2 bytes

Summary

// Integers
var a = 42              // i32
var b = 42u8            // u8
var c = 0xFF            // i32 (hex)
var d = 0b1010          // i32 (binary)
var e = 1_000_000       // i32 (underscores as separators)

// Floats
var f = 3.14            // f64
var g = 3.14f32         // f32

// Bool, char, string
var h = true            // bool
var i = '📣'            // char (4 bytes, Unicode scalar)
var j = "Hello, Kairo!" // string (UTF-8, SSO up to 23 bytes)

// Byte
var k: byte = 0xFF      // raw byte, no arithmetic

// Pointers
var x = 42
var p = &x              // *i32
var q: unsafe *i32 = unsafe &x

// Collections
var nums: [i32] = [1, 2, 3]                           // vector
var rgb: [u8; 3] = [255, 128, 0]                      // array
var ages: {string: i32} = {"Alice": 30, "Bob": 25}    // map
var primes: {i32} = {2, 3, 5, 7}                      // set
var point: (f64, f64) = (1.0, 2.0)                    // tuple

// Function pointer
fn add(a: i32, b: i32) -> i32 { return a + b }
var operator: fn (i32, i32) -> i32 = add

Variables & Bindings

https://www.kairolang.org/docs/language/variables/

Variables & Bindings

KeywordMutableStorageInitializerType annotation
varYesAutomaticOptional (default-initialized)Optional (inferred)
constNoAutomaticRequiredOptional (inferred)
staticYesStaticOptionalRequired
evalNoCompile-timeRequiredOptional (inferred)

Variables in Kairo are mutable by default. const bindings at local scope require an initializer, but class-level const members can be left uninitialized at declaration and assigned exactly once in the constructor body. See Classes for details.

var x = 42
var y: string = "Hello, World!"

Multi-variable declarations on a single line are not supported. Each binding gets its own statement.


Naming Conventions

Kairo recommends the following conventions. They are not enforced as hard errors, but the compiler emits readability warnings when they are not followed.

KindConventionExample
Variablessnake_casemy_value
Functionssnake_caseget_name
ConstantsUPPER_SNAKE_CASEMAX_SIZE
Types (classes, structs, enums)PascalCaseHttpServer

Type Inference

The compiler infers the type from the initializer when no annotation is provided. Explicit annotations are optional but can be used to force a specific type.

var a = 42          // inferred as i32
var b = 3.14        // inferred as f64
var c: u8 = 42      // explicitly u8
var d = "hello"     // inferred as string

See Primitives for default type rules integer literals default to i32, float literals default to f64.


Declaration Shorthands

When the type is fully determined by the initializer, the * or unsafe * qualifier can be placed on the variable name instead of writing out the full type annotation:

var *ptr = std::create<i32>(10)
// equivalent to: var ptr: *i32 = std::create<i32>(10)

var unsafe *raw = unsafe std::alloc<i32>(sizeof i32 * 1)
// equivalent to: var raw: unsafe *i32 = unsafe std::alloc<i32>(sizeof i32 * 1)

The nullable shorthand ? works the same way:

var name? = get_name()
// equivalent to: var name: string? = get_name()

These are purely syntactic sugar the compiler infers the full type from the right-hand side. The long form is always valid and preferred when clarity matters.


Default Initialization

All types with a default constructor are zero-initialized when declared without an initializer. Integers default to 0, booleans to false, strings to "", collections to empty.

var a: i32        // 0
var b: string     // ""
var c: bool       // false
var d: [i32]      // []

If a type has its default constructor explicitly deleted, declaring a variable without an initializer produces a compiler warning for uninitialized state.

class Token {
    fn Token() = delete
}

var t: Token   // warning: t is uninitialized (suppress with @no_warn(UNINIT))

Constants

Immutable bindings use const. A const variable cannot be reassigned after initialization.

const x = 42
const name: string = "Kairo"
// x = 100  // compile error: x is const

Unlike var, a const binding without an initializer is a hard compile error constants must always be explicitly initialized.

const x: i32       // compile error: const requires an initializer

Compile-Time Constants (eval)

For values that must be resolved at compile time, use eval. This is equivalent to C++‘s consteval the expression must be evaluable at compile time, and a compile error is raised if it cannot be.

eval PI = 3.14159
eval MAX_SIZE = 1024 * 1024

eval bindings are implicitly const. See Eval for full details on compile-time evaluation, including eval functions and restrictions.


Static Variables

static declares a binding with static storage duration it lives for the entire program. Unlike var, static requires an explicit type annotation.

static counter: i32 = 0

static can be combined with const for immutable static data. See Modules for static at module scope.


Shadowing

Constants can shadow previous bindings of the same name. Each const declaration creates a new binding the previous one becomes inaccessible.

const raw = "42"
const raw = std::parse<i32>(raw)   // shadows string with i32 valid
const raw = raw * 2                // shadows again valid

Mutable variables cannot shadow. Redeclaring a var with the same name in the same scope is a compile error.

var x = 42
var x = 100   // compile error: var cannot shadow
Note

Shadowing is restricted to const because shadowing a mutable variable is almost always a bug you likely meant to reassign (x = 100) rather than redeclare.


Destructuring

Tuples and structs can be destructured into individual bindings. Use parentheses for tuples and curly braces for structs.

Tuples

var point = (10, 20, 30)
var (x, y, z) = point
// x = 10, y = 20, z = 30

Structs

struct Color {
    var r: u8
    var g: u8
    var b: u8
}

var color = Color { r: 255, g: 128, b: 0 }
var {r, g, b} = color
// r = 255, g = 128, b = 0

Destructured names must match the field names in the struct definition. The binding order follows the definition order.

Discards

Use _ to ignore values you don’t need. _ is not a variable it cannot be referenced after destructuring.

var (x, _, z) = (1, 2, 3)     // discard the second element
var {r, _, _} = color         // keep only r

for _ in 0..10 {
    // loop body doesn't need the index
}

The compiler will error if you attempt to use _ as a value.


Nullable Types

T? is sugar for Nullable<T> a compiler-intrinsic tagged union that holds either a value of type T or null. It applies to any type, not just pointers.

var name: string? = get_name()   // may be null
var count: i32? = null           // explicitly null

Shorthand declaration

The ? suffix on the variable name infers the nullable type from the initializer:

var name? = get_name()           // inferred as string?
// var x? = null                 // compile error: no underlying type to infer

Null checking

The ? suffix on a variable name in a condition checks for non-null:

var result? = find_user("alice")

if result? {
    // result is non-null here
    std::println(result.name)
}

Accessing members on an unchecked nullable is a compile error:

var user? = find_user("alice")
user.name   // compile error: user has not been null-checked

Safe access (?.)

The ?. operator calls a method or accesses a member only if the value is non-null. If null, it default-constructs the type and calls the method on that instead:

var config? = load_config()
config?.timeout   // if null, uses Config().timeout

If the type is not trivially default-constructible (deleted default constructor, contains atomic types), ?. is a compile error.

Null coalescing (??)

?? provides a fallback value when the left side is null. Both sides must be the same underlying type:

var value = get_f32() ?? 0.212   // value is f32, not f32?
var name = get_name() ?? "anonymous"

Force unwrap

unwrap!() extracts the value or panics if null:

var x = unwrap!(maybe_value)   // panics if null

unwrap!() desugars to a null check followed by a panic on the null path. The caller must be inside a try block or in a function with the panic specifier. See Panic for the panic model.

const interaction

const on a nullable binding works the same as on any other type the binding cannot be reassigned after initialization:

const x: i32? = 42
x = null   // compile error: x is const
x = 100    // compile error: x is const

var y: i32? = null
y = 42     // ok: var is mutable
y = null   // ok: can go back to null

Pointers and nullability

*T is non-null by construction, it cannot hold &null. No null checks are needed on dereference because the pointer is always valid:

var x = 42
var ptr: *i32 = &x
*ptr = 10              // guaranteed valid, no check

var bad: *i32 = &null  // compile error: *T is non-null

To represent a pointer that might not exist, use *T?. This wraps the pointer in the standard Nullable<T> system with full support for ?, ?., ??, and unwrap!():

var ptr: *i32? = find_pointer()

if ptr? {
    std::println(*ptr)          // compiler knows ptr is non-null here
}

var val = ptr ?? &fallback      // use fallback if null
var forced = unwrap!(ptr)       // panics if null

For raw nullable pointers with no compiler tracking, use unsafe *T with manual null comparison:

var raw: unsafe *i32 = &null
if raw != &null {
    std::println(*raw)
}

See Pointers for the full pointer model and how AMT tracks pointer provenance.


The const Binding Rule

const in Kairo follows a strict left-to-right binding rule: one const applies to the thing immediately to its right. This eliminates the ambiguity that plagues C/C++ const placement.

On simple variables

const x: i32 = 42
//    ^ x cannot be reassigned
//       ^^^ i32 is the type immediately right of const the value is immutable

On pointers

const on the binding and const on the pointed-to type are independent axes:

var ptr: *i32 = &x
// ptr is mutable, *ptr is mutable can reassign ptr and modify the target

const ptr: *i32 = &x
// ptr is const cannot reassign ptr to point elsewhere
// *ptr is mutable can still modify the target value
*ptr = 10    // ok: allowed
ptr = &y     // compile error:

const ptr: *const i32 = &x
// ptr is const cannot reassign
// *ptr is const cannot modify the target
*ptr = 10    // compile error:
ptr = &y     // compile error:

The rule scales to any depth:

const ptr: *const *i32 = &ptr2
ptr = &ptr3    // ptr is: const
*ptr = &x      // *ptr is: const
**ptr = 10     // ok: the i32 at the end is not const

A practical example

Consider a configuration object that should be readable through a pointer but never modified:

class Config {
    pub var host: string
    pub var port: i32

    fn Config(self, host: string, port: i32) {
        self.host = host
        self.port = port
    }

    fn address(const self) -> string {
        return f"{self.host}:{self.port}"
    }

    fn set_port(self, port: i32) {
        self.port = port
    }
}

var config = Config("localhost", 8080)

const ptr: *const Config = &config
ptr->address()       // ok: address() is a const method
ptr->set_port(9090)  // compile error: set_port() mutates, ptr points to const Config

// in most cases tho you would only want this:
var ptr: *const Config = &config
// cause you would have a const type but still be able to reassign the pointer if needed.

On types

const applied to a type restricts the instance to const methods only methods not marked const cannot be called.

const server = Config("localhost", 8080)
server.address()       // ok: const method
server.set_port(9090)  // compile error: set_port() is not const
Warning

var x: const i32 = 42 is a compile error. The const binding rule is strictly left-to-right from the declaration keyword position. Use const x: i32 = 42 instead. This avoids the const int* x vs int* const x confusion that C++ is notorious for.


Scope and Lifetime

Variables are block-scoped and destroyed at the end of their enclosing block.

{
    var x = 42
    // x is live here
}
// x is no longer accessible

AMT performs full-program analysis to track lifetimes automatically no lifetime annotations are required. If AMT determines that a pointer outlives its referent and cannot automatically promote the pointer (to a shared, weak, or unique smart pointer), it emits a compile error.

var x = 10
var y = &x
{
    *y = 15    // ok: x is still alive, y is valid
}
std::println(*y)      // AMT error: y's safety cannot be guaranteed at this point

See AMT and Ownership for the full lifetime and borrowing model.


All four declaration keywords var, const, static, eval work in both local and class/struct scope.


Operators

https://www.kairolang.org/docs/language/operators/

Operators

Kairo’s operators follow C-style precedence and semantics with a few additions: exponentiation (^^), deep equality (===), null-safe access (?., ?->), and ranges (.., ..=). All operators can be overloaded for user-defined types.


Arithmetic

OperatorDescriptionExample
+Addition / unary plusa + b, +a
-Subtraction / unary negationa - b, -a
*Multiplicationa * b
/Divisiona / b
%Modulo (remainder)a % b
^^Exponentiation2 ^^ 8 -> 256

Integer division truncates toward zero, matching C++.

^^ works on any integer combination (i32 ^^ i32, u64 ^^ u8, etc.) and on float bases with integer exponents (f64 ^^ i32). Overflow follows the same rules as other arithmetic see below.

Integer overflow

Unsigned overflow wraps around (modular arithmetic). Signed overflow depends on the build mode:

  • Debug: crashes with a diagnostic.
  • Release: wraps around silently.

See Primitives for full details on numeric type behavior.

Floating-point overflow

Overflow produces inf, underflow produces 0.0. Operations that produce NaN (e.g., 0.0 / 0.0, sqrt(-1.0)) propagate NaN per IEEE 754 no crash, no trap. Check explicitly with std::is_nan().


Comparison

OperatorDescriptionExample
==Equalitya == b
!=Inequalitya != b
<Less thana < b
>Greater thana > b
<=Less than or equala <= b
>=Greater than or equala >= b
<=>Three-way comparison (spaceship)a <=> b
===Deep equalitya === b

<=> returns an ordering value, matching C++20 spaceship operator semantics.

== vs ===

On pointers, == compares addresses whether two pointers point to the same memory location. === dereferences both pointers and compares the values they point to. === is defined only on *T, which is non-null by construction, so no null check is needed. For a pointer that might be null, use *T? and the nullable operators (?, ??) before comparing.

var z = 42
var x = &z
var y = &z

x == y    // true same address
x === y   // true dereferenced values are equal
var a = 42
var b = 42
var p = &a
var q = &b

p == q    // false different addresses
p === q   // true both point to 42

For user-defined types, === can be overloaded to implement deep equality. By default it is only defined for pointer types.


Logical

OperatorDescriptionExample
&&Logical ANDa && b
||Logical ORa || b
!Logical NOT!a

Short-circuit evaluation applies: && does not evaluate the right operand if the left is false, and || does not evaluate the right operand if the left is true. This is identical to C++.


Bitwise

OperatorDescriptionExample
&Bitwise ANDa & b
|Bitwise ORa | b
^Bitwise XORa ^ b
~Bitwise NOT (complement)~a
<<Left shifta << 2
>>Right shifta >> 2

Right shift is arithmetic (sign-extending) for signed types and logical (zero-filling) for unsigned types.


Assignment

OperatorDescription
=Assignment
+=, -=, *=, /=, %=Arithmetic compound assignment
&=, |=, ^=Bitwise compound assignment
<<=, >>=Shift compound assignment

All compound assignment operators desugar to x = x op y.


Increment and Decrement

SyntaxNameBehavior
++xPrefix incrementIncrements x, returns the new value
x++Postfix incrementReturns the current value, then increments x
--xPrefix decrementDecrements x, returns the new value
x--Postfix decrementReturns the current value, then decrements x
Warning

Expressions that modify a variable multiple times without a sequence point (e.g., i = i++ + ++i) are undefined behavior. This is inherited from C++ evaluation order semantics. Avoid combining multiple side effects on the same variable in a single expression.


Ranges

OperatorDescriptionExample
..Exclusive range (end excluded)0..10 -> 0 through 9
..=Inclusive range (end included)0..=10 -> 0 through 10

Range operators produce a Range object that implements iteration. Any type that satisfies the Steppable interface can be used with range operators:

interface <T> Steppable {
    fn op l++ (self) -> Steppable   // step forward (prefix increment)
    fn op == (self, other: T) -> bool
}
Note

Types do not need to explicitly impl Steppable. If a type satisfies the required method signatures, it is automatically compatible with range operators. See Interfaces for details on structural conformance.

Ranges also work with slicing on strings and collections:

"hello"[1..4]         // "ell"
[1, 2, 3, 4][0..2]   // [1, 2]

Null-Safe Access

Kairo provides null-safe operators for working with nullable types (T?).

OperatorDescriptionExample
?.Null-safe member accessobj?.field
?->Null-safe pointer deref + member accessptr?->field
?.*Null-safe deref member pointerobj?.*member_ptr
?->*Null-safe pointer deref + member pointer derefptr?->*member_ptr

If the left-hand side is null, the entire expression evaluates to null instead of crashing.

var user: User? = find_user("alice")
var name = user?.get_name()   // string? null if user is null

The non-null equivalents follow the same pattern without the safety check:

OperatorDescription
.Member access
->Pointer dereference + member access
.*Dereference member pointer
->*Pointer dereference + member pointer dereference

Type Inspection

KeywordReturn typeDescription
sizeof TusizeSize of type T in bytes
alignof TusizeAlignment requirement of type T in bytes
typeof exprContext-dependentType identity see below

typeof has dual behavior depending on context:

var x = 42
var y: typeof x = 100     // type position compiler substitutes i32

var info = typeof x        // expression position returns a TypeInfo struct
info.get_pretty_name()     // "i32"
info.get_size()            // 4

See Type System for full TypeInfo details.


Type Casting as

as performs explicit type conversion. No implicit narrowing conversions exist in Kairo all narrowing casts must use as. Implicit widening (e.g., i32 to i64) is permitted without as.

var x: i64 = 1000
var y: i8 = x as i8     // explicit narrowing may truncate

var f: f64 = 3.14
var i: i32 = f as i32   // 3 truncates toward zero

as is overloadable for user-defined types:

class Vector2D {
    var x: f32
    var y: f32

    fn op as (self) -> string {
        return f"Vector2D({self.x}, {self.y})"
    }
}

var v = Vector2D { x: 1.0, y: 2.0 }
var s = v as string   // "Vector2D(1.0, 2.0)"

See Casting for the full conversion rules.


Operator Overloading

Operators are overloaded by defining fn op methods on a class, struct OR (via extends). The syntax mirrors the operator being defined.

Standard binary and unary operators

class Vec3 {
    var x: f32
    var y: f32
    var z: f32

    fn op + (self, other: Vec3) -> Vec3 {
        return Vec3 { x: self.x + other.x, y: self.y + other.y, z: self.z + other.z }
    }

    fn op - (self) -> Vec3 {   // unary negation
        return Vec3 { x: -self.x, y: -self.y, z: -self.z }
    }

    fn op == (self, other: Vec3) -> bool {
        return self.x == other.x && self.y == other.y && self.z == other.z
    }
}

Increment and decrement

Use the l (left/prefix) or r (right/postfix) modifier to specify which variant you are overloading. The compiler warns if the modifier is omitted.

fn op r-- (self) -> T    // postfix: x--
fn op l-- (self) -> T    // prefix:  --x
fn op l++ (self) -> T    // prefix:  ++x the ++ is on the left of the operand
fn op r++ (self) -> T    // postfix: x++ the ++ is on the right of the operand

Special operators

OperatorSignatureDescription
asfn op as (self) -> TargetTypeType conversion takes no parameters
===fn op === (self, other: T) -> boolDeep equality
in (containment)fn op in (self, other: T) -> boolif item in collection checks membership
in (iteration)fn op in (self) -> yield Tfor x in collection yields elements
deletefn op delete (self)Custom destructor called when the value goes out of scope

The two in signatures are distinguished by context: the compiler uses the -> bool variant in if expressions and the -> yield T variant in for loops.

class IntSet {
    priv var data: [i32]

    fn op in (self, value: i32) -> bool {
        // containment check: "if 5 in my_set"
        for item in self.data {
            if item == value { return true }
        }
        return false
    }

    fn op in (self) -> yield i32 {
        // iteration: "for x in my_set"
        for item in self.data {
            yield item
        }
    }
}

var s = IntSet { data: [1, 2, 3] }

if 2 in s { /* ... */ }      // calls the bool variant

for x in s {                 // calls the yield variant
    std::println(f"{x}")
}

op delete

op delete defines custom destruction logic. If not defined, the compiler generates a default destructor. If any member has a deleted destructor (fn op delete() = delete), the containing type’s destructor is also deleted and instances must be managed in an unsafe context.

class FileHandle {
    priv var fd: i32

    fn op delete (self) {
        close(self.fd)
    }
}

See AMT for details on destruction order and allocator interaction.

Caution

Overloading operators in an unsafe context is not permitted. All operator overloads must be safe.


Precedence

Operators are listed from highest precedence (tightest binding) to lowest. Operators on the same row have equal precedence and associate in the direction shown.

PrecedenceOperatorsAssociativityDescription
1::LeftScope resolution
2() [] . -> .* ->* ?. ?-> ?.* ?->*LeftPostfix / member access
3++ -- (postfix)LeftPostfix increment/decrement
4++ -- (prefix) ! ~ + - (unary) * & sizeof alignof typeofRightPrefix / unary
5^^RightExponentiation
6* / %LeftMultiplicative
7+ -LeftAdditive
8<< >>LeftBitwise shift
9<=>LeftThree-way comparison
10< <= > >=LeftRelational
11== != ===LeftEquality
12&LeftBitwise AND
13^LeftBitwise XOR
14|LeftBitwise OR
15&&LeftLogical AND
16||LeftLogical OR
17.. ..=LeftRange
18= += -= *= /= %= &= |= ^= <<= >>=RightAssignment
19inLeftContainment / iteration
20asLeftType cast
Note

== binds tighter than && and || compound conditions like a == b && c == d do not require explicit parentheses. This matches C/C++ precedence.

When in doubt, use parentheses. The compiler does not warn about precedence ambiguity, but explicit grouping improves readability.


Evaluation Order

Evaluation order of subexpressions is undefined in Kairo. Given f(a(), b()), there is no guarantee that a() executes before b(). This is inherited from C++ semantics.

Warning

Do not rely on evaluation order for correctness. Expressions with multiple side effects on the same variable in a single statement are undefined behavior.


Operators Not in Kairo

For C++ developers the following C++ operators have no equivalent in Kairo:

C++ OperatorKairo Alternative
? : (ternary)if/else expressions
, (comma operator)Not supported use separate statements
new / deletestd::create<T> / automatic via AMT, or op delete for custom destructors
typeidtypeof expr returns TypeInfo
const_cast / reinterpret_cast / static_cast / dynamic_castas for safe casts; see Casting

Control Flow

https://www.kairolang.org/docs/language/control-flow/

Control Flow

Kairo’s control flow is block-scoped and brace-delimited. Every branch, loop, and error-handling construct uses { ... } there are no single-statement bodies. Conditions are bare expressions (no parentheses required, though permitted for clarity).


Conditionals

if / else if / else

if x > 0 {
    std::println("positive")
} else if x == 0 {
    std::println("zero")
} else {
    std::println("negative")
}

Braces are mandatory on all branches. The condition must evaluate to bool no implicit conversion from integers or pointers.

if as an expression (ternary equivalent)

Kairo has no ternary ? : operator. Use if/else as an expression instead, the expression in each branch is the result:

var x = if condition { 10 } else { 20 }

var y = if a > b { a } else if a == b { 0 } else { b }

When used as an expression, the else branch is required and all branches must produce the same type. Empty branches are not permitted in expression form.

Tip

The compiler warns if else if nesting exceeds 3 levels. Consider restructuring deeply nested conditionals into a match or separate function.

Empty branches

Empty branches are legal in statement form. The compiler will not warn this is intentional for cases where only one side of a conditional has work to do:

if condition {
    // intentionally empty
} else {
    handle_false_case()
}

match

match is Kairo’s multi-way dispatch construct. It handles value matching, enum variant dispatch, ADT destructuring, struct field extraction, and range checks all with compiler-enforced exhaustiveness.

match status_code {
    case 200 { std::println("OK") }
    case 404 { std::println("Not Found") }
    case 500 { std::println("Internal Server Error") }
    default  { std::println(f"Unknown: {status_code}") }
}

Value matching

match operates on integers, strings, and booleans by comparing against literal values:

match command {
    case "start" { engine.start() }
    case "stop"  { engine.stop() }
    case "reset" { engine.reset() }
    default      { log_unknown(command) }
}

match is_valid {
    case true  { proceed() }
    case false { abort() }
}

Enum variant matching

For plain enums, use the .Variant shorthand when the type is inferred from the match operand:

match dir {
    case .North { go_up() }
    case .South { go_down() }
    case .East  { go_right() }
    case .West  { go_left() }
}

The fully qualified form Direction::North also works. The compiler verifies all variants are covered.

ADT destructuring

ADT enum variants carry payloads. Destructure them in match with var or const bindings inside parentheses. Field names must match the variant declaration:

enum <K, V> LookupResult {
    Found    { key: K, value: V },
    NotFound { key: K },
    Error    { message: string },
}

match result {
    case .Found(var key, var value) {
        std::println(f"{key} = {value}")
    }
    case .NotFound(var key) {
        std::println(f"{key} not found")
    }
    case .Error(const message) {
        // message cannot be reassigned bound as const
        log_error(message)
    }
}

You can omit fields you do not need exhaustive field extraction is not required:

match result {
    case .Found(var value) {
        // only value is bound, key is ignored
        process(value)
    }
    default { }
}

See Enums for ADT enum declarations and construction.

where guards

Append where followed by a boolean expression to add a condition to a case. The branch only matches if both the pattern and the guard are satisfied:

match result {
    case .Found(var key, var value) where value > 0 {
        std::println(f"{key} has positive value")
    }
    case .Found(var key, var value) {
        std::println(f"{key} has non-positive value")
    }
    default { }
}

Guards are evaluated after the pattern matches. A case with a guard that fails falls through to the next case this is the only situation where control moves between cases.

Struct matching

Structs have a single shape (no variants), so match on a struct is destructuring combined with guards. A struct case without a where guard always matches and is a compile error unless it is the only case or the last case (acting as a catch-all):

struct Response {
    var status: i32
    var body: string
}

match response {
    case Response(var status, var body) where status == 200 {
        process(body)
    }
    case Response(var status) where status >= 400 {
        log_error(f"HTTP {status}")
    }
    default {
        // catch-all
    }
}

Range matching

Integer cases can match a range using .. (exclusive end) or ..= (inclusive end):

match http_status {
    case 200..=299 { std::println("success") }
    case 300..=399 { std::println("redirect") }
    case 400..=499 { std::println("client error") }
    case 500..=599 { std::println("server error") }
    default        { std::println("unknown") }
}

match as an expression

Like if, match can be used as an expression. The last expression in each branch is the result value. All branches must produce the same type:

var label = match level {
    case .Debug   { "debug" }
    case .Info    { "info" }
    case .Warning { "warning" }
    case .Error   { "error" }
    case .Fatal   { "fatal" }
}

var priority = match code {
    case 200..=299 { 0 }
    case 400..=499 { 1 }
    case 500..=599 { 2 }
    default        { -1 }
}

var message = match result {
    case .Found(var key, var value) { f"{key}: {value}" }
    case .NotFound(var key)        { f"{key} not found" }
    case .Error(var message)       { message }
}

In expression form, exhaustiveness is required every possible value must be covered. For integers and strings, this means default is mandatory.

Exhaustiveness

The compiler verifies that all possible values are covered:

Matched typeCoverage rule
Plain enumAll variants covered, or default present
ADT enumAll variants covered, or default present
Integers / stringsdefault always required
Booleanstrue + false covers it
Structsdefault always required

A default case matches any value not covered by preceding cases. It must be the last case.

No fall-through

Each case is an isolated block. There is no fall-through between cases and no @fallthrough attribute. If you need multiple values to execute the same code, list them in a single case separated by commas:

match priority {
    case 1, 2, 3 { handle_high() }
    case 4, 5    { handle_medium() }
    default      { handle_low() }
}

break in match inside a loop

Inside a match nested within a loop, break exits the loop, not the match. Match cases are already isolated blocks with no fall-through, so there is nothing to “break” out of within the match itself.

for item in items {
    match item.kind {
        case .Sentinel {
            break   // exits the for loop
        }
        case .Data(var payload) {
            process(payload)
        }
        default { }
    }
}

Loops

for range and iterator

Range-based and iterator-based for loops use the in keyword:

for i in 0..10 {
    // i = 0, 1, 2, ..., 9
}

for i in 0..=10 {
    // i = 0, 1, 2, ..., 10
}

for item in collection {
    // iterates using the collection's fn op in (self) -> yield T
}

The range operators .. and ..= produce a Range object that implements the iterator protocol. Any type that defines fn op in (self) -> yield T is iterable. See Operators for the Steppable interface and Operators for the op in overload.

Multi-variable iteration

If the collection yields tuples, destructure directly in the loop header:

for (key, value) in some_map {
    std::println(f"{key} = {value}")
}

for (a, b) in pairs {
    // a and b are the first and second elements of each yielded tuple
}

The tuple arity in the loop header must match the arity yielded by the collection.

for C-style

Traditional three-part for loops are also supported:

for var i = 0; i < 10; i++ {
    // classic index loop
}

The init clause declares a new variable scoped to the loop body. The condition and step are bare expressions.

while

while condition {
    // loop body
}

The condition is evaluated before each iteration. No implicit conversion from integers must be bool.

loop

loop is an unconditional infinite loop. It runs until an explicit break or return:

loop {
    var input = read_line()
    if input == "quit" {
        break
    }
    process(input)
}

Labeled loops

Labels allow break and continue to target a specific enclosing loop. The syntax is label: for/while/loop:

outer: for i in 0..10 {
    inner: for j in 0..10 {
        if some_condition {
            break outer    // exits the outer loop entirely
        }
        if other_condition {
            continue inner // skips to next iteration of the inner loop
        }
    }
}

Labels follow the same naming conventions as variables (snake_case). break and continue without a label target the innermost enclosing loop.


break and continue

StatementBehavior
breakExits the innermost loop
break labelExits the loop identified by label
continueSkips to the next iteration of the innermost loop
continue labelSkips to the next iteration of the loop identified by label

break and continue are statements, not expressions they do not produce values. Inside a match nested within a loop, break exits the loop, not the match (match cases are already isolated blocks with no fall-through by default).


Error Handling

try / catch

try/catch handles panics from functions annotated with the panic specifier. The compiler statically verifies that catch blocks cover all panic types that can propagate from the try body uncovered types are a compile error. See Panic for the full panic model.

try {
    risky_operation()
} catch {
    // catch-all any panic type
    std::println("something went wrong")
}

Catch blocks can filter by specific error types. The compiler checks exhaustiveness against the set of panic types declared by the called functions:

try {
    read_file("data.txt")
} catch e: std::Error::IO {
    std::println(f"IO error: {e}")
} catch e: std::Error::Runtime {
    std::println(f"Runtime error: {e}")
}

A bare catch (no type) acts as a catch-all and satisfies exhaustiveness for any remaining types.

try/catch as an expression

Like if, try/catch can be used as an expression. The last expression in each block is the result value:

var result = try {
    parse_int("42")
} catch e: std::Error::Runtime {
    -1   // fallback value
} catch {
    0    // default for any other error
}

All branches must produce the same type. finally is not permitted in expression form.

finally

finally defines cleanup code that always runs whether the try body completes normally, panics, or returns early.

try {
    acquire_resource()
    do_work()
} catch e: std::Error::Runtime {
    handle_error(e)
} finally {
    release_resource()   // always executes
}

Standalone finally (scope exit)

finally can also appear inside any function body without a preceding try. In this form, it acts as a scope exit block the body executes when the enclosing function returns, regardless of how it exits:

fn process_file(path: string) panic -> void {
    var fd = open(path)
    finally {
        close(fd)   // runs on normal return, panic, or early return
    }

    // ... work with fd ...
    if bad_data {
        return   // finally still runs
    }
    // ... more work ...
}

This is equivalent to Go’s defer the body executes when the enclosing function exits, regardless of how it exits (normal return, panic, or early return). Multiple finally blocks in the same function execute in reverse declaration order (LIFO), matching destructor semantics.


assert

assert evaluates a condition and panics if it is false. It takes an expression and an optional diagnostic message:

assert index < len, "index out of bounds"
assert ptr != null

Assert behavior is globally configurable via the -fassert-mode compiler flag:

ModeBehavior
panicPanics with the provided message or a generated diagnostic
returnReturns a default-constructed value of the function’s return type
log (default)Logs the assertion failure and continues execution
Warning

return mode silently swallows assertion failures and produces a default-constructed value. This can mask bugs and produce incorrect results downstream. Use with caution panic mode is the default for a reason.

Asserts cannot appear at file scope they are only valid inside function bodies.


panic

panic is a statement that triggers an unrecoverable error in the current function. Functions that can panic must be annotated with the panic specifier in their signature, and the compiler enforces that callers handle the panic via try/catch.

fn divide(a: i32, b: i32) panic -> i32 {
    if b == 0 {
        panic std::Error::Runtime("division by zero")
    }
    return a / b
}

See Panic for the full panic model, including Panickable<T>, desugaring semantics, and the zero-cost codegen strategy.


return

return exits the current function with a value. If the function returns void, return takes no operand.

fn max(a: i32, b: i32) -> i32 {
    return if a > b { a } else { b }
}

Blocks and Scope

A block is a brace-delimited sequence of statements. Every block introduces a new lexical scope variables declared inside are not visible outside, and destructors run at the closing brace in reverse declaration order.

{
    var x = 42
    var y = compute(x)
    // x and y are live here
}
// x and y are destroyed and no longer accessible

Blocks are expressions when used as the right-hand side of a binding. The last expression in the block is the result value:

var result = {
    var tmp = expensive_computation()
    tmp * 2   // result = tmp * 2
}

Anonymous blocks are useful for limiting the lifetime of temporary resources without introducing a function:

fn process() {
    var config = load_config()

    {
        var lock = acquire_mutex()
        // critical section
        write_shared_state(config)
    }
    // lock is destroyed here mutex released

    do_unrelated_work()
}

All control flow constructs (if, match, for, while, loop, try) create implicit blocks their bodies follow the same scoping and destruction rules.

See Variables and AMT for full lifetime semantics.


Jumps

For low-level control flow (state machines, interpreters, hot loops), Kairo provides labeled jumps via compiler intrinsics:

label!(entry)
// ... code ...
jump!(entry)   // unconditional jump to 'entry'

jump! can only target labels within the same function cross-function jumps are a compile error. Labels declared with label! are not the same as loop labels; they are raw branch targets with no structured control flow guarantees.

Caution

jump! bypasses destructors and finally blocks. Jumping over a variable’s declaration and then referencing it is undefined behavior. Use structured control flow (loop, break, return) unless you have a concrete reason not to.

Kairo does not have a goto keyword. label! and jump! are compiler intrinsics surfaced with macro syntax to make jump usage explicit and greppable in a codebase. They are not user-defined macros see Macros for the macro system.


Compile-Time Branching

eval if selects a branch at compile time. The condition must be a compile-time constant expression if it is not, the compiler emits an error. Only the selected branch is included in the final program; the other branches are discarded entirely (no codegen, no type-checking).

eval if platform == "linux" {
    fn init() { /* linux-specific init */ }
} else if platform == "windows" {
    fn init() { /* windows-specific init */ }
} else {
    fn init() { /* fallback */ }
}

This is equivalent to #if / #elif / #else in C/C++, but operates on Kairo’s compile-time evaluation system rather than a preprocessor. See Eval for details on what qualifies as a compile-time constant.


Branch Hints

Kairo provides attributes to communicate branch likelihood to the compiler’s optimizer. These map directly to LLVM’s branch weight metadata and __builtin_expect semantics in the generated code.

@likely if hot_path {
    fast_operation()
} else {
    slow_fallback()
}

@unlikely if rare_error {
    handle_error()
}
AttributeMeaning
@likelyThe condition is expected to be true in the common case
@unlikelyThe condition is expected to be false in the common case
@unreachableThe branch should never execute if it does, behavior is undefined

@unreachable is a hard assertion to the optimizer that the marked branch is dead code. If execution reaches an @unreachable branch at runtime, the behavior is undefined the compiler is free to eliminate the branch entirely and may miscompile surrounding code under that assumption.

match direction {
    case .North { /* ... */ }
    case .South { /* ... */ }
    case .East  { /* ... */ }
    case .West  { /* ... */ }
    @unreachable default {
        // optimizer assumes this is dead code
    }
}

Branch hints cannot be applied to eval if branches compile-time branches are resolved before codegen and have no runtime cost to optimize.


The Never Type

Functions that never return they always panic, loop forever, or call a noreturn function use ! as their return type:

fn fatal(msg: string) panic -> ! {
    panic std::Error::Runtime(msg)
}

fn event_loop() -> ! {
    loop {
        process_events()
    }
}

! is the never type. It is a subtype of every type, meaning it can appear anywhere a value is expected. This makes it valid in expression contexts:

var x: i32 = if valid { compute() } else { fatal("invalid state") }
// fatal() returns ! which coerces to i32

See Functions for return type syntax and Type System for how ! interacts with type inference.


return

return exits the current function with a value. See Functions for full details on return types, void returns, and expression-bodied functions.


Short-Circuit Evaluation

&& and || are guaranteed left-to-right with short-circuit evaluation. The right operand is not evaluated if the left operand determines the result:

if ptr != null || *ptr == 42 {
    // safe: *ptr is only evaluated if ptr is non-null
}

This is identical to C/C++ behavior and is guaranteed by the language specification, not an optimizer artifact.


Control Flow Not in Kairo

For C++ developers the following C++ constructs have no equivalent in Kairo:

C++ ConstructKairo Alternative
? : (ternary)if/else expressions
gotolabel! / jump! intrinsics
do { ... } whileloop with a conditional break at the end
switch fall-throughmatch but no fall-through
throw / C++ exceptionspanic / try/catch (see Panic)
#if / #ifdefeval if (see Eval)
constexpr / constevaleval functions / vars (see Eval)

Functions

https://www.kairolang.org/docs/language/functions/

Functions

Functions in Kairo follow a consistent declaration syntax for both free functions and class methods. The full grammar covers visibility, ABI linkage, generics, modifiers, bounds, and return types all optional except the fn keyword, the name, and the parameter list.


Declaration Syntax

fn name(param1: Type1, param2: Type2) -> ReturnType {
    // body
}

The full grammar:

function_declaration ::= visibility? abi_mod? "fn" generics? identifier
                         "(" parameter_list? ")" function_mods? "->" return_type? bounds? block

visibility   ::= "pub" | "priv" | "prot"
abi_mod      ::= ("ffi" string_literal) | "static" | "virtual" | "override"
generics     ::= "<" generic_param_list ">"
function_mods ::= "const" | "volatile" | "unsafe" | "eval" | "async" | "final" | "panic" | "inline"
bounds       ::= "where" expression
return_type  ::= type | "!"

All parts except fn, the name, and the parenthesized parameter list are optional. When the return type is omitted, it defaults to void.


Parameters

Parameters are declared as name: Type. Each parameter requires an explicit type annotation there is no parameter type inference.

fn greet(name: string, loud: bool) {
    if loud {
        std::println(f"HELLO, {name}!")
    } else {
        std::println(f"Hello, {name}.")
    }
}

Default parameters

Parameters can have default values. When a caller omits a defaulted argument, the default is used:

fn greet(name: string = "world") {
    std::println(f"Hello, {name}!")
}

greet()          // "Hello, world!"
greet("Alice")   // "Hello, Alice!"

Defaults are evaluated at the call site. Parameters with defaults must appear after non-defaulted parameters.

Named arguments

Arguments can be passed by name at the call site. Positional arguments must come before named arguments, matching C++ conventions:

fn create_user(name: string, age: i32 = 18, country: string = "USA") -> User {
    return User { name, age, country }
}

create_user("Alice")                              // age=18, country="USA"
create_user("Bob", 25)                            // country="USA"
create_user(name: "Eve", country: "UK")           // age=18
create_user("Grace", 22)                          // country="USA"
create_user(name: "Frank", age: 30, country: "CA") // all explicit

Return Types

Explicit return

fn add(a: i32, b: i32) -> i32 {
    return a + b
}

Implicit void

When no return type is specified, the function returns void:

fn log(msg: string) {
    std::println(msg)
}

fn log_explicit(msg: string) -> void {   // equivalent
    std::println(msg)
}

No-return !

Functions that never return they always panic, loop forever, or call a no-return function use ! as their return type:

fn fatal(msg: string) -> ! {
    std::println(f"Fatal: {msg}")
    std::crash(1)
}

fn event_loop() -> ! {
    loop {
        process_events()
    }
}

! is the no-return type. It is a subtype of every type, meaning it can appear anywhere a value is expected:

var x: i32 = if valid { compute() } else { fatal("bad state") }
// fatal() returns ! which coerces to i32
Warning

No-return functions cannot have a panic specifier. Since panic acts as an alternative return path, it contradicts the guarantee that the function never returns. The compiler rejects fn f() panic -> !.

Special return types

These return type modifiers interact with Kairo’s concurrency and type system. Each is covered in detail on its respective page:

Return typeDescriptionDetails
yield TCoroutine yields values of type T cooperativelyConcurrency
atomic TAtomic wrapper thread-safe operationsConcurrency
thread TThread-local storageConcurrency
fn generate_numbers() -> yield i32 {
    for i in 0..10 {
        yield i   // yield, not return function must have a yield return type
    }
}
Note

Functions with a yield return type cannot use return to produce values only yield. A bare return (no operand) is permitted to terminate the coroutine early.


Expression-Bodied Functions

Single-expression functions can use the = shorthand, omitting braces and return:

fn add(a: i32, b: i32) -> i32 = a + b
fn square(x: f64) -> f64 = x * x
fn greeting(name: string) -> string = f"Hello, {name}!"

The return type annotation is optional for expression-bodied functions it is inferred from the expression when omitted. This is the only form of return type inference in Kairo; block-bodied functions always require an explicit return type (or default to void).

fn add(a: i32, b: i32) = a + b              // inferred as i32
fn greeting(name: string) = f"Hi, {name}!"  // inferred as string

Explicit annotations are still useful when you want to constrain or widen the inferred type:

fn promote(x: i32) -> i64 = x as i64        // would otherwise infer i32

return

return exits the current function with a value. For void functions, return takes no operand.

fn max(a: i32, b: i32) -> i32 {
    if a > b {
        return a
    }
    return b
}

fn log(msg: string) {
    std::println(msg)
    return   // explicit return from void function valid but optional
}

Early returns are permitted anywhere in a function body. The compiler verifies that all code paths return a value of the declared return type.


Function Overloading

Functions can be overloaded by parameter types, matching C++ overload resolution rules:

fn add(a: i32, b: i32) -> i32 = a + b
fn add(a: f64, b: f64) -> f64 = a + b
fn add(a: string, b: string) -> string = a + b

Unsafe overloads

The unsafe modifier creates a separate overload in its own namespace. Safe and unsafe versions of the same function coexist the caller explicitly selects which one to invoke:

fn add(a: i32, b: i32) -> i32 {
    return a + b
}

fn add(a: i32, b: i32) unsafe -> i32 {
    return a << 1 + b << 1   // faster but semantically different
}

var x = add(10, 20)          // calls the safe version
var y = unsafe add(10, 20)   // calls the unsafe overload
Note

unsafe overloads are not “unsafe memory” AMT still guarantees memory safety. The unsafe qualifier signals that the function may not uphold other invariants that the safe version does. See Unsafe for the full unsafe model.

const overloading restriction

const and non-const methods with the same name and parameter types cannot coexist. They live in the same scope use distinct names like get() and get_mut() instead:

class Foo {
    fn bar(const self) -> i32 { return 42 }
    fn bar(self) -> i32 { return 24 }               // compile error: cannot overload const
    fn bar(const self, a: i32) -> i32 { return a }  // ok: different parameter list ok
}

Variadic Functions

The ... prefix on a parameter name accepts an arbitrary number of arguments of the same type. The parameter is accessible as a tuple inside the function body:

fn sum(...numbers: i32) -> i32 {
    var total = 0
    for num in numbers {
        total += num
    }
    return total
}

sum(1, 2, 3)        // 6
sum(10, 20, 30, 40) // 100

Generic variadic functions

Combine ... with generic type packs to accept arguments of different types:

fn <...T> print_all(...args: T) {
    for arg in args {
        std::println(arg as string)
    }
}

print_all(42, "hello", true)   // prints each on a new line

The parameter is a tuple of heterogeneous types. Each element in the pack must satisfy the constraints used in the function body in the example above, every T must be convertible to string via as.


Generic Functions

Generic functions declare type parameters in angle brackets before the function name:

fn <T> identity(x: T) -> T {
    return x
}

Constrain type parameters with impl (interface conformance) or derives (class inheritance):

fn <T impl Comparable> max(a: T, b: T) -> T {
    return if a > b { a } else { b }
}

fn <T derives Serializable> serialize(value: T) -> [byte] {
    return value.to_bytes()
}
Note

impl checks structural conformance the type satisfies the interface’s required method signatures without needing an explicit impl declaration. derives checks polymorphic inheritance the type is a subclass of the specified class. See Interfaces and Bounds for details.

where clauses

For constraints beyond type parameter bounds, use a where clause:

fn <T impl ToString> print_value(value: T) where T.value == "MyThing" {
    std::println(value as string)
}

where conditions are evaluated at compile time when possible the branch is eliminated entirely. When the condition depends on runtime values, the function becomes conditionally callable and the compiler inserts a check at the call site. See Bounds for the full constraint system.


Function Modifiers

Modifiers appear after the parameter list and before the return type arrow. Multiple modifiers can be combined, subject to the compatibility rules below.

fn compute(x: i32) const inline -> i32 { return x * x }
fn dangerous() unsafe -> void          { /* ... */ }
fn compile_time() eval -> i32          { return 42 }
fn may_fail() panic -> i32             { /* ... */ }
fn background() async -> Data          { /* ... */ }

Modifier reference

ModifierFree functionsMethodsDescription
constYesMethod does not modify self. See Variables
volatileYesYesPrevents certain compiler optimizations; for hardware interaction
unsafeYesYesSeparate overload namespace for alternative implementations
evalYesYesMust be evaluable at compile time. See Eval
asyncYesYesAsynchronous execution. See Concurrency
panicYesYesMay panic; callers must handle. See Panic
inlineYesYesHint to inline at call sites
finalYesPrevents override in subclasses. See Classes

Modifier compatibility

Not all modifiers can be combined:

CombinationValidReason
const + volatileok:
const + unsafeunsafe: implies a separate overload with different invariants
const + evaleval: implies compile-time evaluation const self is meaningless
const + asyncok:
unsafe + any otherok:unsafe: creates a separate overload the other modifiers apply to both versions as appropriate
eval + unsafeok:eval and unsafe are orthogonal modifiers
eval + any other (except unsafe)eval implies compile-time evaluation incompatible with runtime modifiers
async + constok:
async + volatileok:

Visibility

KeywordScope
pubAccessible from any module
privAccessible only within the defining module (default)
protAccessible within the defining module and by subclasses in other modules
pub fn public_api()       { /* ... */ }
priv fn internal_helper() { /* ... */ }
prot fn for_subclasses()  { /* ... */ }

Visibility applies to both free functions and methods. See Modules for how visibility interacts with imports.


ABI and Linkage

ABI modifiers control name mangling, dispatch mechanism, and symbol visibility at the object code level.

ModifierDescription
ffi "c"C linkage no name mangling. See C/C++ Interop
ffi "c++"C++ linkage Itanium or MSVC mangling. See C/C++ Interop
staticInternal linkage; no vtable dispatch. Cannot be virtual or override
virtualDynamic dispatch via vtable. See Classes
overrideOverrides a virtual method from a base class; implies virtual

static, virtual, and override are mutually exclusive with each other. ffi can be combined with any of them.

class Shape {
    virtual fn area(self) const -> f64 { return 0.0 }
}

class Circle : Shape {
    var radius: f64

    override fn area(self) const -> f64 {
        return 3.14159 * self.radius * self.radius
    }
}

class Math {
    static fn sqrt(x: f64) -> f64 { /* ... */ }
}

Math::sqrt(16.0)   // called without an instance

Function Pointers

Functions are first-class values. The type of a function pointer is fn(ParamTypes) -> ReturnType:

fn add(a: i32, b: i32) -> i32 = a + b
fn sub(a: i32, b: i32) -> i32 = a - b

var op: fn(i32, i32) -> i32 = add
op(3, 4)   // 7

op = sub
op(10, 3)  // 7

Functions can be nested inner functions are scoped to the enclosing function:

fn outer(x: i32) -> i32 {
    fn inner(y: i32) -> i32 = y * 2
    return inner(x) + 1
}

Closures

Anonymous functions (lambdas) capture variables from the enclosing scope. Default capture is by copy; use |&| for capture-by-reference or specify per-variable:

var multiplier = 3
var scale = fn (x: i32) -> i32 { return x * multiplier }   // captures multiplier by copy

scale(10)   // 30

See Closures for capture modes (|&|, |a, &b|), lifetime rules, and how closures interact with AMT.


Operator Functions

Operators are overloaded with the fn op syntax:

class Vec2 {
    var x: f64
    var y: f64

    fn op +(self, other: Vec2) -> Vec2 {
        return Vec2 { x: self.x + other.x, y: self.y + other.y }
    }
}

See Operators for the full list of overloadable operators, special operator syntax (l++/r++, op as, op in, op delete), and restrictions.


Forward Declarations

A function can be declared without a body a signature followed by no block:

fn parse_expression(tokens: [Token]) -> Expr
fn parse_statement(tokens: [Token]) -> Stmt

Within a single module, forward declarations are rarely needed. Kairo hoists all top-level declarations before type checking, so mutual recursion works without them:

fn is_even(n: u32) -> bool = if n == 0 { true } else { is_odd(n - 1) }
fn is_odd(n: u32) -> bool  = if n == 0 { false } else { is_even(n - 1) }

Forward declarations are used when the definition lives elsewhere:

  • FFI imports the body is provided by a C or C++ library. See C/C++ Interop.

  • Separate translation units the declaration is visible to callers; the definition is linked in from another .kro file.

  • Out-of-line class methods declared in the class body, defined outside it using the Class::method qualified-name syntax:

    class Parser {
        var pos: usize
        fn advance(self)    -> Token
        fn peek(self) const -> Token
    }
    
    fn Parser::advance(self) -> Token {
        var t = self.tokens[self.pos]
        self.pos += 1
        return t
    }
    
    fn Parser::peek(self) const -> Token = self.tokens[self.pos]

    See Classes for the full rules.

Signature matching

When a definition follows a forward declaration, the two must match exactly:

  • Parameter types, return type, and all function modifiers (const, unsafe, panic, eval, async, inline, final, volatile)
  • Visibility (pub, priv, prot)
  • ABI linkage (ffi "c", ffi "c++", static, virtual, override)

Parameter names and default values may differ the declaration’s names and defaults are used at call sites that see only the declaration; the definition’s are used everywhere else. For consistency, keep them the same.

A mismatch in any other element is a compile error.


Summary

// Basic function
fn add(a: i32, b: i32) -> i32 = a + b

// Default parameters + named arguments
fn connect(host: string = "localhost", port: i32 = 8080) { /* ... */ }
connect(port: 9090)

// Generic with bounds
fn <T impl Printable> show(value: T) { std::println(value as string) }

// Variadic
fn <...T> log(...args: T) { /* ... */ }

// Overloaded
fn process(x: i32) -> i32 { /* ... */ }
fn process(x: string) -> string { /* ... */ }

// Unsafe overload
fn process(x: i32) unsafe -> i32 { /* ... */ }

// Method with modifiers
class Server {
    pub fn start(self) async panic { /* ... */ }
    pub fn status(self) const -> string { /* ... */ }
    pub static fn default_port() -> i32 = 8080
}

// Function pointer
var handler: fn(Request) -> Response = handle_request

// No-return
fn abort() -> ! { std::crash(1) }

Closures

https://www.kairolang.org/docs/language/closures/

Closures

A closure is an anonymous function that captures variables from its enclosing scope. In Kairo, lambdas and closures use the same syntax the only distinction is whether the function captures anything. If it does, it’s a closure. If it doesn’t, it’s a plain lambda.


Basic Syntax

Anonymous functions are declared with fn followed by a parameter list, an optional return type, and a body. The return type is inferred when omitted.

var add = fn (a: i32, b: i32) -> i32 {
    return a + b
}

var double = fn (x: i32) -> i32 {
    return x * 2
}

add(3, 4)     // 7
double(10)    // 20

Closures have the same type as function pointers fn(ParamTypes) -> ReturnType. There is no separate closure type:

var op: fn(i32, i32) -> i32 = fn (a: i32, b: i32) -> i32 { return a + b }

Capture Modes

By default, closures capture variables by copy. The closure receives its own copy of each captured variable mutations inside the closure do not affect the original.

var x = 10
var add_x = fn (y: i32) -> i32 {
    return y + x   // x is captured by copy
}

x = 999
add_x(5)   // 15 uses the copied value of x (10), not 999

Capture by reference |&|

To capture all variables by reference, append |&| after the parameter list. The closure can read and modify the original variables:

var count = 0
var increment = fn ()|&| {
    count += 1   // modifies the original count
}

increment()
increment()
count   // 2

Mixed capture |a, &b|

Specify capture mode per variable. Unqualified names are captured by copy, &-prefixed names by reference:

var a = 10
var b = 20

var closure = fn (x: i32)|a, &b| -> i32 {
    b += 1       // modifies the original b
    return x + a + b   // a is a copy, b is a reference
}

closure(5)   // 5 + 10 + 21 = 36
a            // still 10
b            // 21

Capture summary

SyntaxBehavior
(none)All captures by copy (default)
|&|All captures by reference
|a, b|Named variables captured by copy
|&a, &b|Named variables captured by reference
|a, &b|Mixed a by copy, b by reference

Default Parameters

Closures support default parameter values, matching the behavior of regular functions:

var greet = fn (name: string = "world") {
    std::println(f"Hello, {name}!")
}

greet()          // "Hello, world!"
greet("Alice")   // "Hello, Alice!"

Generic Closures

Closures can be generic, declaring type parameters before the parameter list:

var identity = fn <T>(x: T) -> T {
    return x
}

identity(42)        // i32
identity("hello")   // string

panic Specifier

Closures can be marked panic to indicate they may panic. Callers must handle the panic via try/catch, the same as with regular functions:

var risky = fn () panic -> i32 {
    if bad_condition {
        panic std::Error::Runtime("failed")
    }
    return 42
}

try {
    risky()
} catch e {
    std::println(f"Caught: {e}")
}

See Panic for the full panic model.

Note

Closures cannot be async or eval. Asynchronous work should use async free functions or methods. Compile-time evaluation requires named eval functions. See Concurrency and Eval.


AMT and Lifetime Safety

AMT tracks closure captures the same way it tracks any other borrow. If a closure captures a variable by reference and the closure outlives the variable, AMT will attempt to auto-promote the capture to a smart pointer (shared, weak, or unique). If promotion is not possible, the compiler emits a hard error.

fn make_closure() -> fn() -> i32 {
    var x = 42
    return fn ()|&x| -> i32 { return x }
    // AMT error: x is a stack local, closure would outlive it,
    //   and there is no safe promotion path for a stack variable
}

Capture by copy avoids this entirely the closure owns its own copy and has no lifetime dependency on the original:

fn make_closure() -> fn() -> i32 {
    var x = 42
    return fn () -> i32 { return x }   // ok x is copied into the closure
}
Warning

Capturing stack-local variables by reference in a closure that escapes the current scope is always an AMT error. There is no way to promote a reference to a stack variable into a safe smart pointer. Use capture by copy for closures that outlive their enclosing scope.


Closures vs Inner Functions

Inner functions (named functions declared inside another function) do not capture from the enclosing scope they are self-contained:

fn outer() -> i32 {
    var x = 10

    fn inner(y: i32) -> i32 = y + 1     // cannot access x inner is a plain function
    // fn inner(y: i32) -> i32 = y + x  // compile error: x is not in scope

    return inner(x)   // pass x explicitly
}

If you need to reference enclosing variables, use a closure. If you don’t, prefer an inner function it has no capture overhead and makes the data flow explicit.


Passing Closures to Functions

Since closures share the fn pointer type, they can be passed to any function expecting a function pointer:

fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
    return f(x)
}

apply(fn (x: i32) -> i32 { return x * 2 }, 21)   // 42

var triple = fn (x: i32) -> i32 { return x * 3 }
apply(triple, 10)   // 30

Higher-order patterns work naturally:

fn map(data: [i32], transform: fn(i32) -> i32) -> [i32] {
    var result: [i32]
    for item in data {
        result.push(transform(item))
    }
    return result
}

var doubled = map([1, 2, 3], fn (x: i32) -> i32 { return x * 2 })
// doubled == [2, 4, 6]

Summary

// Lambda no capture
var add = fn (a: i32, b: i32) -> i32 { return a + b }

// Closure capture by copy (default)
var x = 10
var add_x = fn (y: i32) -> i32 { return y + x }

// Closure capture all by reference
var count = 0
var inc = fn ()|&| { count += 1 }

// Closure mixed capture
var a = 1
var b = 2
var mix = fn ()|a, &b| { b += a }

// Generic closure
var id = fn <T>(x: T) -> T { return x }

// Passing closures
fn apply(f: fn(i32) -> i32, x: i32) -> i32 = f(x)
apply(fn (x: i32) -> i32 { return x * 2 }, 5)   // 10

Classes

https://www.kairolang.org/docs/language/classes/

Classes

Classes are Kairo’s primary mechanism for encapsulating state and behavior. The object model layout, vtables, ABI follows the platform’s C++ convention. The surface syntax is cleaner, self is always explicit, and the copy/move model is expressed through attributes rather than reference qualifiers.


Declaration

class Foo {
    var x: i32
    var y: f64

    fn Foo(self, x: i32, y: f64) {
        self.x = x
        self.y = y
    }

    fn sum(self) -> f64 {
        return self.x as f64 + self.y
    }
}

var obj = Foo(10, 3.14)
obj.sum()   // 13.14

Members are declared with var, const, static, or eval inside the class body. Methods are declared with fn and take self as the first parameter for instance methods. Omitting self requires the static modifier. If self is present, it is an instance method; if not, it must be static.


self and Self

self is the instance parameter. It behaves like a reference to the current object use self.member to access fields and self to pass the object to other functions. self is not a pointer; you cannot perform pointer arithmetic on it or reassign it.

Self (capitalized) is a type alias for the enclosing class. In a generic class class <T> Foo, Self resolves to Foo<T>. Use Self in parameter types, return types, and anywhere you need the class’s own type without spelling out generic arguments:

class <T> Container {
    var items: [T]

    fn Container(self) {
        self.items = []
    }

    fn merge(self, other: Self) -> Self {
        var result = Container<T>()
        for item in self.items {
            result.items.push(item)
        }
        for item in other.items {
            result.items.push(item)
        }
        return result
    }
}

Self always refers to a reference to the class type. For a pointer, use *Self or *ClassName. The bare class name works anywhere Self does Self is syntactic sugar, not a distinct type.


Visibility

Visibility modifiers control access to members from outside the class:

KeywordScope
pubAccessible from any module
privAccessible only within the defining class or module
protAccessible within the defining class, subclasses, and the defining module

Modifiers are applied per-declaration. There are no visibility blocks (public: sections).

Default visibility

Member kindDefault
Instance / static / const / eval variablespriv
Methods, constructors, destructorspub
Operator overloadspub
Static methodspub
Nested typespub
class Account {
    var balance: f64                  // priv by default
    pub var owner: string             // explicitly public

    fn Account(self, owner: string) { // pub by default
        self.owner = owner
        self.balance = 0.0
    }

    pub fn deposit(self, amount: f64) {
        self.balance += amount
    }

    priv fn audit_log(self) {
        // internal only
    }
}

Visibility also applies at the top level a priv class is only accessible within its file, a prot class within its module and subclasses. See Modules for how top-level visibility interacts with imports.

Note

Kairo has no friend keyword. If external code needs access to internals, expose it through a public method or adjust module-level visibility. priv at the top level restricts access to the current file, which covers most factory and serialization patterns.


Constructors

Constructors use the class name as the function name and take self as the first parameter:

class Point {
    var x: f64
    var y: f64

    fn Point(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }
}

var p = Point(1.0, 2.0)

Constructors support all the features of regular functions default parameters, named arguments, overloading by parameter types, generic type parameters. See Functions.

const members in constructors

Class-level const members can be initialized in the constructor body. They get exactly one assignment; after construction, they are frozen:

class Config {
    const MAX_RETRIES: i32
    var timeout: f64

    fn Config(self, retries: i32, timeout: f64) {
        self.MAX_RETRIES = retries   // one-shot assignment, legal
        self.timeout = timeout
    }
}

var cfg = Config(3, 30.0)
// cfg.MAX_RETRIES = 5   // compile error: MAX_RETRIES is const

If a const member has an initializer at the declaration site, it cannot be reassigned in the constructor.

Default and deleted constructors

class Defaults {
    var x: i32

    fn Defaults(self) = default    // compiler-generated default constructor
}

class NoDefault {
    var x: i32

    fn NoDefault(self) = delete    // no default construction allowed
}

var a = Defaults()     // ok: x is zero-initialized
var b = NoDefault()    // compile error: default constructor is deleted

See Variables for default initialization rules.


Destructors

Destructors use the op delete operator syntax:

class Resource {
    var handle: unsafe *void

    fn Resource(self, h: unsafe *void) {
        self.handle = h
    }

    fn op delete(self) {
        release_handle(self.handle)
    }
}

Destructors can be defaulted or deleted:

fn op delete(self) = default   // compiler-generated, memberwise destroy in reverse declaration order
fn op delete(self) = delete    // prevent destruction (and therefore stack allocation)

Destructors run at the end of the enclosing scope in reverse declaration order.


Lifecycle Categories

Every class has a lifecycle category that determines how its instances can be transferred between variables. The category is implied by which transfer constructor the class defines.

Attribute syntax

@copy and @move are attributes attached to a constructor. The convention is one attribute per line above the declaration:

@copy
fn Buffer(self, other: Self) = default

Inline placement (@copy fn Buffer(self, other: Self) = default) is permitted but discouraged. See Attributes for the full attribute syntax.

The transfer constructor

A constructor with the signature fn Class(self, other: Self) is the transfer constructor. It governs how instances of the class are passed by value. The attribute on this constructor selects the category:

class Buffer {
    @copy
    fn Buffer(self, other: Self) = default
}

class UniqueFile {
    @move
    fn UniqueFile(self, other: Self) = default
}

A transfer constructor without an attribute defaults to @copy. A class with no transfer constructor at all is implicitly COPY with all four special members compiler-generated.

A class cannot define both @copy and @move transfer constructors pick one:

class Bad {
    @copy fn Bad(self, other: Self) = default
    @move fn Bad(self, other: Self) = default   // compile error
}

The four categories

CategoryTriggerSemantics
COPY@copy ctor (explicit or implicit)Allows copy; AMT may silently elide a copy into a move when the source is unused after the transfer
MOVE@move ctorAllows move only; copying is a compile error
NON_TRANSFERboth @copy and @move are = deletedStack-only; cannot be assigned, copied, or moved
DEFAULTno transfer ctor declaredTreated as COPY with compiler-generated members

A NON_TRANSFER class is the canonical scope guard:

class ScopeLock {
    fn ScopeLock(self) = default

    @copy
    fn ScopeLock(self, other: Self) = delete

    @move
    fn ScopeLock(self, other: Self) = delete

    fn op delete(self) { /* ... */ }
}

The Rule of Three

The user writes at most three special members:

  1. The default constructor (fn Class(self))
  2. One transfer constructor (@copy or @move, never both)
  3. The destructor (fn op delete(self))

Each may have a user body, = default, or = delete. Anything not written is compiler-generated.

class Buffer {
    var data: unsafe *u8
    var size: usize

    fn Buffer(self, size: usize) {
        self.data = std::alloc<u8>(size)
        self.size = size
    }

    @move
    fn Buffer(self, other: Self) {
        self.data  = other.data
        self.size  = other.size
        other.data = null
        other.size = 0
    }

    fn op delete(self) {
        std::free(self.data)
    }
}

Assignment is auto-derived

op = is generated from the transfer constructor the user does not write it. Writing it explicitly is a compile error.

Transfer ctor formGenerated op =
= default= default
= delete= delete
{ body }destroys self, then runs the ctor body, then returns self

The body case handles self-assignment by destroying the current state before reconstructing it.

This implies a consistency rule: if the transfer ctor has a body and op delete is = deleted, the class fails to compile the auto-derived op = needs to invoke the destructor and cannot. Fix by providing a destructor or by changing the transfer ctor to = default / = delete.

Defaulted moves of non-movable members

= default on a @move ctor performs memberwise move: each member is moved through its own @move ctor. If a member is COPY-only (no @move available), that member is copied as part of the move matching C++ semantics. This is silent and almost always what you want; if a member must be moved or nothing, write the transfer ctor body explicitly and let the compiler error on the COPY-only field.

Implicit-copy-with-custom-destructor warning

A class with a custom destructor but no explicit @copy or @move transfer constructor compiles, but emits a warning. The class is implicitly copyable, and a custom destructor almost always means the class owns a resource silent copies of that resource cause double-free and aliasing bugs.

class UniqueFile {
    var handle: unsafe *void

    fn op delete(self) { close_file(self.handle) }
    // warning: custom destructor with implicit copy constructor
    //   help: declare as @move:
    //           @move fn UniqueFile(self, other: Self) = default
    //   help: or explicitly delete copy:
    //           @copy fn UniqueFile(self, other: Self) = delete
}

AMT copy elision

For COPY classes, AMT may emit a move instead of a copy when it proves the source is unused after the transfer. This is a pure optimization with no observable semantic difference the source is destroyed either way, just earlier when elided. AMT does not elide if the move constructor is deleted. Users do not opt into or out of this optimization.


Inheritance

Classes inherit from other classes using derives:

class Animal {
    var name: string

    fn Animal(self, name: string) {
        self.name = name
    }

    fn speak(self) {
        std::println(f"{self.name} makes a sound")
    }
}

class Dog derives Animal {
    var breed: string

    fn Dog(self, name: string, breed: string) {
        Animal::Animal(self, name)   // call base constructor
        self.breed = breed
    }

    fn speak(self) {
        std::println(f"{self.name} barks")
    }
}

Inheritance visibility

By default, inheritance is public. Append pub, prot, or priv after derives to control how base members are exposed in the derived class:

class Derived derives pub Base { ... }    // base members keep their visibility
class Derived derives prot Base { ... }   // all base members become protected
class Derived derives priv Base { ... }   // all base members become private

Multiple inheritance

class Serializable {
    fn serialize(self) -> [byte] { ... }
}

class Printable {
    fn print(self) { ... }
}

class Document derives Serializable, Printable {
    // inherits both serialize() and print()
}

class Widget derives pub Drawable, prot EventHandler { ... }

Calling base class methods

There is no super keyword. Call base methods explicitly using the qualified name:

class Derived derives Base {
    fn method(self) {
        Base::method(self)
    }
}

Diamond and virtual inheritance

If B and C both derive from A, and D derives from both B and C, then D contains two copies of A’s subobject. Disambiguate with qualified names:

class A {
    fn method(self) { std::println("A") }
}

class B derives A { fn method(self) { std::println("B") } }
class C derives A { fn method(self) { std::println("C") } }

class D derives B, C {
    fn method(self) {
        B::method(self)
        C::method(self)
    }
}

To share a single A subobject, use derives virtual. The most-derived class is responsible for initializing the virtual base directly; intermediate classes’ calls to the virtual base constructor are ignored:

class A {
    var value: i32
    fn A(self, v: i32) { self.value = v }
}

class B derives virtual A {
    fn B(self, v: i32) { A::A(self, v) }
}

class C derives virtual A {
    fn C(self, v: i32) { A::A(self, v) }
}

class D derives B, C {
    fn D(self, v: i32) {
        A::A(self, v)    // D initializes the virtual base
        B::B(self, v)
        C::C(self, v)
    }
}

Lifecycle category matching

A derived class’s lifecycle category must be compatible with its base’s:

Base categoryAllowed derived categories
COPYCOPY, NON_TRANSFER
MOVEMOVE, NON_TRANSFER
NON_TRANSFERNON_TRANSFER

A copyable base with a move-only derived class is a compile error slicing a derived instance through a base pointer would otherwise silently copy the base subobject of a class that promised never to be copied.

See Casting for upcasting and downcasting.


Virtual Dispatch

By default, methods are statically dispatched. To enable dynamic dispatch via a vtable, mark the method virtual in the base class:

class Shape {
    virtual fn area(self) const -> f64 {
        return 0.0
    }
}

class Circle derives Shape {
    var radius: f64

    fn Circle(self, radius: f64) {
        self.radius = radius
    }

    override fn area(self) const -> f64 {
        return 3.14159 * self.radius * self.radius
    }
}

var shape: *Shape = &Circle(5.0)
shape->area()   // 78.539... dynamic dispatch

virtual creates a vtable slot. override in the derived class replaces the entry in that slot. A class only has a vtable pointer (8 bytes at offset 0) if it declares or inherits at least one virtual method. Non-polymorphic classes have no vtable overhead.

Abstract classes (pure virtual)

A method declared with = virtual has no body and must be overridden by any non-abstract derived class:

class Shape {
    fn area(self) const -> f64 = virtual
    fn perimeter(self) const -> f64 = virtual
}

class Circle derives Shape {
    var radius: f64

    fn Circle(self, radius: f64) { self.radius = radius }

    override fn area(self) const -> f64 {
        return 3.14159 * self.radius * self.radius
    }

    override fn perimeter(self) const -> f64 {
        return 2.0 * 3.14159 * self.radius
    }
}

// var s = Shape()   // compile error: Shape has pure virtual methods
var c = Circle(5.0)  // ok: all pure virtuals overridden

= virtual implies vtable participation no virtual prefix is needed on the declaration. A class with any = virtual method cannot be instantiated directly.

final

final prevents further overriding of a method or derivation from a class:

final class Singleton { /* ... */ }

// class Derived derives Singleton { }   // compile error

class Base {
    virtual fn process(self) { ... }
}

class Middle derives Base {
    final override fn process(self) { ... }   // overrides, then locks
}

class Bottom derives Middle {
    // override fn process(self) { ... }   // compile error: final in Middle
}

Interfaces

A class can declare interface conformance with impl. The compiler verifies at declaration time that the class satisfies all interface requirements:

interface Hashable {
    fn hash(self) const -> u64
}

class UserId impl Hashable {
    var id: u64

    fn UserId(self, id: u64) {
        self.id = id
    }

    fn hash(self) const -> u64 {
        return self.id
    }
}

Interface conformance is structural a class that has the required methods satisfies the interface whether or not impl is declared. The impl keyword triggers the check at the declaration site rather than at the point of use.

class Point {
    var x: f64
    var y: f64

    fn hash(self) const -> u64 { ... }
}

fn <T impl Hashable> insert(set: {T}, item: T) { ... }

insert(my_set, Point(1.0, 2.0))   // compiles: Point has hash()

See Interfaces and Bounds.


Generic Classes

Type parameters are declared in angle brackets before the class name:

class <T> Stack {
    var items: [T]

    fn Stack(self) {
        self.items = []
    }

    fn push(self, item: T) {
        self.items.push(item)
    }

    fn pop(self) panic -> T {
        if self.items.len() == 0 {
            panic std::Error::Runtime("stack underflow")
        }
        return self.items.pop()
    }
}

var s = Stack<i32>()
s.push(42)

Generic classes can inherit from other generic classes:

class <T> Base {
    var data: T
}

class <T> Derived derives Base<T> {
    var extra: i32
}

Type parameter constraints

By default, a type parameter accepts any T. Whether a specific T is valid for a given instantiation depends on what the body does with it by-reference use accepts anything, copying requires a COPY type, moving requires COPY or MOVE, returning by value requires COPY or MOVE. Instantiation errors point at both the body operation that required the capability and the type that lacks it.

Explicit bounds document intent and fail fast at the constraint check rather than mid-body. Two kinds of bounds exist:

Kind bounds restrict T to a specific category of type declaration:

fn <T: class>  process(item: T)   { ... }
fn <T: struct> serialize(item: T) { ... }
fn <T: enum>   stringify(item: T) { ... }
fn <T: union>  inspect(item: T)   { ... }

Interface bounds restrict T to types that satisfy a given interface. The standard library will provide interfaces describing lifecycle capabilities, allowing constraints like “T must be copyable” or “T must be movable” to be written as interface bounds. The exact names and shapes of these interfaces are still being finalized in std write your own if you need them now:

interface Copyable {
    fn Copyable(self)               // default ctor

    @copy
    fn Copyable(self, other: Self)
}

fn <T impl Copyable> store(item: T) -> T { ... }

See Bounds for the full constraint system.


Static Members

Static members belong to the class, not to any instance. They are declared with static and accessed via ClassName::member:

class Counter {
    static var count: i32 = 0

    fn Counter(self) {
        Counter::count += 1
    }

    static fn get_count() -> i32 {
        return Counter::count
    }
}

var a = Counter()
var b = Counter()
Counter::get_count()   // 2

Static variables require an explicit type annotation. They can be initialized at the declaration site or at program startup. Static methods do not take self and cannot access instance members.


const Methods

A method that takes const self promises not to modify the object (except mutable members). Only const methods can be called through a *const T pointer or on a const binding:

class Sensor {
    var reading: f64
    mutable var read_count: i32

    fn value(const self) -> f64 {
        self.read_count += 1   // ok: mutable member
        return self.reading
    }

    fn calibrate(self, offset: f64) {
        self.reading += offset
    }
}

const sensor: *const Sensor = &some_sensor
sensor->value()        // ok: const method
// sensor->calibrate(1.0)  // compile error: not const

const and non-const methods with the same name and parameter types cannot coexist use distinct names like get() and get_mut(). See Variables.

mutable members

The mutable qualifier allows a member to be modified even through a const reference or in a const method:

class Cache {
    var data: [i32]
    mutable var hit_count: i32

    fn Cache(self) {
        self.data = []
        self.hit_count = 0
    }

    fn lookup(const self, index: i32) -> i32 {
        self.hit_count += 1   // ok: mutable
        return self.data[index]
    }
}

mutable is only valid on instance variables inside class and struct bodies. It cannot appear on top-level variables, local variables, or const/eval/static declarations.

Warning

mutable breaks the semantic guarantee that const methods do not modify the object. Use it sparingly caching, reference counting, and lazy initialization are the canonical use cases. If you find yourself marking many members mutable, reconsider the const boundary.


Nested Classes

Classes can be declared inside other classes. Nested classes can access private members of the enclosing class:

class Tree {
    priv class Node {
        var value: i32
        var left: unsafe *Node
        var right: unsafe *Node
    }

    var root: unsafe *Node

    fn Tree(self) {
        self.root = null
    }
}

Forward Declarations and Out-of-Line Definitions

A class can be forward-declared without a body sufficient for *Class uses, not for sizeof or member access:

class Parser

class Lexer {
    var parser: unsafe *Parser   // ok: pointer to incomplete type
}

class Parser {
    var lexer: Lexer
    // full definition
}

Member methods can also be declared inside the class body without a body and defined out-of-line using the Class::method qualified-name syntax:

class Parser {
    var tokens: [Token]
    var pos: usize

    pub fn Parser(self, tokens: [Token])
    pub fn advance(self) -> Token
    pub fn peek(self) const -> Token
    priv fn error(self, msg: string) panic
}

fn Parser::Parser(self, tokens: [Token]) {
    self.tokens = tokens
    self.pos = 0
}

fn Parser::advance(self) -> Token {
    var t = self.tokens[self.pos]
    self.pos += 1
    return t
}

fn Parser::peek(self) const -> Token = self.tokens[self.pos]

fn Parser::error(self, msg: string) panic {
    panic std::Error::Parse(msg, self.pos)
}

This pattern keeps the class body small and readable as an API surface, with implementation details defined separately.

Rules

  • The qualified definition’s signature must exactly match the in-class declaration: parameter types, return type, and all function modifiers (const, unsafe, panic, eval, async, inline, final, virtual, override, static).
  • Visibility is omitted from the out-of-line definition it is taken from the in-class declaration. Writing pub fn Parser::advance is a compile error.
  • Default arguments must appear on the in-class declaration, not the out-of-line definition.
  • Parameter names may differ between declaration and definition; the definition’s names are used in the body.

Generic classes

For generic classes, the type parameters are re-declared on the out-of-line definition and the class name carries its generic arguments:

class <T> Vec {
    var data: unsafe *T
    var len: usize
    var cap: usize

    pub fn push(self, value: T)
    pub fn get(self, i: usize) const -> T
}

fn <T> Vec<T>::push(self, value: T) {
    if self.len == self.cap { self.grow() }
    self.data[self.len] = value
    self.len += 1
}

fn <T> Vec<T>::get(self, i: usize) const -> T = self.data[i]

Operators, constructors, destructors

Operator overloads, constructors, and destructors follow the same pattern:

class Vec2 {
    var x: f64
    var y: f64

    pub fn Vec2(self, x: f64, y: f64)
    pub fn op delete(self)
    pub fn op +(self, other: Vec2) -> Vec2
}

fn Vec2::Vec2(self, x: f64, y: f64) {
    self.x = x
    self.y = y
}

fn Vec2::op delete(self) { /* ... */ }

fn Vec2::op +(self, other: Vec2) -> Vec2 = Vec2(self.x + other.x, self.y + other.y)

Nested types

For methods on a nested type, chain the qualifiers:

fn Outer::Inner::method(self) { /* ... */ }

Operator Overloading

Operators are overloaded with fn op syntax inside the class body:

class Vec2 {
    var x: f64
    var y: f64

    fn Vec2(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }

    fn op +(self, other: Vec2) -> Vec2 {
        return Vec2(self.x + other.x, self.y + other.y)
    }

    fn op ==(self, other: Vec2) const -> bool {
        return self.x == other.x && self.y == other.y
    }
}

var a = Vec2(1.0, 2.0)
var b = Vec2(3.0, 4.0)
var c = a + b   // Vec2(4.0, 6.0)

Special operators beyond arithmetic:

SyntaxPurpose
fn op delete(self)Destructor
fn op as(self) -> TargetTypeCustom type conversion via as
fn <T> op await(self, obj: std::forward<T>) -> TCustom awaitable

op = is not user-definable it is auto-derived from the transfer constructor. See Lifecycle Categories.

See Operators for the full list of overloadable operators and restrictions.


Memory Layout and Allocation

Class layout follows the platform’s C++ ABI:

  • Non-polymorphic classes: members laid out in declaration order with standard padding and alignment.
  • Polymorphic classes (at least one virtual method): vtable pointer at offset 0 (8 bytes on 64-bit), followed by members.
  • Inheritance: base class subobject precedes derived members.

Layout can be controlled with attributes:

@packed
class Compact {
    var a: u8    // offset 0
    var b: u32   // offset 1 (no padding)
}

@align(16)
class Aligned {
    var data: [u8; 12]
}

A plain var declaration allocates on the stack:

var obj = Foo(42)   // stack-allocated

Heap allocation uses std::create<T>(), which returns a pointer. AMT determines whether the returned pointer is raw or promoted to a smart pointer based on usage analysis:

var ptr = std::create<Foo>(42)

See Pointers and AMT.


Structs vs Classes

Structs and classes are distinct types:

ClassStruct
Member default visibilitypriv for variablespub for all members
Methods in bodyYesNo (use extends)
Aggregate initializationNo (must use constructor)Yes (Foo { field: value })
InheritancederivesNot supported
Virtual dispatchYesNo
Lifecycle categoriesYesN/A (always trivially copyable)

See Structures.


Summary

// Basic class
class Point {
    var x: f64
    var y: f64

    fn Point(self, x: f64, y: f64) {
        self.x = x
        self.y = y
    }

    fn distance(const self, other: Self) -> f64 {
        var dx = self.x - other.x
        var dy = self.y - other.y
        return std::sqrt(dx * dx + dy * dy)
    }
}

// Move-only class
class UniqueFile {
    var handle: unsafe *void

    fn UniqueFile(self, path: string) {
        self.handle = open_file(path)
    }

    @move
    fn UniqueFile(self, other: Self) {
        self.handle  = other.handle
        other.handle = null
    }

    fn op delete(self) {
        close_file(self.handle)
    }
}

// Inheritance with virtual dispatch
class Shape {
    fn area(self) const -> f64 = virtual
}

class Circle derives Shape {
    var radius: f64

    fn Circle(self, r: f64) { self.radius = r }

    override fn area(self) const -> f64 {
        return 3.14159 * self.radius * self.radius
    }
}

// Generic class with interface conformance
class <T impl Comparable> PriorityQueue impl Iterable {
    priv var heap: [T]

    fn PriorityQueue(self) { self.heap = [] }
    fn push(self, item: T) { ... }
    fn pop(self) panic -> T { ... }
}

// Final class
final class Immutable {
    const value: i32

    fn Immutable(self, v: i32) { self.value = v }
}

Structures

https://www.kairolang.org/docs/language/structures/

Structures

Structs are plain data types no constructors, no methods in the body, trivially copyable via memcpy. They exist for cases where you want a named bag of fields with aggregate initialization and no lifecycle management. If you need constructors, destructors, inheritance, or virtual dispatch, use a class.


Declaration

struct Point {
    var x: f64
    var y: f64
}

var p = Point { x: 1.0, y: 2.0 }
p.x   // 1.0

The body contains only field declarations (var, const, eval, static) and nested type definitions. Methods and constructors are not permitted inside the struct body use extends to add behavior.


Aggregate Initialization

Structs are constructed with brace-delimited field assignment. All fields must be provided unless they have a default value at the declaration site:

struct Color {
    var r: u8
    var g: u8
    var b: u8
    var a: u8 = 255
}

var red = Color { r: 255, g: 0, b: 0 }         // a defaults to 255
var blue = Color { r: 0, g: 0, b: 255, a: 128 } // explicit alpha

Field names are required in the initializer positional initialization is not supported. The order of fields in the initializer does not need to match the declaration order.

There are no user-defined constructors. If you need construction logic, extend a factory function onto the struct:

extend Color {
    static fn from_hex(hex: u32) -> Color {
        return Color {
            r: (hex >> 16 & 0xFF) as u8,
            g: (hex >> 8 & 0xFF) as u8,
            b: (hex & 0xFF) as u8,
        }
    }
}

var teal = Color::from_hex(0x008080)

Visibility

All struct members default to pub. You can explicitly mark a member priv or prot, but the compiler emits a warning if you need access control on fields, a class is the better fit.

struct Packet {
    var header: u32        // pub by default
    priv var checksum: u32 // legal but warned: consider using a class
}

Extended functions can be pub, prot, or priv without warning.


const and mutable Members

const members must be initialized at the declaration site or in the aggregate initializer. Unlike class const members, there is no constructor body to provide a one-shot assignment:

struct Config {
    const VERSION: i32 = 1
    var name: string
}

var cfg = Config { name: "app" }
// cfg.VERSION = 2   // compile error: VERSION is const

mutable works the same as in classes the member can be modified even through a const reference:

struct Metrics {
    var total: i32
    mutable var cache_hits: i32
}

fn report(const self: Metrics) {
    self.cache_hits += 1   // ok: mutable
    // self.total += 1     // compile error: total is not mutable, self is const
}

See Classes for the mutable rationale and usage guidance.


Copy Semantics

Structs are always trivially copyable. Assignment and parameter passing use memcpy there are no copy constructors, move constructors, or assignment operator overloads:

var a = Point { x: 1.0, y: 2.0 }
var b = a   // memcpy, a and b are independent copies
b.x = 9.0
a.x   // still 1.0

This is a hard guarantee. Extending a destructor (fn op delete), copy assignment (fn op =), or move assignment onto a struct is a compile error. If you need custom lifecycle management, use a class.


Extends

Since structs cannot contain methods in their body, behavior is added via extend blocks. An extend block can add methods, operators, and static functions:

struct Vec2 {
    var x: f64
    var y: f64
}

extend Vec2 {
    fn length(self) const -> f64 {
        return std::sqrt(self.x * self.x + self.y * self.y)
    }

    fn op +(self, other: Vec2) -> Vec2 {
        return Vec2 { x: self.x + other.x, y: self.y + other.y }
    }

    fn op ==(self, other: Vec2) const -> bool {
        return self.x == other.x && self.y == other.y
    }
}

var v = Vec2 { x: 3.0, y: 4.0 }
v.length()   // 5.0

What extends can add

AllowedNot allowed
MethodsConstructors
Static functionsDestructors (fn op delete)
Arithmetic / comparison operatorsCopy / move assignment (fn op =)
fn op as (type conversion)
fn op in (iteration)

Interface conformance

Structs implement interfaces through extend ... impl:

interface Drawable {
    fn draw(self) -> string
}

extend Vec2 impl Drawable {
    fn draw(self) -> string {
        return f"({self.x}, {self.y})"
    }
}

The extend block and the struct definition must be in the same file the same restriction as Rust’s impl blocks. See Extends for the full extend system and Interfaces for interface declarations.


Generic Structs

Type parameters are declared in angle brackets before the struct name:

struct <T> Pair {
    var first: T
    var second: T
}

var p = Pair<i32> { first: 10, second: 20 }

Generic extends must redeclare the type parameters:

extend <T> Pair<T> {
    fn swap(self) -> Pair<T> {
        return Pair<T> { first: self.second, second: self.first }
    }
}

Constrain type parameters with impl or derives bounds:

struct <T impl Comparable> Range {
    var start: T
    var end: T
}

See Bounds for the full constraint system.


Nested Types

Structs can contain nested type definitions classes, enums, unions, other structs:

struct Packet {
    var header: Header
    var payload: [byte]

    struct Header {
        var version: u8
        var length: u16
    }
}

var pkt = Packet {
    header: Packet::Header { version: 1, length: 64 },
    payload: [],
}

Nested types are accessed via StructName::NestedType. Interfaces cannot be nested they must be declared at the top level.


No Inheritance

Structs do not support derives. If you need field inheritance, embed the struct:

struct Base {
    var x: i32
}

struct Composed {
    var base: Base
    var y: i32
}

var c = Composed { base: Base { x: 10 }, y: 20 }
c.base.x   // 10

This keeps aggregate initialization unambiguous and avoids layout questions that inheritance introduces. If you need polymorphism or a type hierarchy, use classes.


Destructuring

Struct fields can be destructured into individual bindings:

struct Color {
    var r: u8
    var g: u8
    var b: u8
}

var color = Color { r: 255, g: 128, b: 0 }
var { r, g, b } = color
// r = 255, g = 128, b = 0

Use _ to discard fields you do not need. See Variables for the full destructuring syntax.


Memory Layout

Struct layout follows the platform’s C++ ABI members in declaration order with standard padding and alignment rules. Control layout with attributes:

@packed
struct Tight {
    var a: u8    // offset 0
    var b: u32   // offset 1 (no padding)
}

@align(16)
struct Aligned {
    var data: [u8; 12]
}

Structs are always value types. A plain var declaration allocates on the stack. Heap allocation uses std::create<T>():

var local = Point { x: 1.0, y: 2.0 }           // stack
var *heap = std::create<Point>(Point { x: 1.0, y: 2.0 })  // heap

See Pointers and AMT for allocation and pointer semantics.


Forward Declarations

Structs can be forward-declared for use in pointer types before the full definition is available:

struct Node    // forward declaration

struct Tree {
    var root: unsafe *Node
}

struct Node {
    var value: i32
    var left: unsafe *Node
    var right: unsafe *Node
}

Structs vs Classes

StructClass
Member default visibilitypubpriv for variables
Methods in bodyNo (use extends)Yes
ConstructorsNo (aggregate init only)Yes
DestructorsNoYes
Copy semanticsmemcpy (trivial)Rule of five
InheritanceNoderives
Virtual dispatchNoYes
Aggregate initializationYesNo

Use structs for plain data configuration records, protocol headers, math vectors, tuple-like types. Use classes when you need lifecycle control, inheritance, or encapsulation.


Summary

// Basic struct
struct Point {
    var x: f64
    var y: f64
}

var p = Point { x: 1.0, y: 2.0 }

// Generic struct
struct <T> Pair {
    var first: T
    var second: T
}

// Extend with methods and operators
extend Point {
    fn length(self) const -> f64 {
        return std::sqrt(self.x * self.x + self.y * self.y)
    }

    fn op +(self, other: Point) -> Point {
        return Point { x: self.x + other.x, y: self.y + other.y }
    }
}

// Extend with interface conformance
extend Point impl Drawable {
    fn draw(self) -> string {
        return f"({self.x}, {self.y})"
    }
}

// Layout control
@packed
struct Header {
    var magic: u16
    var version: u8
    var flags: u8
}

// Composition over inheritance
struct Rect {
    var origin: Point
    var size: Point
}

var r = Rect {
    origin: Point { x: 0.0, y: 0.0 },
    size: Point { x: 100.0, y: 50.0 },
}

Enums

https://www.kairolang.org/docs/language/enums/

Enums

Enums define a closed set of named variants. In their simplest form, each variant is an integer discriminant — equivalent to a C++ enum class. Variants can also carry structured data (ADT enums), making them Kairo’s mechanism for type-safe tagged unions. Dispatch on variants with match. See Control Flow for the complete match syntax.


Plain Enums

A plain enum is a set of named integer constants:

enum Direction {
    North,
    East,
    South,
    West,
}

var dir = Direction::North

Variants are comma-separated and scoped to the enum name. The trailing comma is optional.

Discriminant values

Variants are assigned sequential integers starting from 0 by default. Explicit values can be assigned with =:

enum HttpStatus {
    Ok = 200,
    NotFound = 404,
    InternalError = 500,
    ServiceUnavailable,   // 501
}

Discriminant expressions must be compile-time constants. Duplicate values are a compile error.

Compound expressions are valid for bit flags:

enum FileMode derives u8 {
    Read    = 1 << 0,   // 1
    Write   = 1 << 1,   // 2
    Execute = 1 << 2,   // 4
    RW      = (Read | Write),       // 3
    All     = (Read | Write | Execute),  // 7
}

var perms: FileMode = .RW
if perms & FileMode::Read != 0 {
    // has read permission
}

Underlying Type

The default underlying type is u32. The compiler promotes to a wider unsigned integer if the number of variants exceeds what u32 can represent, though a warning is emitted for enums exceeding 2^32 variants.

Specify an explicit underlying type with derives followed by an integer type:

enum Opcode derives u8 {
    Nop  = 0x00,
    Load = 0x01,
    Store = 0x02,
    Halt = 0xFF,
}

Only integer types (u8, u16, u32, u64, i8, i16, i32, i64, usize, isize) are permitted. The compiler errors if any discriminant does not fit in the specified type.

Note

derives on an enum specifies the underlying integer type. This is distinct from derives on a class, which declares inheritance.


ADT Enums

Variants can carry structured data. Each payload is defined as a set of named fields inside braces:

enum <T> ParseResult {
    Success { value: T, bytes_consumed: i32 },
    Error   { message: string, position: i32 },
    EndOfInput,
}

Variants without a payload (EndOfInput above) are plain discriminants. Variants with a payload carry an anonymous struct alongside the discriminant. A single enum can freely mix both.

Construction

ADT variants are constructed with the variant name followed by brace-delimited field assignment, matching struct aggregate initialization:

var result = ParseResult<f64>::Success { value: 3.14, bytes_consumed: 4 }
var err = ParseResult<f64>::Error { message: "unexpected token", position: 12 }
var eof = ParseResult<f64>::EndOfInput

Field names are required. The order of fields in the initializer does not need to match the declaration order.

When the enum type can be inferred from context, the .Variant shorthand works:

var result: ParseResult<f64> = .Success { value: 3.14, bytes_consumed: 4 }

Destructuring with match

Use match to dispatch on variants and extract payload fields. Destructured fields use var or const to control mutability of the bound variable:

match result {
    case .Success(var value, var bytes_consumed) {
        std::println(f"parsed {value} from {bytes_consumed} bytes")
    }
    case .Error(const message, const position) {
        std::println(f"error at {position}: {message}")
        // message = "other"  // compile error: message is const
    }
    case .EndOfInput {
        std::println("nothing left to parse")
    }
}

Field names in the destructuring pattern must match the names in the variant declaration. You can omit fields you do not need the compiler does not require exhaustive field extraction:

match result {
    case .Error(const message) {
        // only message is bound, position is ignored
        log_error(message)
    }
    // ...
    default { }
}

See Control Flow for the full match syntax, including guards (where), ranges, and expression form.

match as an expression

ADT enums work in expression-form match. Each branch must produce the same type:

var description = match result {
    case .Success(var value, var bytes_consumed) {
        f"parsed {value} ({bytes_consumed} bytes)"
    }
    case .Error(var message, var position) {
        f"error at {position}: {message}"
    }
    case .EndOfInput {
        "end of input"
    }
}

See Control Flow for the full expression-form rules.

Memory layout

An ADT enum stores a discriminant tag plus storage sized to the largest variant’s payload. The tag is the same integer type as the underlying type (default u32). A variant with no payload consumes no extra storage beyond the tag.

ParseResult<f64> layout:
  [tag: u32] [payload: max(sizeof Success, sizeof Error)] [padding]

The compiler generates a destructor that switches on the tag to destroy the active variant’s fields. Copy and move operations follow the same pattern.


Generic Enums

Enums with payloads can be generic. Type parameters are declared in angle brackets before the enum name:

enum <K, V> LookupResult {
    Found    { key: K, value: V },
    NotFound { key: K },
    Error    { message: string },
}

var r = LookupResult<string, i32>::Found { key: "count", value: 42 }

Plain enums (no payloads) cannot be generic there is nothing for a type parameter to parameterize.

Constrain type parameters with impl or derives bounds:

enum <T impl Serializable> CacheEntry {
    Valid   { data: T, expires: i64 },
    Expired { last_data: T },
    Empty,
}

See Bounds for the full constraint system.


Shorthand Syntax

When the compiler can infer the enum type from context, the .Variant shorthand is available in construction, match cases, comparison, and assignment:

var dir: Direction = .North

match dir {
    case .North { go_up() }
    case .South { go_down() }
    case .East  { go_right() }
    case .West  { go_left() }
}

if dir == .North { ... }

dir = .South

The fully qualified form (Direction::North) always works and is required when the type cannot be inferred.


Comparison and Assignment

Enum values support equality comparison and assignment. Ordering operators (<, >, <=, >=) are not available by default extend them if needed:

var a = Direction::North
var b = Direction::South

if a == b { ... }    // ok
if a != b { ... }    // ok
// if a < b { ... }  // compile error: no ordering defined

For ADT enums, equality compares the tag only by default. Extend op == to include payload comparison if needed. See Operators for operator extension.


Extends

Enums cannot contain methods in their body. Use extend blocks to add behavior, matching the same pattern as structs:

enum LogLevel {
    Debug,
    Info,
    Warning,
    Error,
    Fatal,
}

extend LogLevel {
    fn is_severe(self) const -> bool {
        return self == .Error || self == .Fatal
    }

    fn prefix(self) const -> string {
        match self {
            case .Debug   { return "[DEBUG]" }
            case .Info    { return "[INFO]" }
            case .Warning { return "[WARN]" }
            case .Error   { return "[ERROR]" }
            case .Fatal   { return "[FATAL]" }
        }
    }

    static fn from_string(s: string) -> LogLevel {
        match s {
            case "debug"   { return .Debug }
            case "info"    { return .Info }
            case "warning" { return .Warning }
            case "error"   { return .Error }
            case "fatal"   { return .Fatal }
            default        { return .Info }
        }
    }
}

What extends can add

AllowedNot allowed
MethodsConstructors
Static functionsDestructors
Comparison / arithmetic operatorsCopy / move assignment
fn op as (type conversion)

Interface conformance

Enums can implement interfaces through extend ... impl:

interface Loggable {
    fn to_log_string(self) const -> string
}

extend LogLevel impl Loggable {
    fn to_log_string(self) const -> string {
        return self.prefix()
    }
}

The extend block and the enum definition must be in the same file. See Extends for the full extend system and Interfaces for interface declarations.


No Associated Data in Variants (Plain Enums Only)

For plain enums without the { field: Type } payload syntax, variants are strictly integer constants. If you need per-variant data, use an ADT enum.

For untagged memory overlays where you manage the active member yourself, use Unions.


No Inheritance

Enums do not support inheriting from other enums. The derives keyword on an enum is reserved exclusively for specifying the underlying type.


Casting

Plain enum values can be cast to their underlying integer type and back:

var dir = Direction::North
var raw = dir as u32           // 0

var back = raw as Direction    // Direction::North

Casting an integer to an enum that has no matching discriminant is undefined behavior.

ADT enums cannot be cast to integers directly they carry payloads that have no integer representation. Cast the tag explicitly if you need the discriminant value.

See Casting for the full casting model.


Forward Declarations

Enums can be forward-declared when the underlying type is specified:

enum Opcode derives u8    // forward declaration size is known

fn decode(op: Opcode) { ... }

enum Opcode derives u8 {
    Nop  = 0x00,
    Load = 0x01,
    Store = 0x02,
    Halt = 0xFF,
}

Without an explicit underlying type, forward declaration is not permitted the compiler needs the size, which depends on the variant count and payload sizes.


Summary

// Plain enum
enum Direction {
    North, East, South, West,
}

// Explicit discriminants + underlying type
enum Opcode derives u8 {
    Nop  = 0x00,
    Load = 0x01,
    Store = 0x02,
    Halt = 0xFF,
}

// Bit flags
enum FileMode derives u8 {
    Read    = 1 << 0,
    Write   = 1 << 1,
    Execute = 1 << 2,
    All     = (Read | Write | Execute),
}

// ADT enum with payloads
enum <K, V> LookupResult {
    Found    { key: K, value: V },
    NotFound { key: K },
    Error    { message: string },
}

// Construction
var r = LookupResult<string, i32>::Found { key: "count", value: 42 }

// Match with destructuring
match r {
    case .Found(var key, var value) {
        std::println(f"{key} = {value}")
    }
    case .NotFound(var key) {
        std::println(f"{key} not found")
    }
    case .Error(var message) {
        std::println(f"error: {message}")
    }
}

// Extended with methods
extend Direction {
    fn opposite(self) -> Direction {
        match self {
            case .North { return .South }
            case .South { return .North }
            case .East  { return .West }
            case .West  { return .East }
        }
    }
}

Unions

https://www.kairolang.org/docs/language/unions/

Unions

Unions overlay multiple fields at the same memory address, sized to the largest member. They are a low-level memory layout tool for hardware registers, binary protocol parsing, and type punning. The user is responsible for tracking which field is active there is no compiler-managed tag.

For type-safe tagged unions with compiler-enforced variant tracking, use ADT enums.


Declaration

union Register {
    var as_u32: u32
    var as_bytes: [u8; 4]
    var as_f32: f32
}

All fields share the same starting address. The union’s size is the size of its largest member, plus any alignment padding.


Usage

Read and write fields with plain assignment. No special syntax or unsafe block is required:

var r: Register
r.as_u32 = 0xDEADBEEF
var byte0 = r.as_bytes[0]   // reads overlapping memory as u8

r.as_f32 = 3.14f32
// r.as_u32 now contains the bit representation of 3.14 as f32

Reading a field other than the one last written is type punning the value you get is the raw bit reinterpretation. This is legal but the result depends entirely on the underlying representation.

Caution

The compiler does not track which field was last written. Reading the wrong field is not a compile error it produces whatever bits happen to be in memory. This is the intended use case for unions, but it means correctness is entirely on the caller.


Trivial Types Only

Union fields must be trivially copyable. The following types are permitted:

  • Integers (i8i512, u8u512, isize, usize)
  • Floats (f16f512)
  • Booleans (bool)
  • Characters (char)
  • Bytes (byte)
  • Pointers (*T, unsafe *T)
  • Fixed-size arrays of trivial types ([T; N])
  • Structs where all fields are trivially copyable
  • Other unions
  • Enums (plain, without ADT payloads)

Non-trivially copyable types are a compile error:

union Bad {
    var text: string    // compile error: string has a destructor
    var data: [i32]     // compile error: vector owns heap memory
}

If you need a union of non-trivial types, use an ADT enum the compiler manages construction, destruction, and active-variant tracking for you.


Unions Must Be Named

Unions cannot exist as standalone anonymous types. They must always be declared with a name:

union Pixel {           // ok: named union
    var rgba: u32
    var channels: [u8; 4]
}

Nesting in Structs and Classes

Unions are commonly embedded in structs or classes for structured access to overlapping data:

struct Packet {
    var opcode: u8
    var payload: PacketData
}

union PacketData {
    var as_int: i64
    var as_float: f64
    var as_bytes: [u8; 8]
}

var pkt: Packet
pkt.opcode = 0x01
pkt.payload.as_int = 42

For a type-safe version of this pattern (where the opcode determines which payload field is valid), use an ADT enum instead.


Generic Unions

Unions can be generic:

union <T, U> Either {
    var left: T
    var right: U
}

var e: Either<i32, f32>
e.left = 42

Type parameters must resolve to trivially copyable types. The compiler errors at instantiation time if a type argument is not trivially copyable.


Memory Layout

Union layout follows the platform’s C++ ABI:

  • Size equals the largest member’s size, rounded up for alignment
  • All fields start at offset 0
  • Alignment is the strictest alignment of any member

Layout attributes work the same as on structs and classes:

@packed
union Compact {
    var a: u8
    var b: u32   // no padding between a and b at the union level
}

@align(16)
union Aligned {
    var data: [u8; 12]
    var value: i32
}

Forward Declarations

Unions can be forward-declared for use in pointer types:

union Payload    // forward declaration

struct Message {
    var kind: u8
    var data: unsafe *Payload
}

union Payload {
    var as_int: i64
    var as_bytes: [u8; 8]
}

No Extends

Unions do not support extend blocks. They are raw memory overlays with no behavior adding methods would blur the line between unions and classes. If you need methods on overlapping data, wrap the union in a struct or class and add behavior there.


No Inheritance

Unions do not support derives. They cannot inherit from or be inherited by other types.


Summary

// Basic union
union Color {
    var rgba: u32
    var channels: [u8; 4]
}

var c: Color
c.rgba = 0xFF0000FF
c.channels[0]   // 0xFF (on little-endian)

// Generic union
union <A, B> Reinterpret {
    var a: A
    var b: B
}

var bits: Reinterpret<f32, u32>
bits.a = 3.14f32
std::println(f"bits of 3.14: 0x{bits.b:X}")

// Nested in a struct
struct HardwareRegister {
    var address: usize
    var value: RegValue
}

union RegValue {
    var raw: u32
    var fields: RegFields
}

struct RegFields {
    var low: u16
    var high: u16
}

var reg: HardwareRegister
reg.address = 0x4000_0000
reg.value.raw = 0xDEAD_BEEF
reg.value.fields.low    // 0xBEEF (little-endian)
reg.value.fields.high   // 0xDEAD (little-endian)

Interfaces

https://www.kairolang.org/docs/language/interfaces/

Interfaces

Interfaces define a set of method signatures that a type must satisfy. They are zero-cost conformance contracts no vtable, no runtime dispatch, no storage overhead. A type conforms to an interface if it has all the required methods with matching signatures, whether or not it explicitly declares conformance.

Interfaces are a compile-time mechanism. For runtime polymorphism, use a base class with virtual methods and dispatch through a base pointer. Interfaces and virtual dispatch solve different problems and can be used together on the same type.


Declaration

interface Serializable {
    fn serialize(self) const -> [byte]
    fn byte_size(self) const -> i32
}

An interface body contains method signatures, static function signatures, and operator signatures. No method bodies, no variables, no constants, no nested types. Every member is implicitly pub the priv and prot modifiers are compile errors on interface members. Explicit pub is permitted but has no effect.


Conformance

A type declares interface conformance with impl on a class or through extend ... impl on a struct, enum, or class. The compiler verifies at declaration time that all required methods are present with matching signatures:

class Document impl Serializable {
    var content: string

    fn Document(self, content: string) {
        self.content = content
    }

    fn serialize(self) const -> [byte] {
        return self.content.to_bytes()
    }

    fn byte_size(self) const -> i32 {
        return self.content.len()
    }
}
Note

Conformance is structural, not nominal. A type that has all the required methods with matching signatures satisfies the interface implicitly. The check happens at the point of use. See Structural conformance section below for details.

For structs and enums, conformance goes through extend:

struct Point {
    var x: f64
    var y: f64
}

extend Point impl Serializable {
    fn serialize(self) const -> [byte] {
        // serialization logic
    }

    fn byte_size(self) const -> i32 {
        return 16   // two f64s
    }
}

See Extends for the full extend system.

Structural conformance

Explicit impl is not required. A type that has all the required methods with matching signatures satisfies the interface implicitly. The check happens at the point of use:

class Logger {
    fn serialize(self) const -> [byte] { ... }
    fn byte_size(self) const -> i32 { ... }
}

// Logger never mentions Serializable, but satisfies it structurally

fn <T impl Serializable> write_to_disk(data: T) { ... }

write_to_disk(Logger())   // compiles: Logger has serialize() and byte_size()

The difference between explicit and implicit conformance is when the check happens at declaration time (class Foo impl Bar) or at first use (fn <T impl Bar>). The runtime behavior is identical.


Generic Interfaces

Interfaces can declare type parameters:

interface <T> Container {
    fn add(self, item: T)
    fn get(self, index: i32) const -> T
    fn size(self) const -> i32
}

class IntBuffer impl Container<i32> {
    var data: [i32]

    fn IntBuffer(self) { self.data = [] }

    fn add(self, item: i32) {
        self.data.push(item)
    }

    fn get(self, index: i32) const -> i32 {
        return self.data[index]
    }

    fn size(self) const -> i32 {
        return self.data.len()
    }
}

Type parameters on interfaces can have constraints:

interface <T impl Comparable, U derives Base> Registry {
    fn lookup(self, key: T) const -> U
    fn store(self, key: T, value: U)
}

Generic defaults are not permitted on interface type parameters.

See Bounds for the full constraint system.


Operators in Interfaces

Interfaces can require operator overloads:

interface Equatable {
    fn op ==(self, other: Self) const -> bool
}

interface Convertible {
    fn op as(self) -> string
}

Self in an interface signature refers to the conforming type. A class that impl Equatable must provide fn op ==(self, other: Self) const -> bool where Self resolves to that class.

See Operators for the full list of overloadable operators.


Constructors in Interfaces

Interfaces can require constructor signatures:

interface Defaultable {
    fn Defaultable(self)
}

interface <T> Cloneable {
    fn Cloneable(self, source: T)
}

A conforming type must have a constructor that matches the parameter list. The constructor name in the implementation uses the type’s own name, not the interface name:

class Config impl Defaultable {
    var timeout: i32

    fn Config(self) {           // satisfies Defaultable's constructor requirement
        self.timeout = 30
    }
}

Lifecycle attributes on constructor requirements

Constructor requirements can carry @copy or @move attributes to require a specific lifecycle category. This is how you express “T must be copyable” or “T must be movable” as an interface bound:

interface Copyable {
    fn Copyable(self)               // default ctor

    @copy
    fn Copyable(self, other: Self)
}

interface Moveable {
    fn Moveable(self)

    @move
    fn Moveable(self, other: Self)
}

fn <T impl Copyable> store(item: T) -> T { ... }
fn <T impl Moveable> consume(item: T)    { ... }

The standard library will provide canonical lifecycle interfaces; these are just examples. See Lifecycle Categories for the underlying model.


Static Methods in Interfaces

Interfaces can require static methods:

interface <T> Parseable {
    static fn from_string(input: string) -> T
    static fn from_bytes(data: [byte]) -> T
}

class Timestamp impl Parseable<Timestamp> {
    var epoch: i64

    fn Timestamp(self, epoch: i64) {
        self.epoch = epoch
    }

    static fn from_string(input: string) -> Timestamp {
        // parsing logic
        return Timestamp(0)
    }

    static fn from_bytes(data: [byte]) -> Timestamp {
        // parsing logic
        return Timestamp(0)
    }
}

Return Types with Self

Interface methods can use Self as a return type. Self resolves to the conforming type a class that impl Chainable and returns Self returns its own type:

interface Chainable {
    fn then(self, next: Self) -> Self
}

class Pipeline impl Chainable {
    fn then(self, next: Self) -> Self {
        // Self resolves to Pipeline
        return self
    }
}

Kairo has no user-visible reference types AMT infers reference semantics where needed. In practice this means Self returns enable fluent chaining without -> or explicit dereferencing:

Pipeline().then(other).then(another).run()

To return a pointer to the conforming type, use *Self:

interface Cloneable {
    fn clone(self) -> *Self
}

Interface Inheritance

Interfaces can inherit from other interfaces with derives. A type that conforms to the derived interface must satisfy all inherited interfaces as well:

interface Readable {
    fn read(self, buffer: [byte], count: i32) -> i32
}

interface Seekable {
    fn seek(self, position: i64)
    fn tell(self) const -> i64
}

interface Stream derives Readable, Seekable {
    fn close(self)
    fn flush(self)
}

A class that impl Stream must provide read, seek, tell, close, and flush:

class FileStream impl Stream {
    var fd: i32

    fn FileStream(self, path: string) { ... }

    fn read(self, buffer: [byte], count: i32) -> i32 { ... }
    fn seek(self, position: i64) { ... }
    fn tell(self) const -> i64 { ... }
    fn close(self) { ... }
    fn flush(self) { ... }
}

Interface inheritance is purely additive the derived interface’s requirements are the union of its own methods and all inherited methods. There is no diamond problem because interfaces carry no implementation or state.

Multiple inheritance is permitted:

interface Loggable derives Serializable, Printable {
    fn log_level(self) const -> i32
}

Using Interfaces as Bounds

The primary use of interfaces is constraining generic type parameters with impl:

fn <T impl Serializable> save(data: T, path: string) {
    var bytes = data.serialize()
    write_file(path, bytes)
}

impl checks structural conformance the type satisfies the interface’s required method signatures. This is distinct from derives, which checks class inheritance. See Bounds for the full constraint system.

fn <T impl Serializable> save(data: T) { ... }   // T has serialize() and byte_size()
fn <T derives Base> process(data: T) { ... }     // T is a subclass of Base

Declaration Scope

Interfaces must be declared at module scope. They cannot be nested inside classes, structs, enums, or other interfaces:

interface Valid {
    fn check(self) const -> bool
}

class Outer {
    // interface Invalid { ... }   // compile error: interfaces cannot be nested
}

See Modules for module organization.


No Default Implementations

Interface methods have no bodies. Every method is a requirement that the conforming type must fulfill:

interface Hashable {
    fn hash(self) const -> u64
    // fn hash(self) const -> u64 { return 0 }   // compile error: no default implementations
}

This keeps interfaces as zero-cost contracts. There is no method resolution order, no inheritance of behavior, and no hidden dispatch. If you need shared implementation, use a base class with derives.


No Variables or Constants

Interfaces cannot declare variables, constants, static variables, or eval bindings:

interface Invalid {
    // var x: i32             // compile error
    // const Y: i32 = 10      // compile error
    // static z: i32 = 0      // compile error
    fn valid_method(self)     // ok
}

Summary

// Basic interface
interface Drawable {
    fn draw(self) const -> string
    fn bounds(self) const -> (f64, f64, f64, f64)
}

// Generic interface with constraints
interface <T impl Comparable> SortedContainer {
    fn insert(self, item: T)
    fn min(self) const -> T
    fn max(self) const -> T
}

// Interface with operators
interface Arithmetic {
    fn op +(self, other: Self) -> Self
    fn op -(self, other: Self) -> Self
    fn op ==(self, other: Self) const -> bool
}

// Interface inheritance
interface Flushable {
    fn flush(self)
}

interface BufferedWriter derives Flushable {
    fn write(self, data: [byte])
    fn buffer_size(self) const -> i32
}

// Class conformance
class Renderer impl Drawable {
    fn draw(self) const -> string { return "rendering" }
    fn bounds(self) const -> (f64, f64, f64, f64) { return (0.0, 0.0, 100.0, 100.0) }
}

// Struct conformance via extend
extend Point impl Drawable {
    fn draw(self) const -> string { return f"({self.x}, {self.y})" }
    fn bounds(self) const -> (f64, f64, f64, f64) { return (self.x, self.y, self.x, self.y) }
}

// Generic bound
fn <T impl Drawable> render_all(items: [T]) {
    for item in items {
        std::println(item.draw())
    }
}

Type System

https://www.kairolang.org/docs/language/type-system/

Type System

Kairo is statically typed with full type inference. Every value has a single concrete type known at compile time. The type system is nominal two types are the same if they have the same name and generic arguments, not if they happen to have the same structure.


Type Aliases

type declares a transparent alias for an existing type. The alias and the original type are fully interchangeable:

type Byte = u8
type <T> Matrix = [[T]]
type <T> TokenVec = [T]

var b: Byte = 42        // Byte and u8 are the same type
var m: Matrix<f64> = [[1.0, 2.0], [3.0, 4.0]]
var v: TokenVec<i32> = [1, 2, 3]

Aliases can be generic. Type parameters are declared in angle brackets before the alias name.

Strict aliasing mode

By default, aliases are transparent. The -fno-type-aliasing compiler flag makes aliases into distinct types that require explicit construction:

type MyString = string

// With -ftype-aliasing (default):
var a: string = "hello"
var b: MyString = a        // ok: transparent alias

// With -fno-type-aliasing:
var a: string = "hello"
var b: MyString = MyString(a) // explicit construction required
b = a                         // compile error: type mismatch

Restrictions

Type aliases must reference existing named types. Inline anonymous type definitions are not permitted:

type Pair = (i32, i32)                // ok: aliases a tuple type
// type Anon = struct { var x: i32 }  // compile error: cannot alias anonymous type definitions

If you need a named struct, declare a struct. Type aliases are for giving new names to existing types, not for defining new ones.


Type Inference

The compiler infers types from initializers, return expressions, and generic arguments. Explicit annotations are optional where inference succeeds.

Variable inference

var x = 42              // i32
var y = 3.14            // f64
var z = "hello"         // string
var v = [1, 2, 3]       // [i32]
var p = (1.0, true)     // (f64, bool)

See Primitives for default literal types.

Generic argument inference

Generic type arguments are inferred from the arguments at the call site:

fn <T> identity(x: T) -> T { return x }

identity(42)        // T inferred as i32
identity("hello")   // T inferred as string

This extends to complex generic patterns:

fn <T> first(items: [T]) -> T { return items[0] }

var nums: [i32] = [1, 2, 3]
first(nums)   // T inferred as i32 from [T] matching [i32]

Explicit generic arguments are required only when inference is ambiguous or when no arguments provide type information:

var s = Stack<i32>()   // no arguments to infer from, must specify

Return type inference

Return types are never inferred for block-bodied functions or forward declarations. They must be declared explicitly, or they default to void:

fn add(a: i32, b: i32) -> i32 { return a + b }   // explicit
fn log(msg: string) { std::println(msg) }        // defaults to void

Expression-bodied functions (fn foo() = expr) are the one exception: if the return type annotation is omitted, it is inferred from the expression.

fn square(x: i32) = x * x          // inferred as i32
fn name() = "Kairo"                // inferred as string
fn typed(x: i32) -> i64 = x as i64 // explicit still allowed

See Functions


Implicit Conversions

Kairo minimizes implicit conversions. The following are the only implicit conversions in the language:

Numeric widening

Integer and float types can be implicitly widened to a larger type of the same signedness:

var a: i32 = 42
var b: i64 = a       // ok: i32 to i64

var x: f32 = 1.0f32
var y: f64 = x       // ok: f32 to f64

Narrowing conversions require an explicit as cast. See Casting.

T to T?

A non-nullable value is implicitly convertible to its nullable counterpart:

fn maybe_log(msg: string?) { ... }

var s = "hello"
maybe_log(s)   // string implicitly converts to string?

The reverse (T? to T) requires explicit unwrapping see Variables.

Derived-to-base pointer

A pointer to a derived class is implicitly convertible to a pointer to its base class:

class Animal { ... }
class Dog derives Animal { ... }

fn feed(animal: *Animal) { ... }

var dog = Dog("Rex", "Labrador")
feed(&dog)   // *Dog implicitly converts to *Animal

This is always safe the base subobject is at a known offset. The reverse (base-to-derived) requires an explicit cast because it can fail at runtime. See Casting.

No other implicit conversions

The following conversions are all explicit (require as):

  • Enum to integer or integer to enum
  • T? to T (use unwrap!(), ??, or null checking)
  • Base-to-derived pointer (downcast)
  • Any pointer to a different pointer type
  • Integer narrowing or float-to-integer

Subtyping

Kairo has a limited subtyping hierarchy:

  • ! (never type) is a subtype of every type
  • A derived class is a subtype of its base class(es)

Interface-satisfying types are not subtypes of the interface. Interfaces are constraints checked at compile time via impl bounds, not runtime-dispatchable types. For runtime polymorphism, use base class pointers with virtual dispatch or unsafe *void for type-erased pointers.


The Never Type (!)

! is the return type of functions that never return they panic, loop forever, or call a no-return function:

fn fatal(msg: string) panic -> ! {
    panic std::Error::Runtime(msg)
}

! is a subtype of every type, making it valid in expression contexts:

var x: i32 = if valid { compute() } else { fatal("bad state") }

! can only appear as a function return type. It cannot be used as a variable type, type parameter, or in any other type position:

// var x: !          // compile error
// var y: Foo<!>     // compile error

See Functions for no-return function semantics.


void

void represents the absence of a value. It is the default return type for functions with no explicit return type.

void cannot be used as a variable type or type parameter. It can appear in pointer types for opaque, untyped pointers:

var handle: unsafe *void = get_opaque_handle()
var safe_handle: *void = get_tracked_handle()

// var x: void       // compile error
// var y: Foo<void>  // compile error

See Primitives for details.


Nullable Types in the Type System

T? is syntactic sugar for the compiler-intrinsic Nullable<T> tagged union. Nullable is not directly usable as a type name use the ? suffix:

var x: i32? = 42
var y: string? = null

Nested nullables

T?? is a parser error because ?? is the null-coalescing operator. Use parentheses for nested nullables:

// var x: i32??     // parse error: ?? is an operator
var x: (i32?)? = null   // ok: Nullable<Nullable<i32>>

Nested nullables are rarely useful. If you find yourself writing (T?)?, reconsider the data model.

See Variables for null checking, safe access, and unwrapping.


Tuple Types

Tuples are fixed-size, heterogeneous, ordered groups of values:

var point: (f64, f64) = (1.0, 2.0)
var record: (i32, string, bool) = (42, "answer", true)

Single-element tuples do not exist. (T) is just T the parentheses are grouping, not a tuple constructor:

var x: (i32) = 42   // same as var x: i32 = 42

There is no unit type or empty tuple (). Functions that return nothing use void.

See Primitives for tuple syntax and Variables for tuple destructuring.


Function Types

Function pointer types are written as fn(ParamTypes) -> ReturnType:

var add: fn(i32, i32) -> i32 = fn (a: i32, b: i32) -> i32 { return a + b }
var log: fn(string) -> void = std::println

The panic modifier can appear on function types to indicate the function may panic. Callers must handle the panic via try/catch:

var risky: fn(i32) panic -> i32 = some_panicking_function

try {
    risky(42)
} catch e {
    // handle
}

No other modifiers (const, async, eval, unsafe) are valid on function pointer types. const is a property of methods (it constrains self), not of the function signature. unsafe selects a different overload namespace, not a different type. async and eval require named function declarations.

See Functions and Closures for function pointer usage.


Collection Types

Collection literal syntax maps to built-in types:

SyntaxTypeDescription
[T]VectorGrowable contiguous array (ptr + len + cap)
[T; N]ArrayFixed-size, stack-allocated, N must be compile-time
{K: V}MapHash map
{T}SetHash set

These are primitives in the type system, not generic library types. The standard library provides type aliases for the explicit names:

// In module std:
type <T> Vector = [T]
type <T, N> Array = [T; N]
type <K, V> HashMap = {K: V}
type <T> HashSet = {T}

The literal syntax and the aliased names are interchangeable.

N in [T; N] must be a compile-time evaluable expression. Runtime-dependent array sizes are not supported use [T] (vector) for dynamically sized collections:

eval SIZE = 256
var buffer: [u8; SIZE] = [0; SIZE]    // ok: SIZE is compile-time

// var dynamic: [u8; runtime_val]     // compile error: N must be compile-time

See Eval for compile-time evaluation rules.


typeof

typeof has dual behavior depending on whether it appears in a type position or an expression position:

Type position

In a type position, typeof resolves to the compile-time type of the expression:

var x = 42
var y: typeof x = 100   // typeof x resolves to i32 at compile time

Expression position

In an expression position, typeof returns a TypeInfo object:

var x = 42
var info = typeof x
info.get_pretty_name()   // "i32"
info.get_size()          // 4
info.get_align()         // 4

TypeInfo

TypeInfo is a built-in class that describes a type at runtime:

class TypeInfo {
    fn get_name(self) const -> string        // mangled ABI name
    fn get_pretty_name(self) const -> string  // human-readable (e.g. "HashMap<string, i32>")
    fn get_size(self) const -> usize
    fn get_align(self) const -> usize
    fn get_abi_format(self) const -> string   // ABI format string for interop
    fn get_kind(self) const -> TypeKind
}

enum TypeKind {
    Primitive,
    Struct,
    Class,
    Enum,
    Union,
    Function,
    Interface,
}

get_name() returns the mangled name following the platform’s C++ ABI (Itanium on Unix, MSVC on Windows). get_pretty_name() returns a human-readable string including generic arguments.

Important

The TypeInfo API is still being finalized. Additional methods for querying members, base classes, and interface conformance may be added in a future update.

Compile-time typeof

typeof in eval if conditions is resolved at compile time:

fn <T> process(x: T) {
    eval if typeof T == i32 {
        // only compiled when T is i32
        fast_int_path(x)
    } else {
        generic_path(x)
    }
}

See Eval for compile-time evaluation and Control Flow for eval if.


Self

Self is a type alias that resolves to the enclosing type. It is available in:

Self always refers to a reference to the type. For a pointer, use *Self. In a generic class class <T> Foo, Self resolves to Foo<T>:

class <T> Hold {
    var value: T

    fn Hold(self, v: T) { self.value = v }

    fn replace(self, other: Self) {
        // Self is Hold<T>
        self.value = other.value
    }
}

Self is not available in free functions or at the top level.


Type Identity

Two types are the same if they have the same fully qualified name and the same generic arguments. This is nominal identity, not structural:

struct Point { var x: f64; var y: f64 }
struct Vec2  { var x: f64; var y: f64 }

// Point and Vec2 are different types despite identical structure
// var p: Point = Vec2 { x: 1.0, y: 2.0 }   // compile error

Generic types are identified by their monomorphized form. Foo<i32> and Foo<i64> are different types. Foo<i32> in one translation unit is the same type as Foo<i32> in another identity follows the platform’s C++ ABI mangling rules.

Type aliases are transparent (unless -fno-type-aliasing is set) the alias and the original type are the same type for identity purposes.


Summary

// Type alias
type Meters = f64
type <T> Pair = (T, T)

// Type inference
var x = 42                          // i32
var y = [1.0, 2.0]                  // [f64]
fn <T> id(x: T) -> T = x
id(42)                              // T inferred as i32

// Implicit conversions
var n: i64 = 42i32                  // numeric widening
var s: string? = "hello"            // T to T?
var a: *Animal = &Dog("Rex", "Lab") // derived-to-base pointer

// Nullable
var x: i32? = null
var y = x ?? 0                      // null coalescing

// typeof
var info = typeof x
info.get_pretty_name()              // "Nullable<i32>"
var z: typeof x = 10                // type position: resolves to i32?

// Function types
var f: fn(i32) -> i32 = fn (x: i32) -> i32 { return x * 2 }
var g: fn(i32) panic -> i32 = risky_fn

// Collections
var vec: [i32] = [1, 2, 3]
var arr: [u8; 4] = [0, 0, 0, 0]
var map: {string: i32} = {"a": 1}
var set: {i32} = {1, 2, 3}

Casting

https://www.kairolang.org/docs/language/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.


Numeric Casts

Widening

Integer and float widening is implicit no as required:

var a: i32 = 42
var b: i64 = a       // implicit

as is permitted but redundant for widening 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

Upcasting (derived to base)

Derived-to-base pointer conversion is implicit no as required:

class Animal { ... }
class Dog derives Animal { ... }

var dog = Dog("Rex", "Labrador")
var animal: *Animal = &dog   // implicit upcast

Downcasting (base to derived)

Base-to-derived casts come in two forms:

Asserting downcast panics if the runtime type does not match. The function must have the panic specifier or the cast must be inside a try block:

fn process(animal: *Animal) panic {
    var dog = animal as *Dog   // panics if animal is not a Dog
    dog->fetch()
}

Checked downcast returns a nullable pointer. &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 != &null {
        dog->fetch()
    }
}

Both forms perform a runtime type check using the vtable (the class must have at least one virtual method). Downcasting a non-polymorphic class is a compile error.

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:

var ptr: *i32 = &some_value
var raw = ptr as unsafe *void    // type erasure
var back = raw as unsafe *i32    // reinterpret caller must ensure correctness

Casting between unsafe *T types is always permitted. The result is the same pointer value with a different type no runtime check, no adjustment.

Caution

Raw pointer casts bypass AMT’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. No unsafe block is required:

var ptr: *i32 = &some_value
var addr = ptr as usize    // ok: numeric address

var truncated = ptr as u8  // warning: truncation of pointer value

usize is the natural target type since it matches pointer width. Casting to a narrower integer produces a truncation warning.

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:

var addr: usize = 0x7FFE_0000_1000
var ptr = addr as unsafe *i32   // ok: fabricating an unsafe pointer

// var bad = addr as *i32       // compile error: cannot create safe pointer from integer

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

Casting an integer to an enum that has no matching discriminant is undefined behavior. The compiler does not insert a runtime check.

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(self) const -> u32 {
        match self {
            case .Success { 0 }
            case .Error   { 1 }
            case .EndOfInput { 2 }
        }
    }
}

Nullable Collapsing Cast

Casting a nullable value T? to its underlying type T produces a non-null value. If the source is null, a default-constructed value of T is used instead:

var x: i32? = null
var y = x as i32     // 0 (default-constructed i32)

var s: string? = "hello"
var t = s as string  // "hello"

The target type T must be trivially default-constructible. If the type has a deleted default constructor, the collapsing cast is a compile error:

class NoDefault {
    fn NoDefault(self) = delete
}

var obj: NoDefault? = null
// var x = obj as NoDefault   // compile error: NoDefault has no default constructor

This differs from unwrap!(), which panics on null instead of default-constructing:

x as Tunwrap!(x)
Source is non-nullReturns the valueReturns the value
Source is nullReturns T() (default)Panics
Requires default constructorYesNo
Requires panic / tryNoYes

See Variables for other nullable operations (?., ??, 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. op as must take only self as a parameter and return the target type.

See Operators for the full operator overloading reference.


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 checkPanics
Base-to-derived ptr (checked)ptr as *Derived?Runtime checkReturns &null
Raw pointer castptr as unsafe *TNo checkReinterpret
Pointer to integerptr as usizeSafeAddress value
Integer to pointern as unsafe *TUnsafeFabricated pointer
Plain enum to inte as u8SafeDiscriminant value
Int to plain enumn as DirectionUB if no matchNo runtime check
ADT enum to intN/ACompile errorUse extend method
Nullable collapsex as TSafeDefault-constructs on null
User-definedx as TargetTypeDepends on op asCalls user code
T to T?ImplicitSafeN/A
T? to Tx as TCollapsing castDefault on null

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

Requires Clauses

https://www.kairolang.org/docs/language/requires/

Requires Clauses

requires attaches compile-time constraints to declarations. A requires clause specifies a condition that must hold at compile time if it doesn’t, the program does not compile. There is no runtime fallback, no dispatch, no branching. requires is a static gate.

For runtime-conditional dispatch, see Where Clauses.

PropertyGuarantee
Evaluation timeCompile time, always
Memory safeYes
Runtime costZero
Valid on functionsYes
Valid on typesYes
Valid on interface methodsYes
Valid on interfacesYes (constrains conforming types)

Basic Syntax

A requires clause appears after the parameter list and return type, before the function body:

fn divide(a: i32, b: i32) -> i32
  requires b != 0 {
    return a / b
}

If the condition cannot be evaluated at compile time, the compiler emits a hard error:

error: requires expression is not compile-time evaluable
  --> src/math.k:1:32
  1 | fn divide(a: i32, b: i32) -> i32
  2 |   requires b != 0 {
    |            ^^^^^^ depends on runtime value 'b'
  note: for runtime-conditional dispatch, use 'where'

Multiple conditions are combined with boolean operators in a single expression:

fn safe_index(arr: [i32], i: i32) -> i32
  requires i >= 0 && i < arr.len() {
    return arr[i]
}

Compile-Time Expressions

Any expression the compiler can evaluate statically is valid in a requires clause:

ExpressionExample
sizeof(T)requires sizeof(T) <= 64
alignof(T)requires alignof(T) >= 16
Arithmetic and comparisonsrequires N > 0 && N <= 1024
eval variables and functionsrequires is_power_of_two(N)
Type trait checksrequires T impl Comparable
typeof queriesrequires typeof T == i32

If the expression depends on a runtime value, it belongs in a where clause, not requires.


Type Constraints

impl and derives bounds can appear in requires clauses as an alternative to inline bounds on type parameters. The compiler desugars them identically:

// These are equivalent:
fn <T impl Serializable> save(data: T) { ... }
fn <T> save(data: T) requires T impl Serializable { ... }

The requires form is preferred when constraints are long or involve relationships between multiple type parameters:

fn <T, U> convert(input: T) -> U
  requires T impl Serializable && U impl Deserializable {
    var bytes = input.serialize()
    return U::from_bytes(bytes)
}

Requires on Types

requires on a class, struct, or enum is checked at instantiation time. A violation is a hard compile error at the point of use:

class <T> SmallBuffer
  requires sizeof(T) <= 128 {
    var data: [T; 16]
}

SmallBuffer<i32>()         // ok: sizeof(i32) == 4
SmallBuffer<[u8; 256]>()   // compile error: sizeof([u8; 256]) == 256, exceeds 128

struct <T> Aligned16
  requires alignof(T) >= 16 {
    var value: T
}

enum <T> Compact
  requires sizeof(T) <= 8 {
    Some { value: T },
    None,
}

Type-level constraints are always requires. There is no runtime dispatch on type instantiation either the type is valid or it isn’t.


Requires on Interface Methods

requires on an interface method constrains what conforming types must satisfy. The conforming type must implement the method with an identical requires clause:

interface Accumulator {
    fn add(self, value: i32) -> Self
      requires value > 0
}

class Counter impl Accumulator {
    var total: i32

    fn Counter(self) { self.total = 0 }

    fn add(self, value: i32) -> Self
      requires value > 0 {
        self.total += value
        return self
    }
}

Because interfaces are zero-cost structural contracts, requires is the only valid constraint keyword on interface members. where is not permitted runtime dispatch inside an interface method would violate the zero-cost guarantee.


Requires on Interfaces

A requires clause on an interface declaration constrains which types can conform. A type that does not satisfy the requires cannot implement the interface, even if it has all the required methods:

interface <T> SmallStorable
  requires sizeof(T) <= 64 {
    fn store(self) -> [byte]
    fn load(data: [byte]) -> T
}

This is distinct from requires on an individual method it applies to the entire interface as a precondition on conformance.


Requires and eval

requires expressions are evaluated by the same compile-time engine as eval. Any eval function or variable is usable in a requires clause:

eval fn is_power_of_two(n: i32) -> bool {
    return n > 0 && (n & (n - 1)) == 0
}

fn <const N: i32> aligned_buffer() -> [u8; N]
  requires is_power_of_two(N) {
    return [0; N]
}

aligned_buffer<256>()   // ok: 256 is a power of two
aligned_buffer<300>()   // compile error: 300 is not a power of two

See Eval for compile-time evaluation rules.


Requires and panic

requires and panic serve different purposes and can coexist. requires filters invalid inputs before the function body executes. panic handles valid inputs that produce errors during processing:

fn parse_port(input: string) panic -> i32
  requires input.len() > 0 {
    var port = std::parse<i32>(input)
    if port < 0 || port > 65535 {
        panic std::Error::Runtime("port out of range")
    }
    return port
}

The requires clause rejects empty strings at compile time (when the argument is a constant) or documents the precondition for the caller. The panic handles the runtime error case of a non-empty string that isn’t a valid port.


Summary

// Compile-time size constraint on a generic
fn <T> stack_alloc() -> T
  requires sizeof(T) <= 4096 {
    // ...
}

// Type constraint in requires clause
fn <T, U> transform(input: T) -> U
  requires T impl Readable && U impl Writable {
    // ...
}

// Requires on a class
class <T> InlineBuffer
  requires sizeof(T) <= 64 {
    var data: [T; 8]
}

// Requires on an interface method
interface Validator {
    fn validate(self, input: string) -> bool
      requires input.len() > 0
}

// Requires with eval function
eval fn fits_in_cache_line(n: i32) -> bool = n <= 64

struct <T> CacheAligned
  requires fits_in_cache_line(sizeof(T)) {
    var value: T
}

Where Clauses

https://www.kairolang.org/docs/language/where/

Where Clauses

where attaches runtime-conditional constraints to function declarations. A where clause specifies a condition that is checked at the call site if it fails, execution falls through to the next matching overload. The compiler folds multiple where-constrained overloads into a single function with branch dispatch.

For compile-time constraints, see Requires Clauses.

PropertyGuarantee
Evaluation timeRuntime
Memory safeYes
Thread safeYes
Undefined behaviorNo
Requires unsafeNo
Valid on functionsYes
Valid on typesNo use requires
Valid on interfacesNo interfaces are zero-cost, use requires

Basic Syntax

A where clause appears after the parameter list and return type, before the function body:

fn sqrt(x: f64) -> f64
  where x >= 0.0 {
    return std::math::sqrt(x)
}

fn sqrt(x: f64) -> f64 {
    return 0.0   // fallback for negative input
}

At the call site, the compiler generates a branch. If the condition holds, the constrained overload runs. If not, execution falls to the next candidate.

Multiple conditions are combined with boolean operators:

fn safe_divide(a: i32, b: i32) -> i32
  where b != 0 && a >= 0 {
    return a / b
}

fn safe_divide(a: i32, b: i32) -> i32 {
    return 0   // fallback
}

Fallback Requirement

Every where-constrained overload must have a fallback an overload with no where clause (or a where clause that covers the remaining cases). If no fallback exists, the compiler emits an error:

error: no fallback overload for 'sqrt' when where condition fails
  --> src/math.k:1:1
  1 | fn sqrt(x: f64) -> f64
  2 |   where x >= 0.0 {
  note: add an overload without a 'where' clause to handle all remaining cases

The fallback is the compiler’s guarantee that every call site has a valid code path.


Declaration Order Determines Priority

When multiple where-constrained overloads exist for the same function, the compiler checks them in declaration order. The first satisfied condition wins. The unconstrained overload is always the final fallback:

fn categorize(x: i32) -> string where x > 100  { return "high" }
fn categorize(x: i32) -> string where x > 0    { return "positive" }
fn categorize(x: i32) -> string                 { return "non-positive" }

Conceptual codegen:

fn categorize(x: i32) -> string {
    if x > 100     { return "high" }
    else if x > 0  { return "positive" }
    else           { return "non-positive" }
}

Declaration order is the programmer’s explicit priority control. The compiler does not attempt to determine which condition is “more specific” it chains them sequentially in the order they appear in source.

Warning

A where condition that is a strict subset of a preceding condition will never fire. The compiler emits a warning when it can statically determine that a where clause is unreachable:

fn process(x: i32) -> string where x > 50 { return "high" }
fn process(x: i32) -> string where x > 100 { return "very high" }  // warning: unreachable
fn process(x: i32) -> string { return "low" }

Overlapping Conditions

If two where clauses have overlapping conditions that cannot be statically determined to be disjoint, the compiler emits a warning:

fn handle(x: i32) -> string where x > 0 && x < 100 { return "small positive" }
fn handle(x: i32) -> string where x > 50            { return "over 50" }  // warning: overlaps with above
fn handle(x: i32) -> string                          { return "other" }

Declaration order still determines the result x == 75 hits the first overload but the warning signals that the intent may not match the behavior.


Where in Match

The where keyword also appears in match arms as a guard condition. Match guards are always evaluated at runtime and are independent of the overload dispatch system they are simple boolean filters on a matched pattern, not function overloads:

match result {
    case .Found(var key, var value) where value > 0 {
        process(key, value)
    }
    case .Found(var key, var value) {
        skip(key)
    }
    default { }
}

A match guard that fails causes the arm to be skipped and evaluation continues to the next arm. See Control Flow for the full match syntax.


Runtime Dispatch and Timing

where dispatch introduces a branch at the call site. In most cases this is a single conditional jump that the branch predictor handles efficiently. However, if the constrained function processes sensitive data, the branch structure can leak information through execution time differences.

Caution

Runtime where dispatch creates observable branching. If the function handles sensitive data (cryptographic keys, authentication tokens, private user data), the timing difference between the constrained and fallback paths may be measurable by an attacker. Use constant-time implementations in those cases and do not rely on where dispatch for security-critical branching.


Where and panic

where clauses and panic are orthogonal. where selects which overload runs based on a runtime condition. panic signals an error from within a function body. They can coexist:

fn connect(host: string, port: i32) panic -> Connection
  where port > 0 && port <= 65535 {
    var conn = tcp_connect(host, port)
    if !conn.is_valid() {
        panic std::Error::IO("connection failed")
    }
    return conn
}

fn connect(host: string, port: i32) -> Connection {
    // fallback: invalid port, return a no-op connection
    return Connection::invalid()
}

Where and unsafe

where constraints and unsafe overloads are in separate namespaces. Safe overloads dispatch among themselves based on where conditions. Unsafe overloads are selected explicitly by the caller with the unsafe keyword and do not participate in where-based dispatch:

// Safe dispatch: where selects between these
fn access(data: [i32], index: i32) -> i32
  where index >= 0 && index < data.len() {
    return data[index]
}

fn access(data: [i32], index: i32) -> i32 {
    return 0   // fallback
}

// Unsafe overload: separate namespace, caller selects explicitly
fn access(data: [i32], index: i32) unsafe -> i32 {
    return data[index]   // no bounds check
}

var a = access(data, 5)          // safe dispatch
var b = unsafe access(data, 5)   // unsafe overload, no where dispatch

See Unsafe for the unsafe overload model.


What where Cannot Do

where is only valid on free functions and methods. The following are compile errors:

// compile error: where on a type use requires instead
class <T> Buffer
  where sizeof(T) <= 128 {
    var data: [T; 16]
}

// compile error: where on an interface method interfaces are zero-cost, use requires
interface Validator {
    fn validate(self, input: string) -> bool
      where input.len() > 0
}

Type instantiation is always compile-time. Interface conformance is a zero-cost structural contract. Neither admits runtime dispatch. Use requires for both.


Summary

// Basic runtime dispatch with fallback
fn sqrt(x: f64) -> f64 where x >= 0.0 { return std::math::sqrt(x) }
fn sqrt(x: f64) -> f64                 { return 0.0 }

// Multiple constrained overloads declaration order is priority
fn classify(temp: f64) -> string where temp > 100.0 { return "boiling" }
fn classify(temp: f64) -> string where temp > 0.0   { return "liquid" }
fn classify(temp: f64) -> string                     { return "frozen" }

// Where with panic
fn parse_positive(input: string) panic -> i32
  where input.len() > 0 {
    var n = std::parse<i32>(input)
    if n <= 0 { panic std::Error::Runtime("not positive") }
    return n
}

fn parse_positive(input: string) -> i32 {
    return 0
}

// Where coexisting with unsafe overload
fn divide(a: i32, b: i32) -> i32 where b != 0 { return a / b }
fn divide(a: i32, b: i32) -> i32               { return 0 }
fn divide(a: i32, b: i32) unsafe -> i32        { return a / b }  // no check

var result = divide(10, 0)          // 0 (fallback)
var fast   = unsafe divide(10, 2)   // 5 (unsafe, no dispatch)

Pointers & Raw Pointers

https://www.kairolang.org/docs/language/pointers/

Pointers & Raw Pointers

Kairo has two pointer types: safe pointers (*T) with compiler-tracked lifetime and null checking, and raw pointers (unsafe *T) with no tracking and no checks. Both are 8 bytes on 64-bit platforms and hold a memory address.


Safe Pointers (*T)

*T is the default pointer type. The compiler tracks its provenance via AMT and inserts null checks on dereference:

var x = 42
var p: *i32 = &x     // p points to x
*p = 100              // dereference and assign null check inserted
std::println(*p)      // 100

Non-Null Guarantee

*T cannot hold null. Attempting to assign &null to a *T is a compile error:

var p: *i32 = &null   // compile error: *T is non-null

var x = 42
var p: *i32 = &x      // ok: p is guaranteed valid
*p = 100              // no null check needed

If you need a pointer that might be absent, use *T?:

var p: *i32? = get_pointer()   // might be null

if p? {
    std::println(*p)   // safe: compiler has verified p is non-null
}

For raw nullable pointers with no compiler tracking, use unsafe *T:

var raw: unsafe *i32 = &null   // ok: raw pointers can be null
if raw != &null {
    std::println(*raw)
}

Member access

Use -> to access members through a pointer:

class Config {
    pub var port: i32
    fn Config(self, port: i32) { self.port = port }
}

var cfg = Config(8080)
var ptr: *Config = &cfg
ptr->port   // 8080

Raw Pointers (unsafe *T)

unsafe *T is an untracked pointer with no null checks, no bounds checks, and no AMT provenance tracking. It is equivalent to a raw C/C++ pointer:

var x = 42
var p: unsafe *i32 = unsafe &x   // create raw pointer from safe binding
*p = 100                          // no null check

Dereferencing a null unsafe *T is undefined behavior. The compiler will not insert a check.

Raw pointers are required for C/C++ interop, custom allocators, hardware register access, and any scenario where AMT tracking is not possible or not desired.

Creating raw pointers

// From a safe binding
var x = 42
var raw: unsafe *i32 = unsafe &x

// From heap allocation
var raw: unsafe *i32 = unsafe std::alloc<i32>(sizeof i32)

// From an integer address
var raw = 0x7FFE_0000_1000 as unsafe *i32

// Null
var raw: unsafe *i32 = &null

const Pointers

The const binding rule applies to pointers left-to-right. const on the binding prevents reassigning the pointer. *const T prevents modifying the pointed-to value:

var ptr: *i32 = &x
// ptr is mutable, *ptr is mutable

const ptr: *i32 = &x
// ptr is const (cannot reassign), *ptr is mutable
*ptr = 10    // ok
ptr = &y     // compile error

var ptr: *const i32 = &x
// ptr is mutable (can reassign), *ptr is const
*ptr = 10    // compile error
ptr = &y     // ok

const ptr: *const i32 = &x
// both const
*ptr = 10    // compile error
ptr = &y     // compile error

See Variables for the full const model.


Pointer Arithmetic

On safe pointers (*T)

Safe pointers support offset arithmetic with integers. The offset is in units of sizeof T:

var arr: [i32; 4] = [10, 20, 30, 40]
var p: *i32 = &arr[0]

var second = *(p + 1)    // 20
var third = *(p + 2)     // 30
p = p - 1                // ok: reposition pointer

Pointer-to-pointer arithmetic (p1 - p2) is not permitted on safe pointers use unsafe *T for that.

Caution

Safe pointer arithmetic is bounds-checked by AMT only when provenance is trackable. If the pointer originates from a context where AMT cannot determine the allocation bounds, the arithmetic compiles but bounds safety is not guaranteed. Prefer array/vector indexing over pointer arithmetic when possible.

On raw pointers (unsafe *T)

Raw pointers support all arithmetic operations with no checks:

var p: unsafe *i32 = get_buffer()
var q: unsafe *i32 = p + 10      // offset by 10 i32s (40 bytes)

var distance = q - p              // 10 (pointer difference in units of sizeof i32)

Out-of-bounds access through raw pointer arithmetic is undefined behavior.


Array-Style Indexing

Pointers support bracket indexing, which desugars to offset + dereference:

var p: *i32 = &arr[0]
p[0]    // same as *(p + 0)
p[2]    // same as *(p + 2)

Bounds checking follows the same rules as pointer arithmetic AMT checks when provenance is trackable, no checks on unsafe *T.


Void Pointers

*void and unsafe *void are opaque pointers that hold an address without type information. They cannot be dereferenced cast to a typed pointer first:

var handle: unsafe *void = get_opaque_handle()

// *handle             // compile error: cannot dereference void pointer
var typed = handle as unsafe *i32
var value = *typed     // ok: typed pointer

Void pointers are used for type-erased APIs, opaque handles, and C interop where the concrete type is not known at the Kairo call site.


Double Pointers

Pointers to pointers are legal and follow the same rules recursively:

var x = 42
var p: *i32 = &x
var pp: **i32 = &p

**pp = 100   // x is now 100

const applies at each level independently:

const pp: *const *i32 = &p
// pp cannot be reassigned
// *pp (the inner pointer) cannot be reassigned
// **pp (the i32) can be modified

Smart Pointer Promotion

AMT analyzes pointer usage and automatically promotes safe pointers to smart pointers when needed. The smart pointer types are compiler intrinsics exposed through the standard library:

TypeDescription
std::Unique<*T>Single-owner, exclusive access, freed on drop
std::Shared<*T>Reference-counted, multiple owners, freed when count reaches zero
std::Weak<*T>Non-owning reference to a Shared allocation, does not prevent deallocation

AMT decides which smart pointer type to use based on how the pointer is used across the program. The programmer does not need to annotate or choose AMT handles it automatically:

fn make_config() -> *Config {
    var cfg = std::create<Config>(8080)
    return cfg   // AMT determines ownership: likely Unique or Shared
}

If AMT cannot determine a safe promotion path (e.g., the pointer escapes in a way that prevents tracking), it emits a compile error rather than allowing unsafe behavior.

Smart pointer types can be used explicitly to override AMT’s decision or to validate compiler behavior:

var ptr: std::Unique<*Config> = std::create<Config>(8080)
var shared: std::Shared<*Config> = std::create<Config>(8080)

See AMT for the full lifetime and promotion model, and Ownership for borrowing semantics.


Heap Allocation

Stack allocation is the default. Heap allocation uses std::create<T>():

var stack_val = Config(8080)                    // stack-allocated
var heap_ptr = std::create<Config>(8080)        // heap-allocated, AMT chooses pointer type

For raw heap allocation without AMT tracking:

var raw = unsafe std::alloc<i32>(sizeof i32 * 10)   // raw allocation, 10 i32s
// must be manually freed
unsafe std::free(raw)

See AMT for how allocation interacts with lifetime tracking.


Pointer Comparison

OperatorBehavior
==Compares addresses do both pointers point to the same location?
!=Negation of ==
===Deep equality dereferences both pointers and compares the values
var a = 42
var b = 42
var p = &a
var q = &b

p == q    // false: different addresses
p === q   // true: both point to 42

var r = &a
p == r    // true: same address

=== is defined only on *T, which cannot be null, so there is no null case to handle. For raw pointers (unsafe *T), use == for address comparison and dereference manually after a null check. *T? deep equality goes through the nullable system null-check with ? first, then === the unwrapped pointers.

See Operators for the full comparison model.


Function Pointers

Function pointers (fn(T) -> R) are a separate type from *T. They are opaque values that cannot be cast to data pointers or vice versa:

fn add(a: i32, b: i32) -> i32 = a + b
var f: fn(i32, i32) -> i32 = add

// var p = f as unsafe *void   // compile error: function pointers are not data pointers

See Functions for function pointer syntax and Closures for closures as function pointers.


Pointers and Nullable Types

*T is non-null by construction. To represent a pointer that might not exist, use *T? which wraps the pointer in the standard Nullable<T> system:

*T*T?unsafe *T
Can be nullNoYesYes
Null check mechanismN/A?., ??, unwrap!(), val?ptr != &null (manual)
Dereference nullCannot happenCompile error (must check first)Undefined behavior
AMT trackedYesYesNo

*T? supports the same null-handling operators as any other nullable type. unsafe *T uses manual null comparison, it does not participate in the Nullable<T> system.

var p: *i32 = &x            // always valid, no null check needed
var q: *i32? = find_ptr()   // might be null must check before use
var r: unsafe *i32 = &null  // raw pointer, nullable, no tracking

if q? {
    std::println(*q)         // compiler knows q is non-null here
}

var val = q ?? &fallback     // use fallback pointer if q is null
var forced = unwrap!(q)      // panics if null

See Variables for the full nullable type system.


Summary

// Safe pointer
var x = 42
var p: *i32 = &x
*p = 100

// Raw pointer
var raw: unsafe *i32 = unsafe &x

// Null
var null_ptr: *i32 = &null
if null_ptr != &null {
    std::println(*null_ptr)
}

// Const pointer
var ptr: *const i32 = &x    // mutable pointer to const value
const ptr: *i32 = &x        // const pointer to mutable value

// Pointer arithmetic
var arr: [i32; 4] = [10, 20, 30, 40]
var p: *i32 = &arr[0]
*(p + 2)   // 30

// Heap allocation
var heap = std::create<Config>(8080)

// Smart pointer (explicit)
var unique: std::Unique<*Config> = std::create<Config>(8080)

// Void pointer
var opaque: unsafe *void = get_handle()
var typed = opaque as unsafe *i32

// Double pointer
var pp: **i32 = &p
**pp = 999

// Comparison
p == q     // address comparison
p === q    // deep value comparison

Ownership

https://www.kairolang.org/docs/language/ownership/

Ownership

Warning

The ownership model is enforced by AMT, which is not yet implemented. AMT development begins at Stage 2 of the compiler roadmap. This page describes the intended design. See AMT for the implementation timeline.

Kairo’s ownership model governs how values are created, transferred, borrowed, and destroyed. It works in conjunction with AMT to provide memory safety without lifetime annotations and without a separate reference type.

The model has two parts: transfer semantics (how values move between bindings) and pointer aliasing (how multiple pointers to the same value interact). Transfer semantics are determined by the type’s lifecycle category. Pointer aliasing is tracked by AMT at compile time.


Transfer Semantics

Every type in Kairo has a lifecycle category that determines what happens when a value is assigned to a new binding, passed to a function, or returned. The category is set by which transfer constructor the type defines. See Classes Lifecycle Categories for the declaration syntax.

COPY types

A type with a @copy transfer constructor (explicit or implicit) is copyable. Assignment produces an independent copy both the source and destination are live after the assignment:

class Buffer {
    var data: [i32]

    @copy
    fn Buffer(self, other: Self) = default
}

var a = Buffer()
var b = a          // copy: a and b are independent
b.data.push(42)
a.data.len()       // 0 a is unaffected

AMT may elide a copy into a move when it proves the source is not used after the transfer and the elision is unobservable. This is a pure optimization with no observable semantic difference. AMT does not elide when the destructor has timing-sensitive side effects see AMT Copy Elision for the exact rule.

The programmer does not opt into or control copy elision. AMT applies it when safe.

MOVE types

A type with a @move transfer constructor can only be moved. Assignment transfers ownership the source is invalidated and cannot be used:

class UniqueFile {
    var handle: i32

    @move
    fn UniqueFile(self, other: Self) = default
}

var a = UniqueFile()
var b = a              // move: ownership transfers to b
// a is invalidated any use after this line is a compile error

a.handle               // compile error: a has been moved
b.handle               // ok: b owns the value

A type cannot define both @copy and @move pick one. A type with neither is implicitly COPY with compiler-generated members.

NON_TRANSFER types

A type with both @copy and @move explicitly deleted cannot be assigned, copied, or moved. The auto-derived op = is also = deleted as a consequence there is no transfer constructor for it to be generated from. These are scope-bound values: they live and die in the scope where they are created:

class ScopeLock {
    @copy fn ScopeLock(self, other: Self) = delete
    @move fn ScopeLock(self, other: Self) = delete
}

var lock = ScopeLock()
// var copy = lock      // compile error: copy deleted
// var moved = lock     // compile error: move deleted
// lock = ScopeLock()   // compile error: op = is deleted
// lock lives until end of scope, then destructor runs

Structs

Structs are always trivially copyable via memcpy. They have no transfer constructors, no destructors, and no lifecycle categories. Assignment always copies:

struct Point { var x: f64; var y: f64 }

var a = Point { x: 1.0, y: 2.0 }
var b = a      // memcpy always, unconditionally
b.x = 9.0
a.x            // 1.0 independent copy

See Structures.


Function Parameters

Function parameters follow the same transfer rules as assignment. Passing a COPY type copies it. Passing a MOVE type moves it the caller cannot use the value after the call:

fn consume(file: UniqueFile) {
    // file is owned by consume
}

var f = UniqueFile()
consume(f)
// f is invalidated moved into consume
f.handle       // compile error: f has been moved
fn inspect(buf: Buffer) {
    // buf is an independent copy
}

var b = Buffer()
inspect(b)
b.data.len()   // ok: b is still live, inspect got a copy

Last-use move optimization

For MOVE types, AMT detects when a value is passed to a function and never used again. In this case, the value is moved rather than requiring explicit annotation:

var m = Moveable()
foo(m)          // m is moved AMT sees m is not referenced after this line
// m is invalidated from here

This only applies when the value is genuinely unused after the call. If any subsequent code references the value, it is a compile error (MOVE types cannot be copied).

Pass-by-pointer optimization

When a function takes a parameter by value and the type is larger than a pointer (8 bytes on 64-bit), the compiler may silently pass a pointer instead of copying. This is a codegen optimization only the source-level semantics are always by-value. The optimization applies only when AMT can prove the by-value semantics are preserved for the duration of the call:

  • The parameter is not aliased and mutated through any other live pointer while the call is in progress i.e. AMT proves no write reaches the same allocation during the call.
  • The parameter is not modified inside the callee (or the function takes it as const).
  • The parameter is not stored, returned, or captured.
  • The function is not async.

The first condition is stronger than “no other pointer exists.” Kairo permits arbitrary mutable aliasing in single-threaded code (see Pointer Aliasing), so AMT cannot rely on the absence of aliases it must prove that no write through an alias is observable during the call. If it cannot prove this, the parameter is copied as written. The programmer does not control this optimization and cannot observe it.


Pointer Aliasing

Kairo allows multiple pointers to the same value. There is no Rust-style exclusivity rule (one mutable xor many immutable). Multiple *T to the same location is legal, and writing through one pointer is visible through all others:

var x = 42
var p: *i32 = &x
var q: *i32 = &x
*p = 100
std::println(*q)   // 100 defined behavior

This applies uniformly regardless of const:

var x = 42
var p: *i32 = &x
var q: *const i32 = &x
*p = 100
std::println(*q)   // 100 *const prevents mutation through q, not through p

const is a semantic check on the binding, not an aliasing constraint. *const T prevents the holder from mutating through that pointer. It does not prevent other pointers from mutating the same value. AMT does not change behavior based on const qualifiers it tracks provenance and lifetime independently of mutability.

What AMT enforces

AMT does not restrict aliasing patterns in single-threaded code. What it does enforce:

Provenance validity. A pointer must refer to memory that is still live. Using a pointer after its target has been destroyed is a hard error:

var p: *i32
{
    var x = 42
    p = &x
}
*p = 10    // hard error: x is destroyed, p is dangling

Iterator invalidation. A pointer into a container’s buffer is invalidated by operations that may reallocate the buffer:

var v: [i32] = [1, 2, 3]
var p: *i32 = &v[0]
v.push(4)           // may reallocate v's internal buffer
std::println(*p)    // hard error: p's provenance is invalidated by push

AMT detects this through .amt summaries: push’s summary records that it may reallocate the backing buffer, so any live pointer into that buffer is flagged at the call site. Where the buffer is mutated through a path AMT cannot trace (an opaque container, a raw FFI call), AMT conservatively errors rather than allowing an unprovable access. See AMT Analysis scope.

Stack escape. A pointer to a stack-allocated value cannot outlive the value. Returning &x where x is a local is always a hard error. There is no heap allocation behind a stack value, so no smart pointer can rescue it this is why a stack escape is an error rather than a promotion candidate. See AMT Stack Pointers.

Note

Data-race detection across threads is part of the intended model but depends on Kairo’s concurrency design (spawn, the memory model, happens-before semantics), which is not yet finalized. See Concurrency. Once that model is pinned, AMT will treat concurrent write+read and write+write to the same allocation without synchronization as a hard error. The semantics here are a target, not a current guarantee.

What AMT does not enforce

AMT does not prevent multiple mutable pointers to the same value in single-threaded code. This is intentional many valid patterns require mutable aliasing (parent/child pointers, graph structures, cache-and-source patterns). The tradeoff: Kairo allows more programs than Rust at the cost of not statically preventing all aliasing bugs. AMT catches the ones that are provably wrong (dangling, invalidation, and once concurrency is finalized races) and lets the rest through.

noalias optimization

When AMT proves that two pointers do not alias (point to different allocations or non-overlapping regions), it attaches noalias metadata to the LLVM IR. This enables the backend optimizer to perform more aggressive transformations (load/store reordering, vectorization) without the programmer writing anything. This is invisible the source code does not change, and the behavior is identical with or without the tag.


Smart Pointer Promotion and Aliasing

When AMT promotes a heap pointer to a smart pointer (in debug mode), the aliasing pattern determines which smart pointer type is chosen:

// Single owner, no aliasing -> Unique
var cfg = std::create<Config>(8080)
return cfg
// AMT: cfg has one owner -> Unique
// Aliased, both pointers escape -> Shared
var cfg = std::create<Config>(8080)
server_a.config = cfg
server_b.config = cfg
// AMT: cfg is aliased across two live bindings -> Shared
// Aliased, but the alias dies before escape -> Unique
var cfg = std::create<Config>(8080)
{
    var tmp: *Config = cfg
    validate(tmp)
}
// tmp is dead cfg has single ownership at this point
return cfg
// AMT: alias was short-lived, cfg is sole owner -> Unique

The promotion trigger is not “multiple pointers exist” but “multiple pointers exist AND the aliasing pattern requires shared ownership for safety.” A short-lived alias that dies before the owner escapes does not force Shared.

Because AMT is whole-program, the cases where promotion is actually needed are narrow. Most pointers have fully contained lifetimes and require no promotion at all.

See AMT Promotion Decision for the full decision tree.


Closure Captures

Closures capture variables from their enclosing scope. The capture mode determines the ownership relationship between the closure and the captured variable.

Capture by transfer (|=|)

|=| captures all referenced variables by their type’s transfer semantics COPY types are copied, MOVE types are moved. Captures happen at closure creation time, not at invocation:

var buf = Buffer()       // COPY type
var file = UniqueFile()  // MOVE type

var closure = fn ()|=| {
    buf.data.push(1)     // operates on the closure's copy
    file.close()         // operates on the moved-in file
}

buf.data.len()           // ok: buf was copied, original is still live
file.handle              // compile error: file was moved into the closure

Capture by address (|&|)

|&| captures all referenced variables by address. The closure holds *T to each captured variable & here is the address-of operator, the same & used everywhere else in the language. Mutations through the pointer affect the original:

var count = 0

var inc = fn ()|&| {
    count += 1    // modifies the original count through a pointer
}

inc()
inc()
count   // 2

AMT tracks address captures the same way it tracks any other pointer. If the closure escapes and the captured variable is stack-allocated, it is a hard error stack pointers cannot be promoted:

fn make_closure() -> fn() -> i32 {
    var x = 42
    return fn ()|&| -> i32 { return x }
    // hard error: x is stack-allocated, closure would outlive it
    // AMT will not promote stack escapes are never promotable
}

Capture by transfer avoids this the closure owns its own copy:

fn make_closure() -> fn() -> i32 {
    var x = 42
    return fn ()|=| -> i32 { return x }
    // ok: x is copied into the closure, no lifetime dependency
}

Per-variable capture

Mix capture modes per variable. Unqualified names use transfer semantics, &-prefixed names capture by address:

var a = Buffer()    // COPY
var b = 0

var closure = fn ()|a, &b| {
    a.data.push(1)   // closure's own copy of a
    b += 1            // modifies the original b through a pointer
}

See Closures for the full capture syntax.


Destruction Order

Values are destroyed at the end of their enclosing scope in reverse declaration order. This applies to stack-allocated, heap-allocated, and smart-pointer-promoted values alike:

fn example() {
    var a = Resource("first")
    var b = Resource("second")
    var c = Resource("third")
}
// destruction order: c, b, a

For smart pointers promoted by AMT:

  • std::Unique<*T>: the object destructor runs at the end of the owning binding’s lexical scope, in reverse declaration order.
  • std::Shared<*T>: each binding decrements the reference count at its own scope boundary in reverse declaration order; the object destructor and deallocation run once, when the last Shared reference’s scope ends. The decrement order is lexical and deterministic; the object destructor fires at the final owner, which is not necessarily the last-declared binding in any single scope. See AMT Destruction Timing.
  • std::Weak<*T>: invalidated at scope exit. Does not affect the reference count.

There is no drop-at-last-use optimization. Destruction is tied to lexical scope, so side effects in destructors (file close, lock release, flush) are predictable from reading the source.


Moved-From State

After a value is moved, the source binding is invalidated. Any use of a moved-from binding is a compile error there is no “valid but unspecified” state like C++:

var a = UniqueFile()
var b = a              // move

a.handle               // compile error: a has been moved
a = UniqueFile()       // ok: a can be reassigned to a new value
a.handle               // ok: a is live again

A moved-from binding can be reassigned. After reassignment, it is live again with the new value. But between the move and the reassignment, any access is a hard error.

This is enforced by AMT in both debug and release builds. There is no runtime check the compiler statically tracks which bindings are live and which have been moved.


Summary

Type category    Assignment    Source after    Function param
COPY             Copy          Live            Copy (caller keeps)
MOVE             Move          Invalidated     Move (caller loses)
NON_TRANSFER     Error         N/A             Error
Struct           memcpy        Live            memcpy (caller keeps)
// COPY: both sides live after transfer
var a = Copyable()
var b = a              // copy
a.method()             // ok
b.method()             // ok

// MOVE: source invalidated after transfer
var x = Moveable()
var y = x              // move
// x.method()          // compile error
y.method()             // ok

// Pointer aliasing: allowed, AMT checks provenance
var val = 42
var p = &val
var q = &val
*p = 100
std::println(*q)       // 100

// Closure capture by transfer
var m = Moveable()
var f = fn ()|=| { m.use() }
// m is moved into f

// Closure capture by address
var n = 0
var g = fn ()|&| { n += 1 }
g()
// n is 1

AMT

https://www.kairolang.org/docs/language/amt/

AMT (Automatic Memory Tracking)

AMT is a compile-time analysis pass that tracks pointer lifetimes, determines ownership, and automatically promotes pointers to the appropriate smart pointer type in debug builds. It provides memory safety without requiring lifetime annotations from the programmer.

AMT operates on safe pointers (*T) only. Raw pointers (unsafe *T) are not tracked, and stack allocations are never promoted AMT only promotes heap-allocated pointers created through std::create<T>() or equivalent allocator calls. Invalid use of stack pointers (dangling, escaping scope) is always a hard compile error in both debug and release builds.

Warning

AMT is not yet implemented. The compiler is currently in the Stage 1 parsing phase. AMT development begins at Stage 2. The roadmap:

  • Stage 0 (stable): C++ compiler, transpiles Kairo to C++.
  • Stage 1 (current): self-hosted compiler frontend parser, AST, diagnostics.
  • Stage 1.5: Stage 1 migrated to Kairo’s standard library, fully self-hosting.
  • Stage 2: compiler rewritten using Kairo’s extended feature set. AMT work begins here.

This page describes the intended design. The semantics are the target, not the current state. Details may change during implementation.


The Analysis Model

AMT performs whole-program analysis. Every translation unit is analyzed and its results are cached in a .amt file. The final link pass consumes all .amt files to resolve cross-TU pointer flows.

The analysis tracks three properties for every safe pointer:

  • Provenance: where the pointer was created and what allocation it points into.
  • Lifetime: the scope in which the pointer is live and the scope in which its target is valid.
  • Aliasing: whether multiple live pointers refer to the same allocation, and how they relate (exclusive, shared, back-reference).

AMT does not require annotations. It infers all three properties from usage. When the analysis can prove a pointer’s lifetime, provenance, and aliasing unambiguously, it proceeds silently. When it cannot, the behavior depends on the build mode.

Analysis scope and the limits of inference

Within a single function, AMT tracks pointer flow directly. Across function and TU boundaries, it relies on the .amt summaries: each function’s summary records what it does to every pointer parameter (dereferences it, stores it, aliases it, returns it, frees it). When a pointer is passed into a function, AMT consumes that function’s summary rather than re-walking its body.

This is what makes cross-TU iterator-invalidation and escape detection possible: the summary for Vec::push records that it may reallocate the backing buffer, so any live *T into that buffer is flagged at the call site. Where AMT has no summary a pointer stored into an opaque structure it cannot trace, or passed through a code path it cannot analyze it does not guess. It emits a hard error (see When AMT cannot decide).


Debug vs Release

AMT behaves differently in debug and release builds. The difference is deliberate: debug builds are for getting things working, release builds are for getting things right.

Debug mode

When AMT determines that a heap pointer outlives its original scope or needs ownership semantics, it automatically promotes the pointer to the appropriate smart pointer type (std::Unique<*T>, std::Shared<*T>, or std::Weak<*T>) and emits a warning that describes:

  • Where the promotion happened and why.
  • What smart pointer type was chosen.
  • What the programmer should annotate to make the code release-ready.
warning[AMT]: pointer 'cfg' promoted to std::Unique<*Config>
  --> src/server.k:12:16
  12 |     var cfg = std::create<Config>(8080)
     |         ^^^ escapes function scope via return on line 15
  note: AMT promoted this pointer because it has a single owner and no aliases
  help: annotate explicitly for release builds:
     12 |     var cfg: std::Unique<*Config> = std::create<Config>(8080)

Debug mode also inserts runtime safety checks that are not present in release builds:

  • Null dereference checks on safe pointer access.
  • Use-after-free detection via poisoned memory patterns.
  • Double-free detection.

These checks have runtime cost and exist only to catch bugs during development.

Release mode

AMT does not auto-promote in release builds. If a pointer requires promotion, AMT emits a hard compile error with the same diagnostic information that debug mode would have produced as a warning the location, the reason, the suggested smart pointer type, and the fix:

error [AMT]: pointer 'cfg' escapes its scope and requires ownership annotation
  --> src/server.k:12:16
  12 |     var cfg = std::create<Config>(8080)
     |         ^^^ escapes function scope via return on line 15
  note: in debug mode, AMT would promote to: std::Unique<*Config>
  help: annotate the type explicitly:
     12 |     var cfg: std::Unique<*Config> = std::create<Config>(8080)
  help: or restructure to avoid the escape

This is the “no hidden allocations” guarantee. In a release build, every smart pointer in the binary is visible in the source code. The compiler does not silently change pointer types behind the programmer’s back.

Summary

DebugRelease
Auto-promotionYes, with warningNo hard error
Runtime null checksYesNo
Use-after-free detectionYesNo
Double-free detectionYesNo
Stack escapeHard errorHard error
Unprovable safetyHard errorHard error

Promotion Decision

When AMT determines that a heap pointer needs ownership semantics, it selects one of three smart pointer types based on the pointer’s usage pattern across the entire program.

std::Unique<*T> single owner

The pointer has exactly one owner at any point in its lifetime. No other live pointer refers to the same allocation. Ownership may transfer between scopes (via return or assignment), but at every point in the program, exactly one binding holds the pointer.

fn make_config() -> *Config {
    var cfg = std::create<Config>(8080)
    return cfg
    // AMT: cfg has one owner, transferred to caller -> Unique
}

std::Shared<*T> multiple owners

Multiple live pointers refer to the same allocation and AMT cannot prove single ownership. The allocation is reference-counted and freed when the last Shared pointer is destroyed.

fn share_config(a: *Server, b: *Server) {
    var cfg = std::create<Config>(8080)
    a.config = cfg
    b.config = cfg
    // AMT: cfg is aliased across a and b -> Shared
}

std::Weak<*T> back-reference

A pointer that, if promoted to Shared, would form a reference cycle (A -> B -> A). Weak pointers do not contribute to the reference count and do not prevent deallocation. Accessing a Weak pointer requires checking whether the target is still alive.

class Node {
    var child: *Node
    var parent: *Node
    // AMT: parent points back to the owner of this node -> Weak
}

No promotion needed

Most pointers do not need promotion. A pointer that is created, used, and destroyed within a single scope or whose lifetime is provably contained within its referent’s lifetime remains a plain *T. No smart pointer overhead is added:

fn process() {
    var data = std::create<Buffer>(1024)
    fill(data)
    consume(data)
    std::destroy(data)
    // AMT: data's lifetime is fully contained, no aliases -> plain *T
}

When AMT cannot decide

If AMT cannot prove provenance, cannot determine aliasing, or cannot trace the pointer’s flow (e.g., the pointer is stored in an opaque data structure or passed through a code path AMT cannot analyze), it does not guess. It emits a hard compile error in both debug and release modes:

error [AMT]: cannot determine ownership of pointer 'p'
  --> src/engine.k:42:12
  42 |     opaque_container.store(p)
     |                           ^ pointer escapes into unanalyzable context
  help: use 'unsafe *T' if this pointer is managed externally
  help: use 'forget!(p)' in an unsafe block to drop AMT tracking

The distinction: AMT heuristically promotes when it has strong evidence (one owner -> Unique, multiple owners -> Shared, cycle -> Weak). AMT hard errors when it has insufficient evidence to make any determination. The heuristic is a best-effort assist in debug mode. The hard error is a safety guarantee in both modes.


Stack Pointers

AMT does not promote stack-allocated values. Stack memory is managed by scope variables are destroyed at the closing brace in reverse declaration order. Promotion does not apply because there is no heap allocation to transfer ownership of.

What AMT does with stack pointers:

  • Tracks lifetime: ensures no pointer to a stack variable outlives the variable.
  • Detects escapes: a pointer to a stack local returned from a function is always a hard error.
  • Detects dangling: a pointer used after the referent’s scope has ended is always a hard error.
fn bad() -> *i32 {
    var x = 42
    return &x
    // hard error: x is stack-allocated and destroyed at end of bad()
    // AMT will not promote &x stack escapes are never promotable
}

fn also_bad() {
    var p: *i32
    {
        var x = 42
        p = &x
    }
    *p = 10
    // hard error: x is destroyed at the closing brace, p is dangling
}

These are hard errors in both debug and release. No warning, no promotion, no workaround except restructuring the code or using unsafe *T with manual lifetime management.

There is no heap allocation behind a stack value, so no smart pointer can rescue an escaping stack pointer. This is why return &local is a hard error rather than a promotion candidate the rule the Ownership page refers to as “stack escape.”


Allocator Interaction

AMT is aware of the allocator system. Kairo supports two kinds of allocators:

Global allocator

The default allocator used by std::create<T>() and all standard library heap operations. It can be overridden with @mem::set_allocator(MyAllocator) at the top of a file.

Intentional friction: @mem::set_allocator must appear at the top of every file in the dependency graph that the override affects. If a library sets a global allocator, every file that transitively depends on that library must also carry the annotation. This makes global allocator overrides visible and discourages libraries from changing the global allocator library authors should use scoped allocators instead.

Scoped allocator

A scoped allocator is an allocator with RAII semantics it frees all memory it allocated when it goes out of scope. Set one with @mem::set_scoped_allocator(MyAllocator) on a function or block.

@mem::set_scoped_allocator(ArenaAllocator)
fn process_frame() {
    var a = std::create<Mesh>(vertices)
    var b = std::create<Texture>(pixels)
    // a and b are allocated through ArenaAllocator
    // ArenaAllocator frees everything when process_frame returns
}

AMT tracks scoped allocator boundaries. A pointer allocated through a scoped allocator that escapes the allocator’s scope is a hard error the allocator will free the memory when the scope ends, so the escaped pointer would dangle:

@mem::set_scoped_allocator(ArenaAllocator)
fn bad() -> *Config {
    var cfg = std::create<Config>(8080)
    return cfg
    // hard error: cfg was allocated by ArenaAllocator, which frees at end of bad()
    // returning cfg creates a dangling pointer no promotion can fix this
}

This is not a promotable situation. No smart pointer can rescue a pointer whose backing memory is freed by the allocator. The only fix is restructuring: allocate through the global allocator, or move the scoped allocator boundary outward.

Freestanding allocator

A variant of the scoped allocator interface for embedded and bare-metal targets. Freestanding allocators conform through static functions only no pointer indirection, no vtable. The compiler replaces std::create<T>() calls with direct static calls:

// Instead of: cur_allocator->alloc(...)
// Compiler emits: FreestandingAlloc::alloc(sizeof(T) * N) + placement construction

AMT treats freestanding allocators identically to scoped allocators for lifetime tracking purposes. The optimization is purely a codegen concern.


Destruction Timing

When AMT destroys a value, where it places the destruction depends on whether the type’s destructor is observable.

Trivial destructors

A type whose destructor has no observable side effects (no user-defined op delete, no member with one. Just memory to reclaim) is destroyed at last use. AMT places the std::destroy as early as the final use permits:

var foo = std::create<SomeClass>()   // SomeClass has a trivial destructor
use(foo)
// AMT inserts std::destroy(foo) here foo is never used again
std::println(10)

This is safe because there is nothing observable to reorder. The only effect is reclaiming the backing memory, and reclaiming it after the last use is indistinguishable from reclaiming it at the scope brace.

Non-trivial destructors

A type with a side-effecting destructor (a user-defined op delete, or any member with one. File close, lock release, flush, network disconnect) is destroyed at the end of the owning binding’s lexical scope, in reverse declaration order. AMT does not move these to last use:

fn example() {
    var cfg = std::create<Config>(8080)   // Config has a side-effecting destructor
    use(cfg)
    std::println(10)
}
// cfg's destructor runs here, at the closing brace. Not after use(cfg)

This is the “predictable from source” guarantee: for any type whose destruction is observable, the side effect fires at the scope brace exactly as written. finally blocks, lock guards, and NON_TRANSFER scope guards all rely on this. Their effects are tied to lexical scope, not to whenever the optimizer last touched the value.

Unique and stack-bound objects

For std::Unique<*T> and non-promoted values, the object destructor runs at the owning binding’s closing brace, in reverse declaration order relative to other bindings in the same scope.

Shared objects

std::Shared<*T> separates two events:

  • Refcount decrement happens at each Shared binding’s own scope boundary, in reverse declaration order like any other binding.
  • Object destruction (the pointed-to object’s destructor, then deallocation) happens exactly once, when the last live Shared reference’s scope ends and the count reaches zero.

These are not the same guarantee. The per-binding decrement order is lexical and deterministic. The object destructor fires at the last owner, which may not be the last-declared binding in any single scope. For shared objects, reason about destruction timing as “when the final owner goes away,” not “in reverse declaration order of the binding I’m looking at.”

std::Weak<*T> scope exit does not affect the reference count. The Weak pointer simply becomes invalid if the target has already been freed.


Copy Elision

For COPY types, AMT may emit a move instead of a copy when it proves the source is not used after the transfer. AMT does not elide if the move constructor is deleted, and the programmer does not opt into or out of this.

Elision is permitted only when it is unobservable. The source binding would be destroyed at its own scope exit either way; elision means its destructor runs at the transfer point instead. AMT performs the elision only when the destructor’s observable effects do not depend on timing that is, when the type’s destructor is trivial, or its effects (a free, a refcount decrement) produce the same observable program behavior whether they fire at the transfer or at scope exit.

If the destructor has timing-sensitive side effects a file flush, a lock release, a log line AMT does not elide. The copy is preserved and the source is destroyed at its lexical scope exit as written. This keeps the language’s core promise intact: destructor side effects are predictable from reading the source. Elision is a silent optimization precisely because it is only applied where it cannot be observed.


C/C++ Interop

AMT’s behavior at the FFI boundary depends on the C++ declaration’s type signature.

Smart pointer parameters

C++ functions that use smart pointer types are mapped automatically:

C++ typeKairo AMT typeRequires unsafe
std::unique_ptr<T>std::Unique<*T>No
std::shared_ptr<T>std::Shared<*T>No
std::weak_ptr<T>std::Weak<*T>No
T&, const T&*T, *const TNo
T&& (move ref)*T (ownership transfer)No

A mismatch between AMT’s promotion and the C++ function’s expected type is a compile error.

Raw pointer parameters

C++ functions that take raw pointers (T*, void*) require an unsafe block. AMT does not track the pointer across the FFI boundary the C++ side is opaque. Use forget!() to release the pointer from tracking before handing it to C++. See Unsafe for the full mechanics of forget!() and FFI ownership transfer.

Shallow C++ analysis

For raw pointer FFI calls, AMT performs a one-level heuristic analysis of the C++ function body (if the source is visible via the imported header). If the function only dereferences the pointer without aliasing, storing, or forwarding it, AMT may classify the call as safe and not require an unsafe block. This analysis does not recurse AMT will not walk the C++ call graph beyond the immediate function.

If the C++ function’s body is not visible (forward-declared, in a compiled library), AMT treats all raw pointer parameters as opaque and requires unsafe.


Generic Code

AMT tracks through generic instantiations. When a generic function is monomorphized, AMT analyzes the concrete instantiation with full type information:

fn <T> take_ownership(x: *T) {
    // AMT knows x is the sole pointer to the allocation after this call
}

fn caller() {
    var cfg = std::create<Config>(8080)
    take_ownership(cfg)
    // AMT: cfg's ownership transferred into take_ownership
    // Using cfg after this point is a compile error
}

AMT understands lifecycle categories. If T has a @move transfer constructor, AMT tracks ownership transfer through the move. If T is @copy, AMT knows both the source and destination are live after the copy.

Generic code is not an opaque boundary AMT analyzes each monomorphization as if it were a concrete function.


What AMT Does Not Do

  • Garbage collection: AMT is a compile-time analysis. There is no runtime garbage collector, no tracing, no mark-and-sweep. Reference counting for Shared pointers is the only runtime cost, and it only exists when multiple ownership is required.

  • Runtime borrow checking: AMT does not insert runtime aliasing checks. All aliasing analysis is done at compile time. If AMT cannot prove aliasing safety statically, it is a compile error.

  • Lifetime annotations: AMT does not require 'a-style lifetime parameters. The whole-program analysis infers lifetimes from usage. There is no lifetime polymorphism AMT analyzes concrete lifetimes, not abstract ones.

  • Automatic reference counting everywhere: AMT promotes to Shared (which uses refcounting) only when it determines multiple ownership exists. Single-owner pointers are promoted to Unique, which has no refcount overhead. Pointers with fully contained lifetimes are not promoted at all.

  • Cross-language analysis: AMT does not analyze C++ code beyond the one-level heuristic described in C/C++ Interop. C++ code is treated as opaque beyond the immediate function signature and body.


.amt Files

Each translation unit produces a .amt file alongside its object file. The .amt file contains a summary of every pointer’s provenance, lifetime bounds, and promotion decisions for that TU. The link-time pass reads all .amt files to resolve cross-TU flows.

.amt files are cached and invalidated when the source or any of its dependencies change. They do not need to be committed to version control the build system regenerates them.

Important

The .amt file format and the details of the link-time resolution pass are still being finalized. The information above describes the intended architecture. Specifics may change before 1.0.


Summary

TypeTracked?Promoted?Error on escape?
Plain *T (stack)YesNeverAlways (hard error)
Plain *T (heap)YesDebug: yes | Release: noRelease: hard error (must annotate)
unsafe *TNoNeverNo (programmer’s problem)
Scoped alloc ptrYesNeverAlways (hard error)
FFI smart ptrYesMapped 1:1Type mismatch = error
FFI raw ptrNoNeverRequires unsafe block
// Stack pointer AMT enforces lifetime, no promotion
var x = 42
var p: *i32 = &x

// Heap pointer AMT tracks and may promote (debug) or require annotation (release)
var cfg = std::create<Config>(8080)

// Explicit annotation works in both debug and release
var cfg: std::Unique<*Config> = std::create<Config>(8080)

// Scoped allocator AMT enforces no escape
@mem::set_scoped_allocator(ArenaAllocator)
fn frame() {
    var mesh = std::create<Mesh>(data)
    // mesh cannot outlive frame() hard error if it tries
}

Unsafe

https://www.kairolang.org/docs/language/unsafe/

Unsafe

The unsafe keyword appears in three distinct contexts in Kairo, each serving a different purpose:

ContextMeaning
unsafe { ... }Block that suspends AMT tracking
unsafe *TRaw pointer type with no compiler tracking
fn foo() unsafe -> TSeparate function overload namespace

These are independent mechanisms that share a keyword. An unsafe block does not make all pointers raw, and a raw pointer does not require an unsafe block to use.


Unsafe Blocks

An unsafe { ... } block suspends AMT for all operations within its scope. Inside an unsafe block:

  • AMT does not track pointer lifetimes or provenance
  • AMT does not insert automatic destructors or smart pointer promotions
  • forget!() is available to permanently drop pointers from AMT tracking
  • unsafe &x can create raw pointers from safe bindings
var x = std::create<i32>(42)   // AMT-tracked safe pointer

unsafe {
    forget!(x)                  // drop x from AMT tracking
    c_function(x as unsafe *i32)  // pass to C++ which will free it
}

// x is no longer tracked AMT will not auto-free it

When unsafe blocks are required

An unsafe block is required when calling a C/C++ function that takes or returns raw pointers:

ffi "c++" import "native.hh";

fn main() {
    var data = std::create<i32>(100)

    unsafe {
        forget!(data)
        native_take_ownership(data as unsafe *i32)
    }
}

When unsafe blocks are NOT required

Calling C/C++ functions that use safe parameter types does not require an unsafe block:

C++ parameter typeRequires unsafe block
Value types (int, float, structs by value)No
References (T&, const T&)No
Move references (T&&)No
std::unique_ptr<T>No
std::shared_ptr<T>No
std::weak_ptr<T>No
Raw pointers (T*, void*)Yes

The FFI layer reads C++ declaration signatures and maps C++ smart pointers to Kairo’s AMT-tracked equivalents automatically. std::unique_ptr<T> maps to std::Unique<*T>, std::shared_ptr<T> maps to std::Shared<*T>, std::weak_ptr<T> maps to std::Weak<*T>. A mismatch between AMT’s promotion and the C++ function’s expected smart pointer type is a compile error.

See C/C++ Interop for the full FFI model.


Forget

forget!() is a compiler intrinsic that permanently removes a pointer from AMT tracking. It is only valid inside an unsafe block:

var ptr = std::create<Config>(8080)

unsafe {
    forget!(ptr)   // AMT stops tracking ptr
    // ptr is now the caller's responsibility
}

// AMT will not auto-free ptr if nothing else frees it, this is a memory leak

The primary use case is transferring ownership to C++ code that will manage the pointer’s lifetime:

ffi "c++" import "engine.hh";

fn init_engine() {
    var cfg = std::create<EngineConfig>(defaults())

    unsafe {
        forget!(cfg)
        engine_init(cfg as unsafe *EngineConfig)
        // C++ engine now owns the allocation and will free it on shutdown
    }
}
Caution

forget!() does not free memory it tells AMT to stop tracking the pointer. If the pointer is not freed by other means (C++ code, manual std::free(), etc.), the memory leaks. Use forget!() only when transferring ownership across the FFI boundary.


Raw Pointers (unsafe *T)

unsafe *T declares a pointer with no AMT tracking, no null checks, and no bounds checks. It is the Kairo equivalent of a raw C/C++ pointer:

var raw: unsafe *i32 = unsafe &some_value
*raw = 42   // no null check UB if null

Raw pointers do not require an unsafe block to dereference or use. The unsafe is part of the type itself by declaring the pointer as unsafe *T, the programmer has already opted out of safety for that pointer.

var buf: unsafe *u8 = unsafe std::alloc<u8>(1024)
buf[0] = 0xFF      // no bounds check
buf[100] = 0x00    // no bounds check UB if out of allocation
unsafe std::free(buf)

See Pointers for the full pointer model.


Unsafe Function Overloads

The unsafe modifier on a function creates a separate overload in its own namespace. The caller explicitly selects the unsafe variant with the unsafe keyword:

fn sort(data: [i32]) -> [i32] {
    // safe: bounds-checked, stable sort
    return stable_sort(data)
}

fn sort(data: [i32]) unsafe -> [i32] {
    // unsafe: unstable sort, may reorder equal elements
    return quick_sort(data)
}

var a = sort(my_data)           // calls the safe version
var b = unsafe sort(my_data)    // calls the unsafe version

What unsafe means on a function

unsafe on a function does not mean “unsafe memory.” AMT still guarantees memory safety in both safe and unsafe overloads. The unsafe qualifier signals that the function may not uphold semantic invariants that the safe version does stability, ordering, precision, idempotency, or any other contract beyond memory safety.

The caller writing unsafe sort(...) is explicitly acknowledging: “I know this version has weaker guarantees and I accept the trade-off.”

Overload resolution

Safe and unsafe overloads live in separate namespaces. They can have identical parameter types because the dispatch is determined by the presence or absence of the unsafe keyword at the call site:

fn process(x: i32) -> i32 { ... }        // safe
fn process(x: i32) unsafe -> i32 { ... }  // unsafe different namespace

process(42)          // calls safe version
unsafe process(42)   // calls unsafe version

Modifier restrictions

unsafe cannot be combined with other function modifiers:

CombinationValid
unsafe + constYes
unsafe + evalNo
unsafe + asyncYes
unsafe + panicYes
unsafe + inlineYes
unsafe + volatileYes

unsafe stands alone as an overload qualifier. See Functions for the full modifier compatibility table.


The Safety Boundary

Kairo’s safety model has a clear boundary:

Inside normal code: AMT tracks all pointer lifetimes, inserts null checks on safe pointer dereference, auto-promotes to smart pointers, and emits compile errors when safety cannot be guaranteed. Memory safety is the compiler’s responsibility.

Inside unsafe { } blocks: AMT is suspended. The programmer is responsible for pointer lifetimes, null safety, and deallocation. The compiler trusts the programmer.

With unsafe *T pointers: No tracking regardless of whether the code is inside an unsafe block. The pointer is permanently untracked by its type.

With unsafe function overloads: Memory safety is still guaranteed by AMT. Only semantic invariants beyond memory safety are relaxed.

                    Memory safe?    AMT tracked?    Who manages lifetime?
Normal code         Yes             Yes             Compiler (AMT)
unsafe { }          Programmer      No              Programmer
unsafe *T           Programmer      No              Programmer
fn foo() unsafe     Yes             Yes             Compiler (AMT)

Common Patterns

FFI ownership transfer

ffi "c++" import "lib.hh";

fn send_to_native(data: Config) {
    var ptr = std::create<Config>(data)

    unsafe {
        forget!(ptr)
        native_take_ownership(ptr as unsafe *i32)
    }
}

Custom allocator

fn allocate_aligned(size: usize, align: usize) -> unsafe *void {
    var raw: unsafe *void = unsafe std::aligned_alloc(align, size)
    return raw
}

Interfacing with hardware registers

fn write_register(addr: usize, value: u32) {
    var reg = addr as unsafe *u32
    *reg = value
}

Unsafe overload for performance

fn bounds_check(arr: [i32], index: i32) -> i32 {
    assert index >= 0 && index < arr.len(), "out of bounds"
    return arr[index]
}

fn bounds_check(arr: [i32], index: i32) unsafe -> i32 {
    // caller guarantees index is valid skip the check
    return arr[index]
}

Summary

// Unsafe block suspends AMT
var ptr = std::create<i32>(42)
unsafe {
    forget!(ptr)
    c_function(ptr as unsafe *i32)
}

// Raw pointer no tracking by type
var raw: unsafe *i32 = unsafe &some_value
*raw = 100   // no null check

// Unsafe overload separate dispatch namespace
fn compute(x: f64) -> f64 { /* precise */ }
fn compute(x: f64) unsafe -> f64 { /* fast approximation */ }

var precise = compute(3.14)
var fast = unsafe compute(3.14)

// forget!() drop pointer from AMT
var data = std::create<Buffer>(1024)
unsafe {
    forget!(data)
    // data is no longer tracked manual management required
}

Panic

https://www.kairolang.org/docs/language/panic/

Panic

panic is Kairo’s error signaling mechanism. A function marked with the panic specifier can produce an error instead of its declared return type. Callers must handle the error via try/catch or propagate it by marking themselves panic. The compiler statically verifies that all error types are accounted for unhandled error types are a compile error.

Unlike C++ exceptions, panics use no unwinding tables and no runtime. The codegen is zero-cost every panic site becomes a tagged return value checked with a branch.


The panic Specifier

Add panic after the parameter list to indicate a function may produce an error:

fn parse_port(input: string) panic -> i32 {
    if input.len() == 0 {
        panic std::Error::Runtime("empty input")
    }

    var port = std::parse<i32>(input)

    if port < 0 || port > 65535 {
        panic std::Error::Runtime("port out of range")
    }

    return port
}

The panic keyword inside the function body raises an error. The error value can be any type there is no base error class requirement.


Handling Panics with try/catch

Callers handle panics with try/catch. The compiler tracks every error type that can propagate from the try body and verifies that all types are handled:

fn load_config(path: string) -> Config {
    var port: i32

    try {
        port = parse_port(read_file(path))
    } catch e: std::Error::Runtime {
        std::println(f"bad config: {e}")
        port = 8080
    } catch e: std::Error::IO {
        std::println(f"cannot read file: {e}")
        port = 8080
    }

    return Config(port)
}

Exhaustiveness

The compiler enforces that every error type reachable from the try body is handled. If any type is missing, it is a compile error unless the function is itself marked panic:

fn partial_handler() panic -> i32 {
    // This function only handles Runtime errors.
    // IO errors propagate to the caller.
    try {
        return parse_and_validate()
    } catch e: std::Error::Runtime {
        return -1
    }
    // std::Error::IO is not caught propagates because this function is marked panic
}

A bare catch (no type) acts as a catch-all and satisfies exhaustiveness for all remaining types:

fn safe_handler() -> i32 {
    try {
        return parse_and_validate()
    } catch {
        // handles any error type
        return -1
    }
}

Named and unnamed catch

The error value can be bound to a variable for inspection, or the catch block can omit the binding:

try {
    risky_operation()
} catch e: std::Error::IO {
    // e is bound can inspect the error
    log_error(e)
} catch {
    // no binding catch-all, error value is discarded
}

Propagation

If a function calls a panic function without fully handling all error types, it must be marked panic itself. The unhandled errors propagate to the caller:

fn read_config() panic -> Config {
    var content = read_file("config.txt")   // may panic with IO error
    var port = parse_port(content)           // may panic with Runtime error
    return Config(port)
    // Both IO and Runtime errors propagate caller must handle them
}

Calling a panic function without try/catch and without the panic specifier is a compile error:

fn bad() -> i32 {
    return parse_port("8080")   // compile error: parse_port may panic, but bad() is not marked panic
}

Multiple Error Types

A function does not declare which error types it can produce the compiler infers this from the function body. A single function can panic with any number of different error types:

fn process(path: string) panic -> Data {
    if !file_exists(path) {
        panic std::Error::IO("file not found")
    }

    var content = read_file(path)

    if content.len() == 0 {
        panic std::Error::Runtime("empty file")
    }

    if !validate(content) {
        panic std::Error::Validation("invalid format")
    }

    return parse_data(content)
}

Callers of process must handle std::Error::IO, std::Error::Runtime, and std::Error::Validation (plus any errors from read_file and parse_data), or propagate them.


try/catch as an Expression

try/catch can produce a value. Each branch must return the same type:

var port = try {
    parse_port(input)
} catch e: std::Error::Runtime {
    8080
} catch {
    3000
}

finally is not permitted in expression form. See Control Flow for expression-form rules.


finally

finally defines cleanup code that runs regardless of whether the try body succeeds or panics:

try {
    acquire_lock()
    do_work()
} catch e: std::Error::Runtime {
    handle_error(e)
} finally {
    release_lock()   // always runs
}

Standalone finally (scope exit)

finally can appear without a preceding try. In this form it runs when the enclosing function exits, regardless of how normal return, panic, or early return:

fn process_file(path: string) panic {
    var fd = open(path)
    finally {
        close(fd)
    }

    var data = read(fd)
    if !validate(data) {
        return   // finally still runs
    }

    transform(data)
    // finally runs here too on normal exit
}

Multiple finally blocks in the same function execute in reverse declaration order (LIFO).

See Control Flow for full finally semantics.


Panic and No-Return (!)

A function with return type ! can never return normally it always panics, loops forever, or calls another no-return function. The panic specifier and ! cannot coexist because panic implies an alternative return path (the error), while ! guarantees no return at all:

fn fatal(msg: string) panic -> ! {
    // compile error: panic and ! are contradictory
}

fn fatal(msg: string) -> ! {
    loop { }   // ok: never returns
}

See Functions and Type System for ! semantics.


Codegen

Panics compile to zero-cost tagged return values. There are no unwinding tables, no runtime exception handler, and no stack unwinding. A function marked panic returns a tagged union containing either the success value or an error with source location metadata.

At the call site, try/catch compiles to a branch on the tag. If the tag indicates an error, the catch block executes. If it indicates success, the value is extracted and execution continues.

This means:

  • No runtime overhead on the success path beyond a single branch (which the branch predictor handles efficiently)
  • No stack unwinding errors propagate via normal return values
  • No unwinding tables in the binary smaller executables
  • All functions in Kairo are trivially noexcept at the ABI level
Note

The panic statement inside a function body (panic SomeError(...)) does not halt the program. It produces the error as the function’s return value. The term “panic” refers to the signaling mechanism, not to a crash. The program only terminates if an error reaches main() or the top level and is re-panicked with panic e in that context.


Error Types

Kairo does not prescribe a specific error hierarchy. Any type can be used as a panic value. The standard library provides common error types under std::Error:

panic std::Error::Runtime("message")
panic std::Error::IO("message")
panic std::Error::Validation("message")
Important

The std::Error hierarchy is still being finalized. Detailed documentation for standard error types will be added in a future update.

User-defined error types work the same way:

class ParseError {
    pub var message: string
    pub var position: i32

    fn ParseError(self, msg: string, pos: i32) {
        self.message = msg
        self.position = pos
    }
}

fn parse(input: string) panic -> Ast {
    if input.len() == 0 {
        panic ParseError("unexpected end of input", 0)
    }
    // ...
}

try {
    parse("")
} catch e: ParseError {
    std::println(f"parse error at {e.position}: {e.message}")
}

Summary

// Function that may panic
fn divide(a: i32, b: i32) panic -> i32 {
    if b == 0 {
        panic std::Error::Runtime("division by zero")
    }
    return a / b
}

// Full handling no panic propagation
fn safe_divide(a: i32, b: i32) -> i32 {
    try {
        return divide(a, b)
    } catch e: std::Error::Runtime {
        std::println(f"error: {e}")
        return 0
    }
}

// Partial handling propagates unhandled types
fn partial(a: i32, b: i32) panic -> i32 {
    try {
        return complex_operation(a, b)
    } catch e: std::Error::Runtime {
        return -1
    }
    // other error types propagate
}

// Expression form
var result = try { divide(10, 0) } catch { 0 }

// Scope exit
fn with_cleanup() panic {
    var resource = acquire()
    finally { release(resource) }
    do_work(resource)
}

Compile-Time Eval

https://www.kairolang.org/docs/language/eval/

Compile-Time Eval

The eval keyword forces compile-time evaluation. An eval variable, function, or control flow construct must be fully resolvable at compile time if it depends on runtime values, the compiler emits an error. There is no “maybe compile-time, maybe runtime” mode. eval means compile-time, always.


Eval Variables

eval declares a binding whose value is computed at compile time. The result is baked into the binary as a constant:

eval PI = 3.14159265358979
eval MAX_BUFFER = 1024 * 1024
eval HEADER_SIZE = sizeof u32 + sizeof u16 + sizeof u8

eval bindings are implicitly const they cannot be reassigned. The initializer must be a compile-time evaluable expression.

Type annotation

Type annotations are optional. The compiler infers the type from the initializer:

eval N = 10           // i32
eval NAME = "Kairo"   // string
eval N: u64 = 10      // explicitly u64

Usage as generic arguments

eval variables can be used wherever a compile-time constant is required, including array sizes and generic arguments:

eval BUFFER_SIZE = 256

var buffer: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE]

eval TABLE_WIDTH = 16
eval TABLE_HEIGHT = 16
var grid: [[f64; TABLE_WIDTH]; TABLE_HEIGHT]

Eval Functions

An eval function must be fully evaluable at compile time. The compiler executes it during compilation and replaces the call site with the result:

eval fn factorial(n: i32) -> i32 {
    if n <= 1 { return 1 }
    return n * factorial(n - 1)
}

eval FACT_10 = factorial(10)   // computed at compile time: 3628800

Calling other eval functions

Eval functions can call other eval functions:

eval fn square(x: i32) -> i32 = x * x

eval fn sum_of_squares(n: i32) -> i32 {
    var total = 0
    for i in 1..=n {
        total += square(i)
    }
    return total
}

eval RESULT = sum_of_squares(10)   // 385

Recursion

Eval functions can be recursive. The compiler evaluates the recursion at compile time:

eval fn fib(n: i32) -> i32 {
    if n <= 1 { return n }
    return fib(n - 1) + fib(n - 2)
}

eval FIB_20 = fib(20)   // 6765
Warning

Deeply recursive eval functions can significantly increase compile times. The compiler may impose a recursion depth limit to prevent unbounded compilation.


Restrictions

Eval bodies must be deterministic and free of side effects. The following are not permitted inside eval functions or eval variable initializers:

Not allowedReason
Heap allocation (std::create, [T] growth)No runtime allocator at compile time
IO operations (file, network, console)Side effects
Pointer operations (*T, unsafe *T)No addressable memory at compile time
async / await / spawnNo runtime scheduler at compile time
panicNo runtime error handler at compile time
Mutable global / static variablesSide effects across evaluations
Calls to non-eval functionsCannot guarantee compile-time evaluation

What is allowed

AllowedExamples
Arithmetic and logic+, -, *, /, %, &&, ||, !
Comparisons==, !=, <, >, <=, >=
Control flowif/else, for, while, loop, match
Local variablesvar, const within the eval body
Calling other eval functionseval fn calls
sizeof, alignofType size queries
typeof (type position)Compile-time type resolution
Struct/enum construction (trivial)Literal aggregate initialization
String literals and operationsCompile-time string manipulation
Fixed-size arrays ([T; N])Stack-like allocation in the evaluator

Eval If

eval if selects a branch at compile time. The condition must be a compile-time constant. Only the selected branch is compiled the others are discarded entirely (no codegen, no type checking):

eval if platform == "linux" {
    fn init_platform() { /* linux-specific */ }
} else if platform == "windows" {
    fn init_platform() { /* windows-specific */ }
} else {
    fn init_platform() { /* fallback */ }
}

eval if can appear at the top level (selecting between declarations) or inside function bodies (selecting between code paths):

fn <T> process(x: T) -> T {
    eval if sizeof(T) <= 8 {
        return fast_path(x)
    } else {
        return slow_path(x)
    }
}

Type-based branching

eval if combined with typeof enables type-specialized code paths in generic functions:

fn <T> serialize(value: T) -> [byte] {
    eval if typeof T == i32 {
        return int_to_bytes(value)
    } else if typeof T == string {
        return value.to_bytes()
    } else {
        return generic_serialize(value)
    }
}

The unselected branches are not type-checked, so they can contain code that would be invalid for the current T. This is the mechanism for writing type-specialized generic code without separate overloads.

See Control Flow for eval if in the context of control flow.


Eval For

eval for unrolls a loop at compile time when all loop bounds and operations are compile-time evaluable:

eval fn build_lookup_table() -> [i32; 16] {
    var table: [i32; 16]
    eval for i in 0..16 {
        table[i] = i * i
    }
    return table
}

eval SQUARES = build_lookup_table()

If the loop body or bounds depend on runtime values, the compiler emits an error.

Note

eval for fully computes the loop at compile time and embeds the result. For large iteration counts, this increases binary size (the unrolled result is stored as data). Use regular for loops for runtime iteration.


Eval and Types

eval works with any type that can be constructed and manipulated at compile time:

Primitives

All integer, float, bool, char, and string types are eval-compatible:

eval X = 42
eval PI = 3.14159
eval FLAG = true
eval INITIAL = 'K'
eval NAME = "Kairo"

Fixed-size arrays

eval PRIMES = [2, 3, 5, 7, 11, 13, 17, 19]
eval IDENTITY: [f64; 4] = [1.0, 0.0, 0.0, 1.0]

Structs (trivially constructible)

struct Point {
    var x: f64
    var y: f64
}

eval ORIGIN = Point { x: 0.0, y: 0.0 }
eval UNIT_X = Point { x: 1.0, y: 0.0 }

Enums (plain)

enum Mode { Debug, Release, Test }
eval BUILD_MODE = Mode::Release

Types that are NOT eval-compatible

Classes with constructors, types with destructors, heap-allocated types ([T], {K: V}, {T}), and any type involving pointers cannot be used in eval context.


Eval vs Const

evalconst
Evaluation timeCompile time onlyRuntime (at initialization)
InitializerMust be compile-time evaluableAny expression
ReassignmentNoNo
Can use in array sizesYesNo
Can use as generic argumentYesNo
Heap allocation in initializerNoYes
IO in initializerNoYes

const is an immutable binding. eval is a compile-time computed value. Use const for values that are fixed after initialization but may depend on runtime computation. Use eval for values that must be known at compile time.

const config = load_config()          // runtime: reads a file
eval MAX_CONNECTIONS = 1024           // compile time: baked into binary

Eval and Where Clauses

eval expressions are valid in where clauses. When a where clause contains only eval-compatible expressions, it is checked at compile time:

fn <T> stack_alloc() -> T
  where sizeof(T) <= 4096 {
    // guaranteed at compile time: T fits on the stack
}

See Where Clauses for the full constraint system.


Summary

// Eval variable
eval MAX_SIZE = 1024 * 1024
eval TABLE_SIZE = 256

// Eval function
eval fn power(base: i32, exp: i32) -> i32 {
    if exp == 0 { return 1 }
    return base * power(base, exp - 1)
}

eval TWO_TO_16 = power(2, 16)   // 65536

// Eval as array size
var buffer: [u8; MAX_SIZE]

// Eval if (platform selection)
eval if platform == "linux" {
    eval CACHE_LINE = 64
} else {
    eval CACHE_LINE = 128
}

// Eval if (type specialization)
fn <T> zero() -> T {
    eval if typeof T == i32 { return 0 }
    else if typeof T == f64 { return 0.0 }
    else if typeof T == string { return "" }
    else if typeof T == bool { return false }
}

// Eval for (compile-time loop)
eval fn sum_range(n: i32) -> i32 {
    var total = 0
    eval for i in 1..=n {
        total += i
    }
    return total
}

eval SUM_100 = sum_range(100)   // 5050

Modules

https://www.kairolang.org/docs/language/modules/

Modules

Kairo’s module system maps source files and directories to namespaces. Each .k file is a module. A directory containing a .k file with the same name as the directory is a library its entry file re-exports contents from sibling files in the directory.


File-to-Module Mapping

Each .k file is a module named after the file (without the extension). A directory with an entry file forms a library:

project/
  main.k
  math/
    math.k        <- library entry for "math"
    vector.k
    matrix.k
    internal/
      helpers.k
// main.k


The library entry file (math/math.k) controls what the library exports. Files in the directory are accessible via libraryname::filename.


Import Syntax

Imports bring names from other modules into the current scope:

// Import an entire module/library


// Import a specific item


// Import multiple items


// Import everything from a module (wildcard)


// Rename on import



// Source file only (disallow library resolution)

import module

import module restricts the import to source files only it will not resolve library entry files. Use this when you need a specific file and want to avoid ambiguity with a library of the same name.


Visibility on Imports

Imports can have visibility modifiers. A priv import brings names into the current file but does not re-export them to files that import the current module:

// network.k
pub import std::collections     // re-exported to anyone importing network
priv import std::internal       // only available in this file
prot import std::platform       // available to files in the same directory/library

By default, imports are public anything you import is visible to modules that import you. Use priv import for implementation details that should not leak.


Top-Level Declarations

All top-level declarations (functions, classes, structs, enums, variables) can have visibility modifiers:

pub fn public_api() { ... }
priv fn internal_helper() { ... }
prot fn library_internal() { ... }

pub class Server { ... }
priv class ConnectionPool { ... }

pub eval MAX_CONNECTIONS = 1024
priv static var request_count: i32 = 0

The default visibility for top-level declarations is pub. Use priv to restrict access to the current file and prot to restrict access to the current file and modules that are in the same directory or library.


Modules (Namespaces)

The module keyword creates a namespace within a file. It is equivalent to C++‘s namespace:

module serialization {
    pub fn to_json(data: Config) -> string { ... }
    pub fn from_json(input: string) panic -> Config { ... }

    priv fn escape_string(s: string) -> string { ... }
}

serialization::to_json(my_config)

Anonymous modules

A module without a name creates a scope for grouping declarations without introducing a named namespace:

module {
    // declarations here are scoped but not namespaced
    var internal_state: i32 = 0
}

Module visibility

Modules themselves can have visibility modifiers:

pub module api {
    // accessible from anywhere
    fn handle_request(req: Request) -> Response { ... }
}

priv module cache {
    // only accessible within this file
    var store: {string: Data} = {}
    fn lookup(key: string) -> Data? { ... }
}

prot module platform {
    // accessible within this file and sibling modules in the same library
    fn detect_os() -> string { ... }
}

The default module visibility is pub.


Module Extending

A module can be extended across multiple files. The second file imports the module and reopens it to add more declarations:

// encoding.k
pub module codec {
    pub fn encode_base64(data: [byte]) -> string { ... }
    pub fn decode_base64(input: string) -> [byte] { ... }
}
// compression.k


pub module codec {
    // visibility must match the original declaration
    pub fn compress(data: [byte]) -> [byte] { ... }
    pub fn decompress(data: [byte]) -> [byte] { ... }
}
// main.k


compression::codec::encode_base64(data)   // defined in encoding.k
compression::codec::compress(data)         // defined in compression.k

Reopening a module with a different visibility than the original is a compile error.


Scope Resolution (::)

The :: operator resolves names across all scoping contexts:

ContextExample
Module accessstd::println(...)
Class static membersCounter::count
Enum variantsDirection::North
Nested typesPacket::Header
Base class method callsBase::method(self)
Library submodulesmath::vector::cross_product(...)

:: works uniformly across all contexts. There is no separate syntax for module access vs type member access.


The Standard Library (std)

std is not automatically imported. The user must import it explicitly:



std::println("hello")
std::create<i32>(42)

Individual items can be imported directly:



println("hello")
Note

The core language primitives (i32, string, bool, etc.) and built-in syntax (if, for, match, etc.) are available without any import. Only standard library functions and types (std::println, std::Error, std::Shared, etc.) require an import.


C/C++ Header Imports

C and C++ headers are imported via the ffi keyword. The header is parsed by the compiler and all exported declarations become available:

ffi "c++" import "graphics.hh"
ffi "c" import "legacy_api.h"

By default, all declarations from the header are dumped into the current namespace (matching C++ #include behavior). Use as to namespace the import:

ffi "c++" import "graphics.hh" as gfx

gfx::create_window(800, 600)
gfx::RenderContext()

C++ std namespace collision

If a C++ header defines names in the std namespace, those names collide with Kairo’s std module. The C++ standard library is accessible through the libcxx module:





std::println("Kairo's println")
cout << "C++ cout" << endl

Inside inline "c++" blocks, std:: resolves to Kairo’s standard library (the block emits inside namespace kairo). Use libcxx:: imports for C++ standard library types.

See C/C++ Interop for the full FFI model.


Circular Imports

Circular imports are a compile error. If module A imports module B and module B imports module A, the compiler rejects the cycle:

// a.k


// b.k

Break circular dependencies by extracting shared declarations into a third module that both A and B import.


Library Entry Files

A directory with a .k file matching the directory name acts as a library. The entry file controls exports:

// network/network.k (library entry)


priv import dns  // network/dns.k not re-exported

// Anything defined or publicly imported here is accessible via "import network"
pub fn connect(host: string, port: i32) -> Connection { ... }
// main.k


network::connect("localhost", 8080)   // from network.k
network::tcp::listen(8080)            // from network/tcp.k
// network::dns::resolve("...")       // compile error: dns is priv imported

Summary

// File imports






// Visibility on imports
pub import std::collections
priv import std::internal

// Modules (namespaces)
module serialization {
    pub fn to_json(data: Config) -> string { ... }
}

// Module extending

pub module codec {
    pub fn compress(data: [byte]) -> [byte] { ... }
}

// C++ header import with namespace
ffi "c++" import "engine.hh" as engine
engine::initialize()

// Standard library

std::println("hello")

// Scope resolution (uniform)
std::println(...)          // module
Counter::count             // static member
Direction::North           // enum variant
Packet::Header { ... }     // nested type

Extends

https://www.kairolang.org/docs/language/extends/

Extends

extend blocks add methods, operators, and static functions to types declared elsewhere. They are the primary way to attach behavior to structs and enums, which cannot contain methods in their body. Classes can also be extended.


Basic Syntax

struct Point {
    var x: f64
    var y: f64
}

extend Point {
    fn length(self) const -> f64 {
        return std::sqrt(self.x * self.x + self.y * self.y)
    }

    fn op +(self, other: Point) -> Point {
        return Point { x: self.x + other.x, y: self.y + other.y }
    }

    static fn origin() -> Point {
        return Point { x: 0.0, y: 0.0 }
    }
}

var p = Point { x: 3.0, y: 4.0 }
p.length()       // 5.0
Point::origin()  // Point { x: 0.0, y: 0.0 }

Methods added via extend are called the same way as methods defined in a class body there is no syntactic distinction at the call site.


What Can Be Extended

TypeSupports extend
StructsYes the only way to add methods
EnumsYes the only way to add methods
ClassesYes
UnionsNo
InterfacesNo

What Extends Can Add

The rules differ by type:

Structs

AllowedNot allowed
MethodsConstructors
Static functionsDestructors (fn op delete)
Arithmetic / comparison operatorsCopy / move assignment (fn op =)
fn op as (type conversion)
fn op in (iteration)

Structs are trivially copyable. Extending lifecycle operations (destructors, copy/move) would break that guarantee. See Structures.

Enums

AllowedNot allowed
MethodsConstructors
Static functionsDestructors
Comparison / arithmetic operatorsCopy / move assignment
fn op as (type conversion)

Classes

AllowedNot allowed
MethodsConstructors
Static functionsDestructors
All operatorsCopy / move assignment

Classes already support methods and operators in their body. extend on a class is useful for separating interface conformance or adding functionality in a different section of the codebase (within the same file).

Note

Constructors, destructors, and copy/move assignment cannot be added via extend on any type. If you need construction logic on a struct, extend a static factory function instead. If you need a destructor, use a class.


Interface Conformance

extend ... impl declares that a type satisfies an interface and provides the required methods:

interface Drawable {
    fn draw(self) const -> string
}

extend Point impl Drawable {
    fn draw(self) const -> string {
        return f"({self.x}, {self.y})"
    }
}

The compiler verifies at the extend declaration that all interface requirements are satisfied. Missing methods are a compile error.

A type can conform to multiple interfaces through separate extend blocks:

extend Point impl Drawable {
    fn draw(self) const -> string { ... }
}

extend Point impl Serializable {
    fn serialize(self) const -> [byte] { ... }
    fn byte_size(self) const -> i32 { ... }
}

See Interfaces for interface declarations and structural conformance.


Generic Extends

When extending a generic type, redeclare the type parameters:

struct <T> Pair {
    var first: T
    var second: T
}

extend <T> Pair<T> {
    fn swap(self) -> Pair<T> {
        return Pair<T> { first: self.second, second: self.first }
    }
}

The type parameters in the extend block must match the original declaration. Constraints can be added via impl, derives, or where clauses:

extend <T impl Comparable> Pair<T> {
    fn max(self) const -> T {
        return if self.first > self.second { self.first } else { self.second }
    }
}

This max method is only available on Pair<T> when T satisfies Comparable. Calling Pair<SomeNonComparable>.max() is a compile error.

Generic interface conformance

interface <T> Container {
    fn size(self) const -> i32
    fn get(self, index: i32) const -> T
}

extend <T> Pair<T> impl Container<T> {
    fn size(self) const -> i32 { return 2 }

    fn get(self, index: i32) const -> T {
        return if index == 0 { self.first } else { self.second }
    }
}

See Bounds for the full constraint system.


Visibility

Extended methods can have pub, prot, or priv visibility:

extend Point {
    pub fn distance(self, other: Point) const -> f64 {
        return (self - other).length()
    }

    priv fn validate(self) const -> bool {
        return self.x >= 0.0 && self.y >= 0.0
    }
}

The default visibility for extended methods matches the type’s convention pub for struct and enum extensions, pub for class method extensions.


Self in Extend Blocks

Self is available in extend blocks and resolves to the extended type:

extend <T> Pair<T> {
    fn duplicate(self) const -> Self {
        // Self resolves to Pair<T>
        return Pair<T> { first: self.first, second: self.second }
    }
}

Same-File Restriction

A plain extend block must be in the same file as the type definition:

// point.k
struct Point { var x: f64; var y: f64 }

extend Point {
    fn length(self) const -> f64 { ... }   // ok: same file as Point
}
// other.k


// extend Point { ... }   // compile error: Point is defined in a different file

For extend ... impl blocks (interface conformance), the rule is looser: either the type or the interface must be defined in the same file as the extend block:

// drawable.k


interface Drawable {
    fn draw(self) const -> string
}

// This is legal even though Point is defined in point.k,
// because Drawable is defined here:
extend Point impl Drawable {
    fn draw(self) const -> string {
        return f"({self.x}, {self.y})"
    }
}

This prevents conflicts between extensions in different files while still allowing interface conformance to be declared where the interface is defined.


Multiple Extend Blocks

A type can have multiple extend blocks in the same file. Each block can target different interfaces or group related functionality:

struct Color {
    var r: u8
    var g: u8
    var b: u8
}

// Arithmetic operations
extend Color {
    fn op +(self, other: Color) -> Color {
        return Color {
            r: (self.r as i32 + other.r as i32).min(255) as u8,
            g: (self.g as i32 + other.g as i32).min(255) as u8,
            b: (self.b as i32 + other.b as i32).min(255) as u8,
        }
    }
}

// Serialization
extend Color impl Serializable {
    fn serialize(self) const -> [byte] { ... }
    fn byte_size(self) const -> i32 { return 3 }
}

// Display
extend Color impl Drawable {
    fn draw(self) const -> string {
        return f"rgb({self.r}, {self.g}, {self.b})"
    }
}

Extend vs Class Methods

For classes, there is no semantic difference between a method in the class body and a method in an extend block both produce the same compiled output. The choice is organizational:

class Server {
    var port: i32

    fn Server(self, port: i32) { self.port = port }

    // Core functionality in the class body
    fn start(self) { ... }
    fn stop(self) { ... }
}

// Interface conformance separated
extend Server impl Loggable {
    fn to_log_string(self) const -> string {
        return f"Server(:{self.port})"
    }
}

Summary

// Extend a struct with methods
struct Vec2 { var x: f64; var y: f64 }

extend Vec2 {
    fn length(self) const -> f64 = std::sqrt(self.x * self.x + self.y * self.y)
    fn op +(self, other: Vec2) -> Vec2 = Vec2 { x: self.x + other.x, y: self.y + other.y }
    static fn zero() -> Vec2 = Vec2 { x: 0.0, y: 0.0 }
}

// Extend an enum with methods
extend Direction {
    fn opposite(self) -> Direction {
        match self {
            case .North { return .South }
            case .South { return .North }
            case .East  { return .West }
            case .West  { return .East }
        }
    }
}

// Interface conformance
extend Vec2 impl Drawable {
    fn draw(self) const -> string = f"({self.x}, {self.y})"
}

// Generic extend with constraints
extend <T impl Comparable> Pair<T> {
    fn max(self) const -> T = if self.first > self.second { self.first } else { self.second }
}

Attributes

https://www.kairolang.org/docs/language/attributes/

Attributes

Attributes are compile-time AST transformations. They modify the structure of declarations renaming fields, injecting code, adding members with full access to the parsed syntax tree and type information. Unlike macros, which operate on raw tokens before parsing, attributes run after parsing and can inspect and modify typed AST nodes.

PropertyGuarantee
Type safetyYes
HygieneYes
ScopeYes
Expansion timeCompile time
Expansion orderInner to outer
Expansion contextThe node the attribute is attached to

Defining Attributes

Attributes are defined with the macro keyword followed by @name and a parameter list. The first parameter is always a pointer to the AST node being transformed:



macro @add_logging(node: *AST::FunctionDecl) {
    node->body.prepend(
        AST::parse!(std::println(f"entering {stringify!(node->name)}"))
    )
    node->body.append(
        AST::parse!(std::println(f"exiting {stringify!(node->name)}"))
    )
}
@add_logging
fn process_data(x: i32) -> i32 {
    return x * 2
}

After expansion:

fn process_data(x: i32) -> i32 {
    std::println("entering process_data")
    var __result = x * 2
    std::println("exiting process_data")
    return __result
}

Node preservation rule

Attributes must preserve the node type. An attribute attached to a function declaration receives a *AST::FunctionDecl and must leave it as a function declaration it cannot replace it with a class or a variable. This ensures the expansion process is deterministic and the AST remains structurally consistent.

To add new nodes, create them with std::create<AST::NodeType>() and attach them to the existing node (e.g., appending statements to a function body, adding members to a class).


Attribute Arguments

Attributes can take additional arguments beyond the implicit node parameter:

macro @repeat(block: *AST::Block, times: i32) {
    var original = block->clone()
    var expanded = std::create<AST::Block>()

    for i in 0..times {
        expanded->body.append(original.clone())
    }

    *block = *expanded
}

@repeat(3)
{
    std::println("hello")
}

After expansion:

{
    { std::println("hello") }
    { std::println("hello") }
    { std::println("hello") }
}

Arguments are passed in parentheses after the attribute name at the use site. The node parameter is implicit it is always the declaration or block the attribute is attached to.


Overloading

Attribute definitions can be overloaded by node type or argument types. The compiler selects the correct overload based on what the attribute is attached to:

macro @serialize(node: *AST::ClassDecl) {
    // generate serialization for a class
}

macro @serialize(node: *AST::StructDecl) {
    // generate serialization for a struct
}
@serialize
class Config { ... }    // calls the ClassDecl overload

@serialize
struct Point { ... }    // calls the StructDecl overload

Expansion Order

When multiple attributes are stacked on a single declaration, they expand inner to outer the attribute closest to the declaration runs first:

@deserialize    // runs second, on the result of @serializable
@serializable   // runs first, on the original class
class Config {
    var host: string
    var port: i32
}

This allows attributes to compose @serializable can add serialization methods, and @deserialize can then inspect those methods to generate the inverse.


Attaching Attributes

Attributes can be attached to any AST node:

// On a function
@inline
fn hot_path(x: i32) -> i32 { ... }

// On a class
@packed
class Header { ... }

// On a struct
@align(16)
struct SimdData { ... }

// On a block
@repeat(3)
{ std::println("repeated") }

// On a variable (if the attribute accepts VariableDecl)
@deprecated("use new_config instead")
var old_config: Config

Built-in Attributes

Kairo provides built-in attributes that are handled directly by the compiler:

Layout attributes

AttributeDescriptionApplies to
@packedRemove padding between membersClasses, structs, unions
@align(N)Set minimum alignment to N bytesClasses, structs, unions

See Classes and Structures for layout details.

Branch hints

AttributeDescriptionApplies to
@likelyCondition is expected to be trueif statements
@unlikelyCondition is expected to be falseif statements
@unreachableBranch should never execute (UB if reached)match/if branches

See Control Flow for branch prediction hints.

Diagnostics

AttributeDescriptionApplies to
@no_warn(CODE)Suppress a specific compiler warningAny declaration
@deprecated(msg)Mark a declaration as deprecatedAny declaration

Other

AttributeDescriptionApplies to
@core::where_handlerCustom handler for where-clause failuresFunctions

See Where Clauses for the where handler system.


The std::AST API

Attribute definitions interact with the AST through the std::AST module. This module provides types representing each kind of AST node (FunctionDecl, ClassDecl, StructDecl, Block, VariableDecl, etc.) and methods for inspecting and modifying them.

Important

The std::AST API is under development. The full set of node types, their fields, and available methods will be documented once the API is finalized. The examples on this page demonstrate the intended usage patterns.

Key operations available on AST nodes:

OperationDescription
node->nameAccess the declaration name
node->bodyAccess the body (for functions, blocks)
node->body.append(stmt)Add a statement to the end
node->body.prepend(stmt)Add a statement to the beginning
node->clone()Deep-copy the node
AST::parse!(code)Parse a code fragment into an AST node
std::create<AST::T>()Create a new AST node of type T

Macros vs Attributes

MacrosAttributes
Operates onRaw tokensAST nodes
Type awarenessNoYes
Expansion timeBefore parsingAfter parsing
Can modify structureToken substitution onlyCan transform the AST
Syntaxname!(args)@name on declarations
Must preserve node typeN/AYes

Use macros for simple substitutions and conditional compilation. Use attributes for structural transformations that need to inspect or modify declarations.

See Macros for the token-level macro system.


Summary

// Define an attribute


macro @timer(node: *AST::FunctionDecl) {
    node->body.prepend(
        AST::parse!(var __start = std::time::now())
    )
    node->body.append(
        AST::parse!(std::println(f"elapsed: {std::time::now() - __start}ms"))
    )
}

// Use an attribute
@timer
fn expensive_computation() {
    // ... work ...
}

// Attribute with arguments
macro @version(node: *AST::ClassDecl, ver: string) {
    // add a static VERSION member to the class
}

@version("2.1.0")
class MyLibrary { ... }

// Stacked attributes (inner to outer)
@json_output
@validate_fields
struct ApiResponse {
    var status: i32
    var body: string
}

// Built-in attributes
@packed
@align(16)
struct CacheLine {
    var data: [u8; 64]
}

@likely if hot_path {
    fast_operation()
}

Macros

https://www.kairolang.org/docs/language/macros/

Macros

Macros in Kairo are token-level substitutions they operate on raw tokens before parsing, similar to C/C++ #define but with scoping and balanced-delimiter requirements. Macros are identified by the ! suffix on their name.

For AST-level transformations with type awareness, see Attributes.


Defining Macros

A macro is defined with the macro keyword, a name ending in !, optional parameters, and a body. The body must have balanced delimiters:

macro double!(x) {
    x + x
}

macro greeting! {
    "hello, world"
}

At every use site, the preprocessor replaces the macro invocation with the body, substituting parameters:

var a = double!(5)     // replaced with: 5 + 5
var b = greeting!      // replaced with: "hello, world"

Parameters

Parameters are typeless they accept any sequence of tokens. Multiple parameters are comma-separated:

macro clamp!(value, lo, hi) {
    if value < lo { lo } else if value > hi { hi } else { value }
}

var x = clamp!(temperature, 0, 100)

Macros as arguments

Macros can be passed to other macros. The inner macro is expanded at the final substitution site:

macro tag! { "debug" }
macro repeat!(m) { m! m! m! }

var labels = repeat!(tag)   // becomes: "debug" "debug" "debug"

Built-in Macros

Kairo provides a set of compiler-intrinsic macros for common tasks.

Token manipulation

MacroDescription
concat!(a, b)Join tokens into a single token
stringify!(a)Convert a token to a string literal
unstringify!(s)Convert a string literal back to tokens
var name = concat!(my, _var)       // becomes: my_var
var s = stringify!(some_ident)     // becomes: "some_ident"
Warning

unstringify! converts a string into raw tokens that are injected into the source. This is a potential injection risk only use with trusted, compile-time-known strings.

Variadic helpers

MacroDescription
count!(...args)Number of arguments
first!(...args)First argument
last!(...args)Last argument
rest!(...args)All arguments except the first
init!(...args)All arguments except the last
count!(a, b, c)     // 3
first!(a, b, c)     // a
last!(a, b, c)      // c
rest!(a, b, c)      // b, c
init!(a, b, c)      // a, b

Source location

MacroDescription
file!Current file path as a string literal
line!Current line number as an integer literal
column!Current column number as an integer literal
module_path!Current module path as a string literal
std::println(f"logged from {file!}:{line!}")

Diagnostics

MacroDescription
error!(msg)Emit a compile error with the given message
warning!(msg)Emit a compile warning
note!(msg)Emit a compile note
eval if platform == "wasm" {
    error!("WebAssembly is not supported yet")
}

An optional error code can be passed as a second argument:

warning!("deprecated API", "W0042")

Code generation

MacroDescription
include!(path)Paste file contents as tokens
include_str!(path)File contents as a string literal
embed!(path)File contents as a byte array ([byte])
unique_id!Generate a unique identifier
todo!(msg)Runtime panic placeholder with optional message
unreachable!(msg)Assert a code path is unreachable
// Embed a shader as a string
eval VERTEX_SHADER = include_str!("shaders/vertex.glsl")

// Embed a binary resource
eval ICON_DATA = embed!("assets/icon.png")

// Mark unfinished code
fn process() -> Config {
    todo!("implement config parsing")
}

todo!() panics at runtime with the given message. unreachable!() is undefined behavior if reached the optimizer assumes the code path is dead.

Conditional

MacroDescription
defined!(name)Check if a macro with the given name exists
eval if defined!(DEBUG_MODE) {
    fn log(msg: string) { std::println(f"[DEBUG] {msg}") }
} else {
    fn log(msg: string) { }
}

Hygiene

MacroDescription
undef!(name)Remove a macro definition
macro TEMP! { 42 }
var x = TEMP!        // 42
undef!(TEMP)
// var y = TEMP!     // compile error: TEMP is not defined

Compiler Intrinsic Macros

Some macros are compiler intrinsics that perform operations beyond token substitution:

MacroDescriptionDetails
label!(name)Declare a jump targetControl Flow
jump!(name)Unconditional jump to a labelControl Flow
forget!(ptr)Drop a pointer from AMT trackingUnsafe
unwrap!(expr)Force-unwrap a nullable, panic on nullVariables
mref!(T)Produce an rvalue reference type (&&T)Classes

These use macro syntax (name!) to make their usage explicit and searchable in a codebase, but they are not user-definable they are built into the compiler.


Scoping

Unlike C/C++ #define, Kairo macros respect scope. A macro defined inside a module or block is only visible within that scope:

module internal {
    macro BUFFER_SIZE! { 4096 }
    var buf: [u8; BUFFER_SIZE!]
}

// BUFFER_SIZE! is not visible here

Top-level macros follow the same visibility rules as other declarations:

pub macro MAX_RETRIES! { 3 }          // visible to importers
priv macro INTERNAL_FLAG! { true }     // file-scoped

Macros vs Attributes

MacrosAttributes
Operates onRaw tokensAST nodes
Type awarenessNoYes
Expansion timeBefore parsingAfter parsing
Can modify structureToken substitution onlyCan transform the AST
Syntaxname!(args)@name on declarations
HygieneScoped, balanced delimitersFull AST hygiene

Use macros for simple substitutions, constants, and conditional compilation. Use attributes for code transformations that need type information or structural awareness.

See Attributes for the AST-level transformation system.


Summary

// Define a macro
macro square!(x) { x * x }
var s = square!(5)   // 25

// No-parameter macro
macro VERSION! { "1.0.0" }
std::println(f"version: {VERSION!}")

// Built-in macros
std::println(f"file: {file!}, line: {line!}")
eval SHADER = include_str!("shader.glsl")
eval ICON = embed!("icon.png")

// Variadic helpers
var n = count!(a, b, c, d)   // 4

// Diagnostics
eval if sizeof(usize) < 8 {
    error!("64-bit platform required")
}

// Conditional compilation
macro DEBUG! { true }
eval if defined!(DEBUG) {
    fn trace(msg: string) { std::println(f"[TRACE] {msg}") }
}

// Scoped macros
module config {
    priv macro DEFAULT_PORT! { 8080 }
    pub eval PORT = DEFAULT_PORT!
}

Concurrency

https://www.kairolang.org/docs/language/concurrency/

Concurrency

Important

This page is under development. The concurrency model is being designed. Full documentation will be added once the design is finalized.

Kairo’s concurrency system provides async/await, coroutines, and thread-level primitives. The following features are planned and referenced across the existing documentation:

Async/Await

The async modifier on functions and the await keyword for waiting on asynchronous results. See Functions for the async modifier.

Coroutines (yield)

Functions with a yield T return type produce values cooperatively. The yield keyword suspends the function and produces a value to the caller. See Functions for yield return types.

spawn

Launching concurrent work. Syntax and runtime model (green threads, OS threads, or event loop) are being finalized.

Atomic Types (atomic T)

Thread-safe wrapper type for lock-free operations. Referenced in Functions.

Thread-Local Storage (thread T)

Per-thread storage modifier. Referenced in Functions.

Synchronization Primitives

Mutexes, channels, and other coordination mechanisms will be documented here once the standard library concurrency API is finalized.

Custom Awaitables

Classes can define fn <T> op await(self, obj: std::forward<T>) -> T to customize the behavior of await when called on an instance. See Operators for the op await overload.

await async_fn()       // syntax sugar for a state machine
spawn some_async_fn()  // syntax sugar for detaching a thread
yield some_value       // syntax sugar for a coroutine, function must have yield on return type

fn get_tokens() -> yield string {
    yield "token1"
    yield "token2"
}

op await is overloadable: fn op await (self) -> T for custom async types.

  • await async_fn() syntax sugar for state machine
  • spawn some_async_fn() syntax sugar for detaching a thread
  • yield some_value coroutine syntax, function must have -> yield T return type
  • fn op await (self) -> T overloadable for custom async types
  • atomic T thread-safe wrapper type
  • thread T thread-local storage type

C & C++ Interoperability

https://www.kairolang.org/docs/language/c-c++/

C & C++ Interoperability

Kairo provides zero-overhead, bidirectional interoperability with C and C++. There is no serialization layer, no binding generator, and no runtime bridge Kairo emits ABI-compatible object code and consumes C/C++ headers directly.

This page covers the full interop surface: calling C/C++ from Kairo, exposing Kairo to C/C++, inline C++ blocks, pointer and reference passing, templates and concepts across the boundary, exception interop, and the underlying ABI contract.


Coverage Matrix

The table below summarizes which C and C++ features Kairo can consume and expose. Rows marked bidirectional work in both directions.

FeatureDirectionNotes
FunctionsBidirectionalIncludes variadic functions
StructsBidirectionalLayout-compatible; see Structs
UnionsBidirectionalSee Unions
EnumsBidirectionalSee Enums
ClassesBidirectionalVtable-compatible; see Classes
TemplatesBidirectionalInstantiation across the boundary; see below
ConceptsBidirectionalKairo’s impl constraints map to C++20 concepts
NamespacesBidirectional
Pointers & ReferencesBidirectionalRequires unsafe on the Kairo side; see below
Operator OverloadingBidirectionalSee Operators
LambdasBidirectional
ExceptionsC++ -> KairoKairo -> C++ is on the roadmap
MacrosC++ -> KairoPreprocessor macros are expanded before Kairo sees them
Preprocessor DirectivesC++ -> Kairo
Inline AssemblyKairo -> C++Via inline "c++" blocks
CoroutinesBidirectional
Named Modules (import std;)Not yetSee Modules note

Calling C/C++ from Kairo

Import a C or C++ header with the ffi directive. The compiler parses the header, extracts declarations, and makes them available as native Kairo symbols no wrapper code required.

// main.k
ffi "c++" import "my_code.hh";

fn main() {
    var obj = MyClass("Kairo")
    std::println(f"name = {obj.get_name()}")
    my_function(42)
}

Given this C++ header:

// my_code.hh
#include <string>
#include <iostream>

class MyClass {
public:
    MyClass(std::string name) : name(name) {}
    std::string get_name() const { return name; }
private:
    std::string name;
};

void my_function(int x) {
    std::cout << "Hello from C++! x = " << x << std::endl;
}

Build and run:

kairo main.k
./main
name = Kairo
Hello from C++! x = 42
Note

ffi "c++" invokes Clang’s frontend internally to parse the header. All exported declarations functions, classes, enums, templates become available in Kairo’s scope with their original names and signatures. No code generation or binding step is visible to the user.


Exposing Kairo to C++

Going the other direction requires the kcc driver, a libclang-based compiler wrapper that makes #include "file.k" work transparently in C++ translation units.

// my_code.k
fn my_kairo_function(x: i32) {
    std::println(f"Hello from Kairo! x = {x}")
}

class MyKairoClass {
    pub var name: string

    fn MyKairoClass(self, name: string) {
        self.name = name
    }

    fn get_name(self) -> string {
        return self.name
    }
}
// main.cpp
#include "my_code.k"
#include <iostream>

int main() {
    MyKairoClass obj("C++");
    std::cout << "name = " << obj.get_name() << std::endl;
    my_kairo_function(42);
    return 0;
}
kcc main.cpp -o main
./main
name = C++
Hello from Kairo! x = 42

How kcc works

kcc is a Clang driver with a single addition: a preprocessor hook that intercepts #include directives. When the included file has a .k extension, kcc:

  1. Invokes the Kairo compiler in-process as a library to produce a C++-compatible header containing forward declarations and wrapper signatures.
  2. Compiles the .k file into an object file.
  3. Links the Kairo object into the final binary at the end of the pipeline.

Auto-linking can be disabled with -fno-kairo-link if you need manual control over the link step.

Manual workflow (without kcc)

If you prefer a standard C++ build process, compile the Kairo source to a static library and a generated header, then link normally:

kairo my_code.k -c -o my_code -xc++ -header my_code.hh
g++ main.cpp my_code.o -o main
Tip

kcc is the simplest path for mixed codebases. The manual workflow is better when Kairo is a dependency consumed by an existing CMake/Meson/Bazel project that manages its own link step.


The ffi Keyword

ffi controls linkage and name mangling. It can be applied to individual declarations or to blocks.

// C++ linkage name mangling, overloading, classes, templates all permitted
ffi "c++" {
    fn compute(x: i32) -> i32 {
        return x + 1;
    }
}

// C linkage no name mangling, same restrictions as extern "C" in C++
ffi "c" fn add(x: i32, y: i32) -> i32 {
    return x + y;
}

ffi "c" follows the same rules as extern "C" in C++: no classes, no overloading, no templates. ffi "c++" follows the same rules as extern "C++": full C++ feature set, Itanium or MSVC mangling depending on the target.


Inline C++

For small amounts of C++ that don’t warrant a separate header, use inline "c++" blocks directly in Kairo source.



fn main() {
    inline "c++" {
        // std:: here refers to Kairo's standard library, not libstdc++
        std::println("Hello from inline C++!");

        // C++ standard library types use the libcxx:: prefix
        cout << "Hello from libcxx!" << endl;
    }
}
Warning

Inside inline "c++" blocks, std:: resolves to the Kairo standard library (the block emits inside namespace kairo). Access the C++ standard library through the libcxx module, importing the specific header you need (e.g., import libcxx::vector;). See Modules for more on imports.


Pointers and References

Kairo’s pointer model distinguishes safe pointers (*T, non-nullable, bounds-tracked) from raw pointers (unsafe *T, no tracking). Passing any pointer or reference across the FFI boundary requires explicit unsafe context because the compiler cannot enforce safety guarantees on the C/C++ side.

Safe variable, unsafe pass

ffi "c++" import "my_code.hh";

fn main() {
    var x = 41

    // compile error: cannot pass reference to C function without unsafe block
    // add_one(&x)

    unsafe {
        add_one(unsafe &x)  // strips bounds checking; caller owns the memory contract
    }

    // for calling c++ with pointers and back, you must use unsafe blocks,
    // short hand syntax is not allowed `unsafe add_one(unsafe &x)` is a compile error

    std::println(f"x = {x}")  // x = 42
}

unsafe & creates a raw pointer from a safe binding. The compiler relinquishes tracking for that pointer the caller is responsible for lifetime and aliasing correctness.

Raw pointer from the start

If the value will be passed to C/C++ repeatedly, allocate it as a raw pointer upfront:

ffi "c++" import "my_code.hh";

fn main() {
    var x: unsafe *i32 = std::create<i32>(41)
    add_one(x)  // already unsafe no block needed
    std::println(f"x = {*x}")  // x = 42
}
Warning

std::create<T> uses Kairo’s global allocator, which is compatible with C++‘s new/delete by default. If you supply a custom allocator, C++ code must not free the resulting pointer with delete doing so is undefined behavior. See AMT for allocator details.

Warning

unsafe *T pointers can be null. Dereferencing a null unsafe *T is undefined behavior the compiler will not insert a null check.


Templates and Concepts

Kairo generics and C++ templates are interchangeable across the boundary. A C++ concept can constrain a Kairo generic parameter, and a Kairo generic type can satisfy a C++ concept.

// my_code.hh
#include <concepts>

template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};
// MyInt.k
ffi "c++" import "my_code.hh";

class <T> MyInt {
    pub var value: T

    fn MyInt(self, value: T) {
        self.value = value
    }

    fn op + (self, other: MyInt) -> MyInt {
        return MyInt(self.value + other.value)
    }
}

fn <T impl Addable> add(a: T, b: T) -> T {
    return a + b
}
// main.cpp
#include <iostream>
#include "my_code.hh"
#include "MyInt.k"

int main() {
    MyInt<int> a(5), b(10);
    MyInt<int> c = add(a, b);
    std::cout << "c.value = " << c.value << std::endl;  // 15

    int x = add(3, 4);
    std::cout << "x = " << x << std::endl;  // 7
}

T impl Addable in Kairo maps directly to Addable T in the generated C++ the constraint is preserved across the boundary, not erased.


Exceptions

Kairo can catch C++ exceptions using its standard try/catch syntax.

ffi "c++" import "my_code.hh";

fn main() {
    try {
        might_throw(true);
    } catch e: std::exception {
        std::println(f"Caught: {e.what()}")
    }
}
Note

Unlike Kairo’s panic system, the compiler cannot statically determine every exception type a C++ function might throw. A catch block that doesn’t handle a thrown type will propagate the exception up the stack. If nothing catches it, the runtime calls std::terminate.

Throwing Kairo exceptions into C++ is not yet supported. Use error codes or return types (e.g., Panickable<T>) for error signaling in the Kairo -> C++ direction.


ABI Compatibility

Kairo emits object code conforming to the platform’s native C++ ABI:

  • Unix-like systems: Itanium C++ ABI
  • Windows: Microsoft C++ ABI

This means Kairo .o/.obj files can be linked with object files from any ABI-compliant C++ compiler (GCC, Clang, MSVC) without shims or translation layers. Name mangling, vtable layout, RTTI, and exception unwinding tables all follow the platform convention.

ffi "c++" declarations use C++ mangling. ffi "c" declarations use C mangling (no decoration). This matches the behavior of extern "C++" and extern "C" in C++.


A Note on C++ Modules

C++20 named modules (import std;, import my_module;) are not currently supported. The interop layer relies on header-based inclusion via Clang’s preprocessor, and module interface deserialization (consuming pre-compiled BMIs) is a future roadmap item.

Exporting Kairo code as a C++ module interface unit (.cppm) is also planned but not yet implemented.

For now: use header-based interop (ffi "c++" + kcc) for all cross-language boundaries.