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. An integer literal takes the type expected by its context (annotation, parameter, return position, the other operand) and must fit it. With no expected type the default is i32; only if the value does not fit i32 does the compiler fall back to the smallest signed type that can hold it, up to i512. See Literal inference.

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

i64 and u64 are spelled as the target's int64_t / uint64_t, not hard-coded: long on 64-bit Linux and FreeBSD; long long on Windows, on every 32-bit target, and on Darwin (a 64-bit platform that still spells int64_t as long long). This keeps C++ name mangling and header signatures matching on every target.

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
var h = 64usize     // usize: the target's pointer width
var i = 5f32        // f32: a float suffix on an integer body makes it that float

The integer suffixes are i8 i16 i32 i64 i128, u8 u16 u32 u64 u128, in lower or upper case (42U8), and usize and isize. A suffix is a declaration, not a hint: 42u8 is a u8 wherever it stands, so it selects the u8 overload and a generic called with it deduces T = u8. A value that does not fit the suffix's type is an error, and so is a suffix that names no type (1u7, SC074E). Prefixes and separators combine with a suffix: 0xFF_FFu16, 0b1010u8.

Overflow behavior

Integer arithmetic wraps around: +, -, *, unary -, ~, ++, --, the shifts and the matching compound assignments compute modulo 2N of the operand's type, two's complement, signed or not, in every build. The result has the operand's type, with no promotion: ~x on a u8 is a u8, and a * b on two u16 values never overflows an i32 on the way.

var a: i32 = 2147483647
var b = a + 1           // -2147483648
var p: u16 = 65535
var q = p * p           // 1
var z: u8 = 0
var n = ~z              // 255

A shift count is taken modulo the register width: 32 for types up to 32 bits, else the type's width: with n a runtime 33, 1i32 << n is 2; with n a runtime 9, 1u8 << n is 0, since a narrow type shifts in 32 bits and then truncates. A constant count must be below the width. See Bitwise operators. Division and remainder by zero crash the program (like crash!(), not a panic). MIN / -1 is MIN and MIN % -1 is 0.

Overflow is never undefined behavior, and the checks cost nothing where the plain operator would not overflow: the compiled code is the same as C++'s for those inputs. Code imported from C or C++ keeps its own rules. Kairo code writing an imported value follows Kairo's: ++ on a C struct's uint8_t field, or += on an int& returned by a C++ operator[], wraps. A C bit-field wraps at the field's own width.

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 except f80 follow the IEEE 754 standard. A float literal takes the type expected by its context and must fit it. With no expected type the default is f64; only if the value does not fit f64 does the compiler fall back to the smallest float type that can hold it, up to f512.

TypeSizePrecisionC++ Equivalent
f162 bytesHalf (IEEE 754-2008)_Float16
f324 bytesSinglefloat
f648 bytesDoubledouble
f8016 bytes (12 on i386); 10 hold the valuex87 extended; only on some targets, see belowlong double
f12816 bytesQuadruple__float128
f25632 bytesExtended (software)
f51264 bytesExtended (software)
var x = 3.14        // f64 (default)
var y = 3.14f32     // f32
var z = 1.0e-10     // f64 scientific notation
var w = 0x1.8p3f32  // f32 hexadecimal float: 12.0
var b = 1.5bf16     // bf16

The float suffixes are f16, bf16, f32, f64, f80 and f128 (f80 and f128 only where the target has them, see below), in lower or upper case. A literal is rounded once, to the type it is used at, from its decimal digits: 16777217.0f32 is 16777216.0 and 16777217.0f64 is exact, and var q: f128 = 0.1 is 0.1 at f128 precision, not the f64 value 0.1 widened. An unknown float suffix (1.5f8, 1.5xyz) is SC074E.

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. Test for NaN with x != x: a NaN compares unequal to everything, itself included, so x == f64::NAN is always false. An infinity is an ordinary value to compare with: x == f64::INFINITY. See Limits.

Note

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

f80 follows the target's long double

f80 is the x87 80-bit extended format, and it is C's long double. It exists only where the target ABI's long double is x87 extended precision. That is a property of the ABI, not the CPU:

Targetlong doublef80
x86_64 Linux, BSD, macOSx87 extendedavailable
i386 Linuxx87 extendedavailable
x86_64 Windows (MSVC)binary64, despite the x87 hardwarenot available
x86_64 Androidbinary128not available
AArch64 and RISC-V Linuxbinary128not available
Apple ARM, Windows on ARMbinary64not available

Anywhere else, writing f80 is an error (P003E). The error names the target and what its long double is, and suggests f64 where long double is binary64 and f128 where it is binary128. Kairo never silently substitutes another float type for f80.

// building for x86_64-unknown-linux-gnu
var x: f80 = 1.0    // ok

// building for aarch64-unknown-linux-gnu
var y: f80 = 1.0    // error P003E: long double is binary128 here; use f128

Implicit Conversions

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

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

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

Bool

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

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


Char

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

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

A char converts implicitly to and from an integer only when the target is equal or wider: char to u32, u64, u128, i64, i128, and u8, u16, u32 to char. Everything else (char to u8, u16, i8, i16, i32; i8, i16, i32, i64, i128, u64, u128 to char) needs as. An integer literal takes char when it is a Unicode scalar value, and 0xD800 or 0x110000 is an error. See the conversion lattice.

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

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

C's char: std::c_char

char is a Unicode scalar value, which C and C++'s char is not. C's plain char is std::c_char (import std::c_char): one byte, a third type beside signed char (i8) and unsigned char (u8), signed or not as the target's C is. A C++ char imports as c_char, so a C string is *const c_char, which is const char* exactly. It is a character, not a number: as converts it to and from the integers, it compares with == and <, it does no arithmetic, and an integer literal takes it only in 0 through 127, where both signednesses agree. In an f-string a c_char prints its code, 0 through 255.

import std::c_char

var c: c_char = 65          // ok: 'A'
var d = 200u8 as c_char     // the byte 200, whatever the target's char is
// var e: c_char = 200      // error SC075E: only 0 through 127 fit on every target
var n = (c as i32) + 1      // arithmetic on the integer

C++'s wide characters: std::c_wchar, std::c_char16, std::c_char8

C++'s wchar_t, char16_t and char8_t are their own types too, and import as std::c_wchar, std::c_char16 and std::c_char8, never as the integer of their width: f(wchar_t) and f(int) are two overloads, a const wchar_t* is *const c_wchar, and cxx::std::wstring is basic_string<c_wchar>. They behave as c_char does: characters, not numbers, as to and from the integers, == and <, no arithmetic, and no conversion between them. c_wchar is 32 bits on most targets and 16 on Windows, signed or not as the target's C++ says, so a literal takes it in 0 through 0xFFFF; c_char16 (16 bits, unsigned) takes 0 through 0xFFFF and c_char8 (8 bits, unsigned) 0 through 255. In an f-string each prints its code.

import std::c_wchar
import std::c_char16

var w: c_wchar = 0x263A     // ok: fits wchar_t on every target
var u: c_char16 = 0x3B1
var n = w as u32            // 9786
// var x: u32 = w           // error SC028E: needs an explicit 'as'

Byte

TypeSizeC++ Equivalent
byte1 bytestd::byte

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

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

Strings

TypeSizeEncoding
string4 words (32 bytes on 64-bit)UTF-8

A string is a UTF-8 byte sequence. Its fields are public:

struct string {
    var data:   *const u8   // the bytes
    var len:    usize       // length in bytes
    var cp_len: usize       // number of codepoints the bytes encode
    var cap:    usize       // heap capacity in bytes; 0 for a borrowed string
}

len counts bytes, not characters; cp_len counts codepoints, and len == cp_len means the string is ASCII. A string literal is a borrowed view of static storage: it allocates nothing, and its cap is 0. An owned string has cap bytes of heap memory behind data. Both kinds keep a NUL byte after the last byte, so data can be passed to C as a C string.

var greeting = "Hello, Kairo! 📣"   // len 18, cp_len 15, no allocation
var name     = "Name"               // len 4, cp_len 4

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
}
Important

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


Void

TypeSizeC++ Equivalent
void0 bytesvoid

void indicates the absence of a value. It can be used as a 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

TypeSizeDescription
*T8 bytesSafe pointer non-null, compiler-tracked
unsafe *T8 bytesRaw pointer may hold &null, 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 Tether. 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

Arrays, slices, vectors, list literals, maps, sets, tuples and function pointers are covered in Collections.


Platform-Dependent Sizes

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

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

Limits

Every numeric primitive has a fixed set of associated constants, the Kairo answer to C++'s std::numeric_limits<T> and C's <limits.h> / <float.h>. They are spelled like a class's eval members, Type::NAME:

var lo  = i32::MIN               // -2147483648
var hi  = u8::MAX                // 255
var eps = f64::EPSILON           // 2.220446049250313e-16
var inf = f32::INFINITY
var buf: [u8; u16::MAX as usize + 1]   // a constant: legal in an array length

Each one is a constant of its own type, exactly as a suffixed literal is: u64::MAX is a u64 in any context, var x: u8 = i32::MAX is the usual narrowing error (SC028E), and u8::MAX + 1 wraps to 0 like any u8. They are constants wherever a constant is needed: an array length, an eval binding, eval if, a generic value argument. The descriptive ones (BITS, MANTISSA_DIGITS, the exponents) are u32 or i32, as listed.

Integers

u8 through u128, i8 through i128, usize, isize:

NameTypeValue
MINthe typethe smallest value (0 for unsigned)
MAXthe typethe largest value
BITSu32the width in bits

usize and isize take the target's width: usize::MAX on a 32-bit target is 4294967295. BITS exists for the 256- and 512-bit integers too; their MIN and MAX are not supported yet.

Floating point

f8e4m3, f8e5m2, f16, bf16, f32, f64, f80, f128:

NameTypeMeaningC++
MINthe typemost negative finite value (-MAX)lowest()
MAXthe typelargest finite valuemax()
MIN_POSITIVEthe typesmallest positive normal valuemin()
MIN_SUBNORMALthe typesmallest positive subnormal valuedenorm_min()
EPSILONthe typethe next value after 1.0, minus 1.0epsilon()
INFINITYthe typepositive infinityinfinity()
NEG_INFINITYthe typenegative infinity-infinity()
NANthe typea quiet NaNquiet_NaN()
MANTISSA_DIGITSu32significand bits, the implicit bit includeddigits
DIGITSu32decimal digits that survive text to float to textdigits10
MAX_DIGITSu32decimal digits float to text to float needsmax_digits10
MIN_EXPi32one more than the smallest normal binary exponentmin_exponent
MAX_EXPi32one more than the largest binary exponentmax_exponent
MIN_10_EXPi32smallest n with 10^n normalmin_exponent10
MAX_10_EXPi32largest n with 10^n finitemax_exponent10
RADIXu32always 2radix
BITSu32storage width in bitssizeof(T) * CHAR_BIT
Warning

MIN is not C++'s min(). std::numeric_limits<double>::min() is the smallest positive normal value. Kairo's f64::MIN is the most negative finite value, spelled the same way as i32::MIN, so T::MIN is the bottom of the range for every numeric type. C++'s min() is MIN_POSITIVE.

MANTISSA_DIGITSEPSILONMAXMIN_POSITIVEMIN_SUBNORMALDIGITSMAX_DIGITSMIN_EXPMAX_EXPMIN_10_EXPMAX_10_EXP
f8e4m342⁻³4482⁻⁶2⁻⁹03−59−12
f8e5m232⁻²573442⁻¹⁴2⁻¹⁶02−1316−44
f16112⁻¹⁰655042⁻¹⁴2⁻²⁴35−1316−44
bf1682⁻⁷≈3.39·10³⁸2⁻¹²⁶2⁻¹³³24−125128−3738
f32242⁻²³≈3.40·10³⁸2⁻¹²⁶2⁻¹⁴⁹69−125128−3738
f64532⁻⁵²≈1.80·10³⁰⁸2⁻¹⁰²²2⁻¹⁰⁷⁴1517−10211024−307308
f80642⁻⁶³≈1.19·10⁴⁹³²2⁻¹⁶³⁸²2⁻¹⁶⁴⁴⁵1821−1638116384−49314932
f1281132⁻¹¹²≈1.19·10⁴⁹³²2⁻¹⁶³⁸²2⁻¹⁶⁴⁹⁴3336−1638116384−49314932

The values are the formats' own (IEEE 754 for f16, f32, f64 and f128; bfloat16; x87 extended; OCP FP8 E4M3FN and E5M2), computed by the compiler from each format's layout, never read from the host's <float.h>. Every finite one is exact.

Comparing with an infinity works as with any value; comparing with NAN never does, because a NaN is unequal to everything, itself included:

if x == f64::INFINITY { }    // true for +infinity
if x != x { }                // true exactly for a NaN
if x == f64::NAN { }         // always false

Limits a type does not have

A limit a format does not have is not defined, and naming it is an error (R024E), never a substitute value:

  • f8e4m3 is the FN format: it has no infinity, and overflow saturates to 448. f8e4m3::INFINITY and f8e4m3::NEG_INFINITY are errors; it has a NAN.
  • f80 exists only where the target's long double is x87 extended. Elsewhere, f80::MAX is the same P003E as spelling f80 at all.
  • f256 and f512 have no limits until they have a fixed representation.
  • The float limits (EPSILON, INFINITY, ...) are not defined on integers.

char, byte, bool

TypeMembers
charMIN = '\0', MAX = '\U0010FFFF', BITS = 32
byteBITS = 8. No MIN or MAX: a byte is not a number.
boolnone

char::MAX is the largest Unicode scalar value. The range between MIN and MAX is not contiguous (the surrogates U+D800 to U+DFFF are not chars); the constants name the ends.

Note

T::MAX on a generic parameter T is not supported yet.


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)

// 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 view: [i32;] = [1, 2, 3]                          // slice
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 f: fn (i32, i32) -> i32 = add

The full conversion rules between primitive types are covered in the Conversion Lattice.

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.

  1. 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 is fine. var b: u8 = 300 is an error at the literal, with the range in the diagnostic. The other operand of a binop gives its type whichever side the literal is on: 1 + b and b + 1 are both u8 additions, and 300 + b is the same error as b + 300. A shift's result is its left operand's type, so a literal on the left of << / >> takes the expected type when there is one (var m: u16 = 1 << n is a u16 shift) and otherwise the count's integer type (1 << 4u8 is a u8 shift). When both operands are untyped literals, neither gives the other a type and rule 2 applies (1 << 4 is i32).
  2. With no expected type, the default is i32 for integer literals and f64 for float literals. Only if the value does not fit does the "smallest type that holds it" rule from Integers apply.

Rule 1 comes first on purpose. If promotion fired unconditionally, var x: u32 = 3000000000 would type the literal as i64 before looking at the annotation, and then fail to narrow it. Expected-type-first removes that whole class of error.

Suffixed literals (42u8) are typed at the literal and then participate in conversion normally.


See also