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); short strings are stored inline without a heap allocation (exact threshold subject to change until the standard library is finalized). Longer strings are heap-allocated.
var greeting = "Hello, Kairo! 📣" // 18 UTF-8 bytes fits in SSO
var name = "Name" // 7 bytes SSO
Because UTF-8 is a variable-width encoding, indexing by codepoint (s[i]) complexity depends on how the string was constructed. For string literals, the compiler pre-populates a breadcrumb cache at codegen time mapping codepoint positions to byte offsets, making indexing O(1) with zero runtime cost. For strings constructed at runtime from a raw pointer, no cache is available and indexing is O(n). Indexing by byte (s.bytes[i]) is always O(1) but returns raw u8 bytes, not characters.
var s = "Hello 📣"
s.bytes[0] // byte: 0x48 ('H') O(1)
s[6] // char: '📣' codepoint indexing, O(1) for literals (compiler cache), O(n) for runtime-constructed strings
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 normal type and as the target of an
unsafe pointer (unsafe *void), using void as a normal type denotes a unit type.
fn log(msg: string) -> void {
// ...
}
var opaque: unsafe *void = get_handle() // raw, untyped pointer
var void_t: MyObj<void> = MyObj<void>() // void is valid here
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 threshold TBD)
// 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
Kairo Primitive Conversion Lattice
Normative specification for implicit and explicit conversions between primitive types.
This document is the single source of truth for -f[no-]implicit-conv, overload resolution
ranking, and binary operator result typing.
1. Conversion classes
Every ordered pair (S, T) of primitive types falls into exactly one class:
| Class | Meaning | as cast | Implicit |
|---|---|---|---|
| I — identity | S and T are the same type | no-op | yes |
| W — implicit | value-preserving for every value of S; zero or near-zero cost; target-independent | permitted | yes |
| E — explicit | representable but may lose value, sign, or precision, or costs a runtime helper | required | no |
| U — unsafe | requires an unsafe context in addition to as | required | no |
| X — forbidden | no cast exists; diagnostic suggests a library function | rejected | no |
I deliberately dropped the “warn” class I floated earlier. A conversion is either sound-by-construction or it is not; a third state means the checker has to carry a severity through overload resolution, and severities do not compose. Lossy-cast detection on known-constant operands belongs in a lint (§8), not in the type relation.
-fno-implicit-conv demotes every W to E. It changes nothing else. §7 proves this
cannot alter which overload is selected.
2. The rule set
W membership is decided by five rules. Everything not matched by a rule is E if a representation-changing cast is meaningful, X otherwise.
R1 — integer widening. uN → uM and iN → iM are W when M > N.
R2 — sign-crossing. uN → iM is W when M > N (value-preserving: the entire
unsigned range fits). iN → uM is never W, for any N, M. Signed-to-unsigned
loses negatives at every width; there is no widening that repairs it.
R3 — integer to float. S → fM is W iff every value of S is exactly representable
in fM, i.e. bits(S) ≤ mantissa(fM) counting the implicit leading bit. This is an
exactness criterion, not a size criterion — i32 → f32 is same-size and lossy, while
i32 → f64 is smaller-to-larger and exact.
| Target | Mantissa bits | Exact integer sources |
|---|---|---|
f16 | 11 | u8, i8 |
f32 | 24 | u8, i8, u16, i16 |
f64 | 53 | u8, i8, u16, i16, u32, i32 |
R4 — float widening. f16 → f32, f16 → f64, f32 → f64 are W. All other
float-to-float pairs are E.
R5 — extended width is opaque. No type wider than 64 bits (u128–u512,
i128–i512, f128–f512) is ever the target of a W conversion, and no
extended-width type is ever the source of one. Rationale: on targets without wide
register support these conversions emit multi-word or software-SIMD sequences. Making
implicitness depend on the target would mean the same source compiles differently per
triple. Uniform E on all targets keeps the cost visible in the source text and keeps the
lattice target-independent.
Everything else — bool, char, byte, usize, isize, string, void, pointers,
aggregates — participates in no W conversion in either direction. Reasons in §4.
3. Core integer table
Rows are source, columns are target. ≤64-bit fixed-width integers only; wider widths
are E by R5.
| S \ T | u8 | u16 | u32 | u64 | i8 | i16 | i32 | i64 |
|---|---|---|---|---|---|---|---|---|
u8 | I | W | W | W | E | W | W | W |
u16 | E | I | W | W | E | E | W | W |
u32 | E | E | I | W | E | E | E | W |
u64 | E | E | E | I | E | E | E | E |
i8 | E | E | E | E | I | W | W | W |
i16 | E | E | E | E | E | I | W | W |
i32 | E | E | E | E | E | E | I | W |
i64 | E | E | E | E | E | E | E | I |
Read the shape: the unsigned block is upper-triangular, the signed block is
upper-triangular, the unsigned→signed quadrant is strictly upper-triangular (one width
step is required, so u32 → i32 is E), and the signed→unsigned quadrant is empty.
3.1 Float and cross-domain
| S \ T | f16 | f32 | f64 | f128+ | any int | bool | char | byte |
|---|---|---|---|---|---|---|---|---|
u8/i8 | W | W | W | E | see §3 | E | E | E |
u16/i16 | E | W | W | E | see §3 | E | E | E |
u32/i32 | E | E | W | E | see §3 | E | E | E |
u64/i64 | E | E | E | E | see §3 | E | E | E |
f16 | I | W | W | E | E | X | X | X |
f32 | E | I | W | E | E | X | X | X |
f64 | E | E | I | E | E | X | X | X |
f128+ | E | E | E | E/I | E | X | X | X |
bool | X | X | X | X | E | I | X | X |
char | X | X | X | X | E | X | I | X |
byte | X | X | X | X | E | X | X | I |
string | X | X | X | X | X | X | X | X |
void | X | X | X | X | X | X | X | X |
Float→int is E in all cases (truncation toward zero; out-of-range is a saturating result, not UB — pick this and state it, C’s UB here is a permanent source of bugs).
4. Non-numeric primitives — rationale
usize / isize. No W conversion to or from any fixed-width type, in either
direction, including usize → u64 on 64-bit targets. This is the one place where a
tempting rule breaks portability outright: if usize → u64 were W on 64-bit, code would
compile on x86_64 and fail on wasm32. usize and isize also do not W-convert to
each other. Literals still work — var i: usize = 0 is literal inference (§5), not
conversion.
byte. No W conversion to or from u8. Your docs restrict byte to bitwise ops;
if byte → u8 were implicit then b + mask succeeds by converting both operands and
the restriction is decorative. E in both directions.
char. char → u32 is bit-identical and technically widening-free, but a codepoint
is not a number. E. u32 → char is E and must range-check (surrogates and > 0x10FFFF
are invalid scalar values) — decide whether that check traps or produces a
char?/result; do not let an invalid char exist.
bool. E in both directions. Your docs already say “no implicit conversion from
integers”; this extends it to the reverse. int → bool is E with != 0 semantics.
Pointers. *T → unsafe *T is U, not W — it is a safety downgrade and AMT loses
provenance at that point, so it should be visible. unsafe *T → *T is U and must be a
checked or asserted construction, never a silent reinterpretation. *T → unsafe *void
is U. Pointer↔integer is U in both directions. No pointer conversion is ever W.
Aggregates. Conversions never recurse into structure. [i32] → [i64],
(i32, i32) → (i64, i64), {string: i32} → {string: i64} are all X. Function pointers
are invariant in both parameter and return position — no variance, no exceptions. If you
want element-wise conversion it is a library map, not a coercion.
5. Literal inference — a separate mechanism
Literals are untyped until a type is assigned. Literal typing is not conversion and does not consult this lattice.
- If an expected type is available from context (annotation, parameter, return position,
the other operand of a binop, aggregate element type), the literal takes that type
directly, and the compiler checks the value fits.
var b: u8 = 42— fine.var b: u8 = 300— error at the literal, with the range in the diagnostic. - With no expected type, the default is
i32for integer literals andf64for float literals. Only if the value does not fit does the “smallest type that holds it” rule in your primitives doc apply.
State rule 1 explicitly in the docs, because your current text implies the promotion rule
always fires. Under that reading, var x = 3000000000 yields i64, and a later
var y: u32 = x errors even though the value fits u32 — the type was chosen before
anyone knew the destination. Expected-type-first eliminates the whole class.
Suffixed literals (42u8) are typed at the literal and then participate in conversion
normally.
6. Binary operators
No separate “usual arithmetic conversions.” Operator operand unification is defined in terms of the W relation, so there is exactly one conversion concept in the language:
Given operands of type A and B, A ≠ B:
- If
W(A → B)and notW(B → A): result operand type isB. - If
W(B → A)and notW(A → B): result operand type isA. - Otherwise: error, requiring an explicit cast on one side.
Consequences worth confirming you want:
| Expression | Result | Why |
|---|---|---|
i32 + i64 | i64 | W one way only |
u32 + i32 | error | neither direction is W (R2 blocks i32→u32; u32→i32 fails the width test) |
u32 + i64 | i64 | R2 |
i32 + f64 | f64 | R3 |
i64 + f64 | error | R3 exactness fails; force the cast |
usize + i32 | error | §4 |
i64 + i128 | error | R5 — extended width never silent |
byte & u8 | error | §4 |
The u32 + i32 error is the single most valuable line in this document. C’s answer is
u32, which silently converts every negative i32 into a huge positive number, and it
has cost the industry more than any other implicit conversion.
Comparison operators use the same unification. Shift operators are the exception: the right operand is unified independently and any integer type is accepted, since the shift amount is not in the value domain of the result.
Compound assignment a op= b requires W(typeof(b) → typeof(a)) or an exact match. It
never converts the left operand.
7. Overload resolution, and why -fno-implicit-conv is a strict subset
Conversion sequences have exactly two ranks:
- Exact — I.
- Converted — W.
There is deliberately no ordering within rank 2. If two candidates are both reachable
by W, the call is ambiguous and errors, even if one conversion is “narrower” in some
intuitive sense. No i32 → i64 beats i32 → f64 tiebreaking. No promotion-vs-conversion
distinction. This is the property that makes the flag safe:
Let
C_onbe the candidate selected with W enabled.-fno-implicit-convdemotes every W to E, which removes candidates from the viable set but never adds one and never reorders the two ranks. An exact-match winner stays the winner. A rank-2 winner becomes non-viable, so the call errors. Therefore for all programs: the flag either preserves the selection or produces a diagnostic. It can never select a different candidate.
Write that as a differential test: compile the whole suite both ways, diff the
--dump-type-info output, and assert every difference is resolved → error and never
resolved(A) → resolved(B). If that assertion ever fires you have introduced a ranking
somewhere, and you want to know the day it happens.
8. Lints (not part of the type relation)
lint::lossy-cast— anascast where the operand is a known constant that does not survive the round trip. Error by default; this is always a bug.lint::redundant-cast— anascast where the pair is I or W.lint::sign-cast— any E cast crossing the signedness boundary. Off by default, on under a strict profile.
None of these participate in overload resolution or operand unification.
9. Invariants — property tests to write now
These are the properties that keep the relation a well-formed partial order. Each is a one-page property test over the full primitive set, and each catches a class of bug that is otherwise found by users.
- Reflexivity.
class(T, T) == Ifor allT. - Antisymmetry.
W(A → B) ∧ W(B → A) ⟹ A == B. A W-cycle means overload resolution can pick either candidate depending on iteration order. - Transitivity.
W(A → B) ∧ W(B → C) ⟹ W(A → C). The rules in §2 are already transitively closed; the test guards against a future rule addition that breaks it. If this ever fails, conversion becomes chain-length-dependent and results stop being stable under refactoring. - Value preservation. For every W pair and a generated corpus of source values (bounds, zero, ±1 around bounds, random), round-tripping through the target and back yields the original. Any failure is a mis-classified cell, not a codegen bug.
- Target independence. The full class matrix is byte-identical across every
supported triple.
usize/isizerows and columns are the ones this is really testing. - Totality. Every ordered pair over the full primitive set has exactly one class.
No
default:fallthrough in the decision function.
10. Implementation shape
Generate everything from one table. A single conversions.def X-macro or TOML listing
(source, target, class) for every pair, and from it emit:
- the
ConversionClass classify(TypeId, TypeId)function used by sema, - the docs table in this file,
- the property-test corpus for §9,
- the
--dump-type-infolegend.
The alternative — a hand-written classify with the docs maintained separately — drifts
within a month, and the drift is invisible because nothing compares them. One source,
three consumers, zero drift.
Order of work: land classify and its property tests against the current checker before
changing any behavior, so you find out which cells the compiler already disagrees with.
That diff is the actual work item list.