Kairo Primitive Conversion Lattice

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:

ClassMeaningas castImplicit
I (identity)S and T are the same typeno-opyes
W (implicit)value-preserving for every value of S; zero or near-zero cost; target-independentpermittedyes
E (explicit)representable but may lose value, sign, or precision, or costs a runtime helperrequiredno
U (unsafe)requires an unsafe context in addition to asrequiredno
X (forbidden)no cast exists; diagnostic suggests a library functionrejectedno

There is no "warn" class. A conversion is either sound-by-construction or it is not; a third state would force the checker to carry a severity through overload resolution, and severities do not compose. Lossy-cast detection on known-constant operands is a lint (section 8), not part of the type relation.

-fno-implicit-conv demotes every W to E. It changes nothing else. Section 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.

TargetMantissa bitsExact integer sources
f1611u8, i8
f3224u8, i8, u16, i16
f6453u8, 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 section 4. The single exception is an argument to an imported C/C++ function, where usize/isize widen by the boundary rule in section 4.1.


3. Core integer table

Rows are source, columns are target. <=64-bit fixed-width integers only; wider widths are E by R5.

S \ Tu8u16u32u64i8i16i32i64
u8IWWWEWWW
u16EIWWEEWW
u32EEIWEEEW
u64EEEIEEEE
i8EEEEIWWW
i16EEEEEIWW
i32EEEEEEIW
i64EEEEEEEI

Read the shape: the unsigned block is upper-triangular, the signed block is upper-triangular, the unsigned-to-signed quadrant is strictly upper-triangular (one width step is required, so u32 -> i32 is E), and the signed-to-unsigned quadrant is empty.

3.1 Float and cross-domain

S \ Tf16f32f64f128+any intboolcharbyte
u8/i8WWWEsee section 3EEE
u16/i16EWWEsee section 3EEE
u32/i32EEWEsee section 3EEE
u64/i64EEEEsee section 3EEE
f16IWWEEXXX
f32EIWEEXXX
f64EEIEEXXX
f128+EEEE/IEXXX
boolXXXXEIXX
charXXXXEXIX
byteXXXXEXXI
stringXXXXXXXX
voidXXXXXXXX

Float-to-int is E in all cases. The cast truncates toward zero and saturates on out-of-range values; it is never undefined behavior. See Casting.


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 (section 5), not conversion.

byte. No W conversion to or from u8. byte is restricted to bitwise operations (Byte); if byte -> u8 were implicit then b + mask would succeed by converting both operands and the restriction would be 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 range-checks: surrogates and values above 0x10FFFF are not scalar values, and an invalid char never exists. Whether the failed check traps or yields a char? is not yet specified.

bool. E in both directions. Bool already forbids implicit conversion from integers; the reverse is forbidden too. int -> bool is E with != 0 semantics.

Pointers. *T -> unsafe *T is U, not W. It is a safety downgrade and Tether 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-to-integer and integer-to-pointer are both U. 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.

4.1 The C/C++ boundary

An argument to an imported C/C++ function may widen to or from usize/isize without as. Nothing else uses this rule: Kairo functions, assignments, if/match arms and operator operands keep section 4 alone.

The rule reads the target only below 32 bits. usize and isize are taken as [32, 64] bits wide, or [P, 64] on a target whose pointer width P is below 32; a fixed-width integer of N bits is [N, N]. S -> D is implicit iff:

  • same signedness: max(S) <= min(D)
  • unsigned to signed: max(S) < min(D)
  • signed to unsigned: never
AcceptedRefused
u8/u16/u32 -> usizeu64 -> usize (narrows on 32-bit)
i8/i16/i32 -> isizei64 -> isize (narrows on 32-bit)
u8/u16 -> isizeu32 -> isize (same width on 32-bit)
usize -> u64, isize -> i64usize -> u32, isize -> i32 (narrow on 64-bit)
usize -> i64 (same width on 64-bit)
i32 -> usize, isize -> u64 (signed to unsigned)

Because the rule judges against the narrowest and widest target at once, every 32- and 64-bit target accepts the same set of programs; a 16-bit target accepts a subset. This keeps the property section 4 protects. A conversion accepted by this rule is considered only when section 3 would require as, and ranks like any other implicit conversion in overload resolution. Extended-width types (R5), floats and non-numeric types never take it, and -fno-implicit-conv turns it off like any other W.

The importer reads size_t/uintptr_t as usize, and ptrdiff_t/intptr_t/ssize_t as isize. A plain unsigned long stays a fixed-width type (u64 on LP64 targets) and is judged by section 3.


Literal inference is covered in Primitives.


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 not W(B -> A): result operand type is B.
  • If W(B -> A) and not W(A -> B): result operand type is A.
  • Otherwise: error, requiring an explicit cast on one side.

Consequences:

ExpressionResultWhy
i32 + i64i64W one way only
u32 + i32errorneither direction is W (R2 blocks i32 -> u32; u32 -> i32 fails the width test)
u32 + i64i64R2
i32 + f64f64R3
i64 + f64errorR3 exactness fails; force the cast
usize + i32errorsection 4
i64 + i128errorR5: extended width never silent
byte & u8errorsection 4

The u32 + i32 error is deliberate. C's answer is u32, which silently converts every negative i32 into a huge positive number; Kairo refuses rather than guess.

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:

  1. Exact (I).
  2. 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. The tie-breaks in Function Overloading (reference binding, non-generic over generic, most specialized generic) never order conversions, so "no ordering within rank 2" still holds, and so does the guarantee below. This is the property that makes the flag safe:

Let C_on be the candidate selected with W enabled. -fno-implicit-conv demotes 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.

List literal arguments

A list literal argument uses the same two ranks:

  • A [T; N] parameter, where N equals the literal's element count, is Exact when every element is exact for T.
  • A [T;] or a [T] parameter is Converted. The two are unordered against each other, like any other pair of rank-2 candidates.
fn f(const xs: [i32; 3]) { }
fn f(xs: [i32;]) { }
fn f(xs: [i32]) { }

f([1, 2, 3])            // picks [i32; 3]: Exact beats Converted

With only the slice and vector overloads, both are Converted and the call is ambiguous. Annotate the argument to choose:

fn g(xs: [i32;]) { }
fn g(xs: [i32]) { }

g([1, 2, 3])            // compile error: call to 'g' is ambiguous
g([1, 2, 3] as [i32])   // ok: vector overload
var s: [i32;] = [1, 2, 3]
g(s)                    // ok: slice overload

Literals other than list literals are unaffected.


8. Lints (not part of the type relation)

  • lint::lossy-cast: an as cast 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: an as cast 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.


See also