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.
| Type | Size | Description | C++ Equivalent |
|---|---|---|---|
u8 | 1 byte | Unsigned 8-bit integer | uint8_t |
u16 | 2 bytes | Unsigned 16-bit integer | uint16_t |
u32 | 4 bytes | Unsigned 32-bit integer | uint32_t |
u64 | 8 bytes | Unsigned 64-bit integer | uint64_t |
u128 | 16 bytes | Unsigned 128-bit integer | __uint128_t |
u256 | 32 bytes | Unsigned 256-bit integer | |
u512 | 64 bytes | Unsigned 512-bit integer | |
i8 | 1 byte | Signed 8-bit integer | int8_t |
i16 | 2 bytes | Signed 16-bit integer | int16_t |
i32 | 4 bytes | Signed 32-bit integer | int32_t |
i64 | 8 bytes | Signed 64-bit integer | int64_t |
i128 | 16 bytes | Signed 128-bit integer | __int128_t |
i256 | 32 bytes | Signed 256-bit integer | |
i512 | 64 bytes | Signed 512-bit integer | |
usize | Platform-dependent | Unsigned, pointer-width integer | size_t |
isize | Platform-dependent | Signed, pointer-width integer | ptrdiff_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.
| Type | Size | Precision | C++ Equivalent |
|---|---|---|---|
f16 | 2 bytes | Half (IEEE 754-2008) | _Float16 |
f32 | 4 bytes | Single | float |
f64 | 8 bytes | Double | double |
f128 | 16 bytes | Quadruple | __float128 |
f256 | 32 bytes | Extended (software) | |
f512 | 64 bytes | Extended (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.
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
| Type | Size | C++ Equivalent |
|---|---|---|
bool | 1 byte | bool |
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
| Type | Size | Description | C++ Equivalent |
|---|---|---|---|
char | 4 bytes | Unicode 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 = '漢'
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
| Type | Size | C++ Equivalent |
|---|---|---|
byte | 1 byte | std::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
| Type | Size | Encoding | C++ Equivalent |
|---|---|---|---|
string | 32 bytes | UTF-8 | std::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
}
The stdlib API for strings is still being finalized. Detailed documentation for string methods will be added in a future update.
Void
| Type | Size | C++ Equivalent |
|---|---|---|
void | 0 bytes | void |
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
| Type | Size | Description |
|---|---|---|
*T | 8 bytes | Safe pointer non-null, compiler-tracked |
unsafe *T | 8 bytes | Raw 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
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:
| Platform | usize / isize |
|---|---|
| 64-bit | 8 bytes |
| 32-bit | 4 bytes |
| 16-bit | 2 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
| Keyword | Mutable | Storage | Initializer | Type annotation |
|---|---|---|---|---|
var | Yes | Automatic | Optional (default-initialized) | Optional (inferred) |
const | No | Automatic | Required | Optional (inferred) |
static | Yes | Static | Optional | Required |
eval | No | Compile-time | Required | Optional (inferred) |
Variables in Kairo are mutable by default.
constbindings at local scope require an initializer, but class-levelconstmembers 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.
| Kind | Convention | Example |
|---|---|---|
| Variables | snake_case | my_value |
| Functions | snake_case | get_name |
| Constants | UPPER_SNAKE_CASE | MAX_SIZE |
| Types (classes, structs, enums) | PascalCase | HttpServer |
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
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
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
| Operator | Description | Example |
|---|---|---|
+ | Addition / unary plus | a + b, +a |
- | Subtraction / unary negation | a - b, -a |
* | Multiplication | a * b |
/ | Division | a / b |
% | Modulo (remainder) | a % b |
^^ | Exponentiation | 2 ^^ 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
| Operator | Description | Example |
|---|---|---|
== | Equality | a == b |
!= | Inequality | a != b |
< | Less than | a < b |
> | Greater than | a > b |
<= | Less than or equal | a <= b |
>= | Greater than or equal | a >= b |
<=> | Three-way comparison (spaceship) | a <=> b |
=== | Deep equality | a === 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
| Operator | Description | Example |
|---|---|---|
&& | Logical AND | a && b |
|| | Logical OR | a || 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
| Operator | Description | Example |
|---|---|---|
& | Bitwise AND | a & b |
| | Bitwise OR | a | b |
^ | Bitwise XOR | a ^ b |
~ | Bitwise NOT (complement) | ~a |
<< | Left shift | a << 2 |
>> | Right shift | a >> 2 |
Right shift is arithmetic (sign-extending) for signed types and logical (zero-filling) for unsigned types.
Assignment
| Operator | Description |
|---|---|
= | Assignment |
+=, -=, *=, /=, %= | Arithmetic compound assignment |
&=, |=, ^= | Bitwise compound assignment |
<<=, >>= | Shift compound assignment |
All compound assignment operators desugar to x = x op y.
Increment and Decrement
| Syntax | Name | Behavior |
|---|---|---|
++x | Prefix increment | Increments x, returns the new value |
x++ | Postfix increment | Returns the current value, then increments x |
--x | Prefix decrement | Decrements x, returns the new value |
x-- | Postfix decrement | Returns the current value, then decrements x |
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
| Operator | Description | Example |
|---|---|---|
.. | 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
}
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?).
| Operator | Description | Example |
|---|---|---|
?. | Null-safe member access | obj?.field |
?-> | Null-safe pointer deref + member access | ptr?->field |
?.* | Null-safe deref member pointer | obj?.*member_ptr |
?->* | Null-safe pointer deref + member pointer deref | ptr?->*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:
| Operator | Description |
|---|---|
. | Member access |
-> | Pointer dereference + member access |
.* | Dereference member pointer |
->* | Pointer dereference + member pointer dereference |
Type Inspection
| Keyword | Return type | Description |
|---|---|---|
sizeof T | usize | Size of type T in bytes |
alignof T | usize | Alignment requirement of type T in bytes |
typeof expr | Context-dependent | Type 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
| Operator | Signature | Description |
|---|---|---|
as | fn op as (self) -> TargetType | Type conversion takes no parameters |
=== | fn op === (self, other: T) -> bool | Deep equality |
in (containment) | fn op in (self, other: T) -> bool | if item in collection checks membership |
in (iteration) | fn op in (self) -> yield T | for x in collection yields elements |
delete | fn 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.
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.
| Precedence | Operators | Associativity | Description |
|---|---|---|---|
| 1 | :: | Left | Scope resolution |
| 2 | () [] . -> .* ->* ?. ?-> ?.* ?->* | Left | Postfix / member access |
| 3 | ++ -- (postfix) | Left | Postfix increment/decrement |
| 4 | ++ -- (prefix) ! ~ + - (unary) * & sizeof alignof typeof | Right | Prefix / unary |
| 5 | ^^ | Right | Exponentiation |
| 6 | * / % | Left | Multiplicative |
| 7 | + - | Left | Additive |
| 8 | << >> | Left | Bitwise shift |
| 9 | <=> | Left | Three-way comparison |
| 10 | < <= > >= | Left | Relational |
| 11 | == != === | Left | Equality |
| 12 | & | Left | Bitwise AND |
| 13 | ^ | Left | Bitwise XOR |
| 14 | | | Left | Bitwise OR |
| 15 | && | Left | Logical AND |
| 16 | || | Left | Logical OR |
| 17 | .. ..= | Left | Range |
| 18 | = += -= *= /= %= &= |= ^= <<= >>= | Right | Assignment |
| 19 | in | Left | Containment / iteration |
| 20 | as | Left | Type cast |
== 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.
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++ Operator | Kairo Alternative |
|---|---|
? : (ternary) | if/else expressions |
, (comma operator) | Not supported use separate statements |
new / delete | std::create<T> / automatic via AMT, or op delete for custom destructors |
typeid | typeof expr returns TypeInfo |
const_cast / reinterpret_cast / static_cast / dynamic_cast | as 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.
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 type | Coverage rule |
|---|---|
| Plain enum | All variants covered, or default present |
| ADT enum | All variants covered, or default present |
| Integers / strings | default always required |
| Booleans | true + false covers it |
| Structs | default 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
| Statement | Behavior |
|---|---|
break | Exits the innermost loop |
break label | Exits the loop identified by label |
continue | Skips to the next iteration of the innermost loop |
continue label | Skips 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:
| Mode | Behavior |
|---|---|
panic | Panics with the provided message or a generated diagnostic |
return | Returns a default-constructed value of the function’s return type |
log (default) | Logs the assertion failure and continues execution |
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.
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()
}
| Attribute | Meaning |
|---|---|
@likely | The condition is expected to be true in the common case |
@unlikely | The condition is expected to be false in the common case |
@unreachable | The 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++ Construct | Kairo Alternative |
|---|---|
? : (ternary) | if/else expressions |
goto | label! / jump! intrinsics |
do { ... } while | loop with a conditional break at the end |
switch fall-through | match but no fall-through |
throw / C++ exceptions | panic / try/catch (see Panic) |
#if / #ifdef | eval if (see Eval) |
constexpr / consteval | eval 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
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 type | Description | Details |
|---|---|---|
yield T | Coroutine yields values of type T cooperatively | Concurrency |
atomic T | Atomic wrapper thread-safe operations | Concurrency |
thread T | Thread-local storage | Concurrency |
fn generate_numbers() -> yield i32 {
for i in 0..10 {
yield i // yield, not return function must have a yield return type
}
}
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
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()
}
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
| Modifier | Free functions | Methods | Description |
|---|---|---|---|
const | Yes | Method does not modify self. See Variables | |
volatile | Yes | Yes | Prevents certain compiler optimizations; for hardware interaction |
unsafe | Yes | Yes | Separate overload namespace for alternative implementations |
eval | Yes | Yes | Must be evaluable at compile time. See Eval |
async | Yes | Yes | Asynchronous execution. See Concurrency |
panic | Yes | Yes | May panic; callers must handle. See Panic |
inline | Yes | Yes | Hint to inline at call sites |
final | Yes | Prevents override in subclasses. See Classes |
Modifier compatibility
Not all modifiers can be combined:
| Combination | Valid | Reason |
|---|---|---|
const + volatile | ok: | |
const + unsafe | unsafe: implies a separate overload with different invariants | |
const + eval | eval: implies compile-time evaluation const self is meaningless | |
const + async | ok: | |
unsafe + any other | ok: | unsafe: creates a separate overload the other modifiers apply to both versions as appropriate |
eval + unsafe | ok: | eval and unsafe are orthogonal modifiers |
eval + any other (except unsafe) | eval implies compile-time evaluation incompatible with runtime modifiers | |
async + const | ok: | |
async + volatile | ok: |
Visibility
| Keyword | Scope |
|---|---|
pub | Accessible from any module |
priv | Accessible only within the defining module (default) |
prot | Accessible 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.
| Modifier | Description |
|---|---|
ffi "c" | C linkage no name mangling. See C/C++ Interop |
ffi "c++" | C++ linkage Itanium or MSVC mangling. See C/C++ Interop |
static | Internal linkage; no vtable dispatch. Cannot be virtual or override |
virtual | Dynamic dispatch via vtable. See Classes |
override | Overrides 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
.krofile. -
Out-of-line class methods declared in the class body, defined outside it using the
Class::methodqualified-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
| Syntax | Behavior |
|---|---|
| (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.
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
}
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:
| Keyword | Scope |
|---|---|
pub | Accessible from any module |
priv | Accessible only within the defining class or module |
prot | Accessible within the defining class, subclasses, and the defining module |
Modifiers are applied per-declaration. There are no visibility blocks (public: sections).
Default visibility
| Member kind | Default |
|---|---|
| Instance / static / const / eval variables | priv |
| Methods, constructors, destructors | pub |
| Operator overloads | pub |
| Static methods | pub |
| Nested types | pub |
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.
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
| Category | Trigger | Semantics |
|---|---|---|
| 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 ctor | Allows move only; copying is a compile error |
| NON_TRANSFER | both @copy and @move are = deleted | Stack-only; cannot be assigned, copied, or moved |
| DEFAULT | no transfer ctor declared | Treated 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:
- The default constructor (
fn Class(self)) - One transfer constructor (
@copyor@move, never both) - 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 form | Generated 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 category | Allowed derived categories |
|---|---|
| COPY | COPY, NON_TRANSFER |
| MOVE | MOVE, NON_TRANSFER |
| NON_TRANSFER | NON_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.
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::advanceis 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:
| Syntax | Purpose |
|---|---|
fn op delete(self) | Destructor |
fn op as(self) -> TargetType | Custom type conversion via as |
fn <T> op await(self, obj: std::forward<T>) -> T | Custom 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
virtualmethod): 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)
Structs vs Classes
Structs and classes are distinct types:
| Class | Struct | |
|---|---|---|
| Member default visibility | priv for variables | pub for all members |
| Methods in body | Yes | No (use extends) |
| Aggregate initialization | No (must use constructor) | Yes (Foo { field: value }) |
| Inheritance | derives | Not supported |
| Virtual dispatch | Yes | No |
| Lifecycle categories | Yes | N/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
| Allowed | Not allowed |
|---|---|
| Methods | Constructors |
| Static functions | Destructors (fn op delete) |
| Arithmetic / comparison operators | Copy / 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
| Struct | Class | |
|---|---|---|
| Member default visibility | pub | priv for variables |
| Methods in body | No (use extends) | Yes |
| Constructors | No (aggregate init only) | Yes |
| Destructors | No | Yes |
| Copy semantics | memcpy (trivial) | Rule of five |
| Inheritance | No | derives |
| Virtual dispatch | No | Yes |
| Aggregate initialization | Yes | No |
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.
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
| Allowed | Not allowed |
|---|---|
| Methods | Constructors |
| Static functions | Destructors |
| Comparison / arithmetic operators | Copy / 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.
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 (
i8—i512,u8—u512,isize,usize) - Floats (
f16—f512) - 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()
}
}
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?toT(useunwrap!(),??, 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:
| Syntax | Type | Description |
|---|---|---|
[T] | Vector | Growable contiguous array (ptr + len + cap) |
[T; N] | Array | Fixed-size, stack-allocated, N must be compile-time |
{K: V} | Map | Hash map |
{T} | Set | Hash 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.
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.
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 T | unwrap!(x) | |
|---|---|---|
| Source is non-null | Returns the value | Returns the value |
| Source is null | Returns T() (default) | Panics |
| Requires default constructor | Yes | No |
Requires panic / try | No | Yes |
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
| Cast | Syntax | Safety | Behavior on failure |
|---|---|---|---|
| Numeric widening | Implicit | Safe | N/A |
| Numeric narrowing | x as i8 | Truncates | Low bits kept |
| Float to int | x as i32 | Truncates/saturates | Saturates on overflow |
| Int to float | x as f64 | May lose precision | Rounded |
| Derived-to-base ptr | Implicit | Safe | N/A |
| Base-to-derived ptr (asserting) | ptr as *Derived | Runtime check | Panics |
| Base-to-derived ptr (checked) | ptr as *Derived? | Runtime check | Returns &null |
| Raw pointer cast | ptr as unsafe *T | No check | Reinterpret |
| Pointer to integer | ptr as usize | Safe | Address value |
| Integer to pointer | n as unsafe *T | Unsafe | Fabricated pointer |
| Plain enum to int | e as u8 | Safe | Discriminant value |
| Int to plain enum | n as Direction | UB if no match | No runtime check |
| ADT enum to int | N/A | Compile error | Use extend method |
| Nullable collapse | x as T | Safe | Default-constructs on null |
| User-defined | x as TargetType | Depends on op as | Calls user code |
T to T? | Implicit | Safe | N/A |
T? to T | x as T | Collapsing cast | Default on null |
Casts Not in Kairo
| C++ Cast | Kairo Equivalent |
|---|---|
static_cast<T>(x) | x as T |
dynamic_cast<T*>(p) | p as *T? (checked) or p as *T (asserting) |
reinterpret_cast<T*>(p) | p as unsafe *T |
const_cast<T*>(p) | Not supported const cannot be stripped at runtime |
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.
| Property | Guarantee |
|---|---|
| Evaluation time | Compile time, always |
| Memory safe | Yes |
| Runtime cost | Zero |
| Valid on functions | Yes |
| Valid on types | Yes |
| Valid on interface methods | Yes |
| Valid on interfaces | Yes (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:
| Expression | Example |
|---|---|
sizeof(T) | requires sizeof(T) <= 64 |
alignof(T) | requires alignof(T) >= 16 |
| Arithmetic and comparisons | requires N > 0 && N <= 1024 |
eval variables and functions | requires is_power_of_two(N) |
| Type trait checks | requires T impl Comparable |
typeof queries | requires 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.
| Property | Guarantee |
|---|---|
| Evaluation time | Runtime |
| Memory safe | Yes |
| Thread safe | Yes |
| Undefined behavior | No |
| Requires unsafe | No |
| Valid on functions | Yes |
| Valid on types | No use requires |
| Valid on interfaces | No 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.
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.
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.
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:
| Type | Description |
|---|---|
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
| Operator | Behavior |
|---|---|
== | 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 null | No | Yes | Yes |
| Null check mechanism | N/A | ?., ??, unwrap!(), val? | ptr != &null (manual) |
| Dereference null | Cannot happen | Compile error (must check first) | Undefined behavior |
| AMT tracked | Yes | Yes | No |
*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
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.
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 lastSharedreference’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.
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
| Debug | Release | |
|---|---|---|
| Auto-promotion | Yes, with warning | No hard error |
| Runtime null checks | Yes | No |
| Use-after-free detection | Yes | No |
| Double-free detection | Yes | No |
| Stack escape | Hard error | Hard error |
| Unprovable safety | Hard error | Hard 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
Sharedbinding’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
Sharedreference’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++ type | Kairo AMT type | Requires 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 T | No |
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
Sharedpointers 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 toUnique, 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.
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
| Type | Tracked? | Promoted? | Error on escape? |
|---|---|---|---|
| Plain *T (stack) | Yes | Never | Always (hard error) |
| Plain *T (heap) | Yes | Debug: yes | Release: no | Release: hard error (must annotate) |
| unsafe *T | No | Never | No (programmer’s problem) |
| Scoped alloc ptr | Yes | Never | Always (hard error) |
| FFI smart ptr | Yes | Mapped 1:1 | Type mismatch = error |
| FFI raw ptr | No | Never | Requires 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:
| Context | Meaning |
|---|---|
unsafe { ... } | Block that suspends AMT tracking |
unsafe *T | Raw pointer type with no compiler tracking |
fn foo() unsafe -> T | Separate 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 trackingunsafe &xcan 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 type | Requires 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
}
}
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:
| Combination | Valid |
|---|---|
unsafe + const | Yes |
unsafe + eval | No |
unsafe + async | Yes |
unsafe + panic | Yes |
unsafe + inline | Yes |
unsafe + volatile | Yes |
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
noexceptat the ABI level
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")
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
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 allowed | Reason |
|---|---|
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 / spawn | No runtime scheduler at compile time |
panic | No runtime error handler at compile time |
| Mutable global / static variables | Side effects across evaluations |
Calls to non-eval functions | Cannot guarantee compile-time evaluation |
What is allowed
| Allowed | Examples |
|---|---|
| Arithmetic and logic | +, -, *, /, %, &&, ||, ! |
| Comparisons | ==, !=, <, >, <=, >= |
| Control flow | if/else, for, while, loop, match |
| Local variables | var, const within the eval body |
Calling other eval functions | eval fn calls |
sizeof, alignof | Type size queries |
typeof (type position) | Compile-time type resolution |
| Struct/enum construction (trivial) | Literal aggregate initialization |
| String literals and operations | Compile-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.
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
eval | const | |
|---|---|---|
| Evaluation time | Compile time only | Runtime (at initialization) |
| Initializer | Must be compile-time evaluable | Any expression |
| Reassignment | No | No |
| Can use in array sizes | Yes | No |
| Can use as generic argument | Yes | No |
| Heap allocation in initializer | No | Yes |
| IO in initializer | No | Yes |
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:
| Context | Example |
|---|---|
| Module access | std::println(...) |
| Class static members | Counter::count |
| Enum variants | Direction::North |
| Nested types | Packet::Header |
| Base class method calls | Base::method(self) |
| Library submodules | math::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")
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
| Type | Supports extend |
|---|---|
| Structs | Yes the only way to add methods |
| Enums | Yes the only way to add methods |
| Classes | Yes |
| Unions | No |
| Interfaces | No |
What Extends Can Add
The rules differ by type:
Structs
| Allowed | Not allowed |
|---|---|
| Methods | Constructors |
| Static functions | Destructors (fn op delete) |
| Arithmetic / comparison operators | Copy / 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
| Allowed | Not allowed |
|---|---|
| Methods | Constructors |
| Static functions | Destructors |
| Comparison / arithmetic operators | Copy / move assignment |
fn op as (type conversion) |
Classes
| Allowed | Not allowed |
|---|---|
| Methods | Constructors |
| Static functions | Destructors |
| All operators | Copy / 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).
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.
| Property | Guarantee |
|---|---|
| Type safety | Yes |
| Hygiene | Yes |
| Scope | Yes |
| Expansion time | Compile time |
| Expansion order | Inner to outer |
| Expansion context | The 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
| Attribute | Description | Applies to |
|---|---|---|
@packed | Remove padding between members | Classes, structs, unions |
@align(N) | Set minimum alignment to N bytes | Classes, structs, unions |
See Classes and Structures for layout details.
Branch hints
| Attribute | Description | Applies to |
|---|---|---|
@likely | Condition is expected to be true | if statements |
@unlikely | Condition is expected to be false | if statements |
@unreachable | Branch should never execute (UB if reached) | match/if branches |
See Control Flow for branch prediction hints.
Diagnostics
| Attribute | Description | Applies to |
|---|---|---|
@no_warn(CODE) | Suppress a specific compiler warning | Any declaration |
@deprecated(msg) | Mark a declaration as deprecated | Any declaration |
Other
| Attribute | Description | Applies to |
|---|---|---|
@core::where_handler | Custom handler for where-clause failures | Functions |
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.
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:
| Operation | Description |
|---|---|
node->name | Access the declaration name |
node->body | Access 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
| Macros | Attributes | |
|---|---|---|
| Operates on | Raw tokens | AST nodes |
| Type awareness | No | Yes |
| Expansion time | Before parsing | After parsing |
| Can modify structure | Token substitution only | Can transform the AST |
| Syntax | name!(args) | @name on declarations |
| Must preserve node type | N/A | Yes |
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
| Macro | Description |
|---|---|
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"
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
| Macro | Description |
|---|---|
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
| Macro | Description |
|---|---|
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
| Macro | Description |
|---|---|
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
| Macro | Description |
|---|---|
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
| Macro | Description |
|---|---|
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
| Macro | Description |
|---|---|
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:
| Macro | Description | Details |
|---|---|---|
label!(name) | Declare a jump target | Control Flow |
jump!(name) | Unconditional jump to a label | Control Flow |
forget!(ptr) | Drop a pointer from AMT tracking | Unsafe |
unwrap!(expr) | Force-unwrap a nullable, panic on null | Variables |
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
| Macros | Attributes | |
|---|---|---|
| Operates on | Raw tokens | AST nodes |
| Type awareness | No | Yes |
| Expansion time | Before parsing | After parsing |
| Can modify structure | Token substitution only | Can transform the AST |
| Syntax | name!(args) | @name on declarations |
| Hygiene | Scoped, balanced delimiters | Full 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
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 machinespawn some_async_fn()syntax sugar for detaching a threadyield some_valuecoroutine syntax, function must have-> yield Treturn typefn op await (self) -> Toverloadable for custom async typesatomic Tthread-safe wrapper typethread Tthread-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.
| Feature | Direction | Notes |
|---|---|---|
| Functions | Bidirectional | Includes variadic functions |
| Structs | Bidirectional | Layout-compatible; see Structs |
| Unions | Bidirectional | See Unions |
| Enums | Bidirectional | See Enums |
| Classes | Bidirectional | Vtable-compatible; see Classes |
| Templates | Bidirectional | Instantiation across the boundary; see below |
| Concepts | Bidirectional | Kairo’s impl constraints map to C++20 concepts |
| Namespaces | Bidirectional | |
| Pointers & References | Bidirectional | Requires unsafe on the Kairo side; see below |
| Operator Overloading | Bidirectional | See Operators |
| Lambdas | Bidirectional | |
| Exceptions | C++ -> Kairo | Kairo -> C++ is on the roadmap |
| Macros | C++ -> Kairo | Preprocessor macros are expanded before Kairo sees them |
| Preprocessor Directives | C++ -> Kairo | |
| Inline Assembly | Kairo -> C++ | Via inline "c++" blocks |
| Coroutines | Bidirectional | |
Named Modules (import std;) | Not yet | See 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
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:
- Invokes the Kairo compiler in-process as a library to produce a C++-compatible header containing forward declarations and wrapper signatures.
- Compiles the
.kfile into an object file. - 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
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;
}
}
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
}
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.
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()}")
}
}
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.