Type Conversions in Envzn — AS / INTO Reference

Audience: Envzn developers and future Claude sessions. This is the practical lookup for "I have a value of type X and I want type Y — is it valid, do I write AS or INTO, and what do I get back?"

Source of truth: the normative spec is ENVZN_CONSTITUTION.md I.D.viii + IV.A.viii; the design rationale is conversion-operators-design.md. This document is a derived catalogue of the surface those define, enumerated from the kernel hosts that implement it (kernel/src/PrimitiveConversions.ev, NumberConverter.ev, FloatConverter.ev, CharConverter.ev, CharWidthConverter.ev, TextConverter.ev). Every row below is an OPERATOR that exists in those files today and compiles. When the kernel hosts change, re-derive this doc from them — do not let it drift.


1. The four rules that explain everything

  1. Envzn has no casts. There is no syntax that reinterprets one type as another. Every crossing between numeric types, between numbers/chars and text, and between the text and binary container columns is a checked conversion written with one of two operators.

  2. Two operators, split by the LOSS axis: - INTO = lossless. The target can represent every value of the source. Widening (int32 INTO int64), value→text (42 INTO String), and parse ("42" INTO int32). - AS = lossy. The conversion can lose data or cross a sign/range boundary, so it is range-checked at runtime, never a silent truncation (int64 AS int32, float64 AS int32, char32 AS char8).

  3. A second, independent axis — FALLIBILITY — decides the return shape: - Total → a plain value. Consume it directly: String s := 42 INTO String. - Fallible → the pipe-XOR shape (T value | STATUS status) (I.M.i). Consume it as the condition of an IF/WHILE; in the success branch read the value with $= (the alias $RETURNED is equivalent), and in the failure branch read the failure STATUS with $! and its numeric error code with $#. $# is not always meaningful: it is 0 whenever the failure carried no numeric code — which is the case for every conversion operator here (they raise FAILURE("message") with a message but no code), so rely on $! for conversion failures and treat $# as 0. The code is only non-zero for STATUS values from sources that set one (e.g. an errno-bearing SETS_ERRNO FFI bind).

The two axes combine into exactly three live cells (there is no lossy-total cell — any lossy conversion is range-checked, hence fallible):

Total (plain value) Fallible (T \| STATUS)
INTO (lossless) widening, value→text parse (String INTO T), ByteBuffer INTO String
AS (lossy) — (none) narrowing / sign-cross / char-width narrowing
  1. You never name the host. Write x INTO T or x AS T; the compiler resolves the single operator whose (source, target) signature matches. NumberConverter, PrimitiveConversions, etc. are where the operators live, not something you type at the call site. (They are extensible: a module can add its own CONVERSIONS host for its own types — see IV.A.viii.)

The two consumption shapes, concretely

// TOTAL — plain value, used directly
String label := 42 INTO String              // "42"
int64  wide  := myInt32 INTO int64           // widening
float64 d    := myInt32 INTO float64

// FALLIBLE — pipe-XOR, consumed by IF; $= (alias $RETURNED) is the value,
//            $! is the failure STATUS, $# is its error code
IF (userInput INTO int32) THEN {
    int32 n = $RETURNED                             // success: the parsed value; note that $= and $RETURNED are equivalent
    process(n)
} ELSE {
    String why := $!                         // failure: the STATUS (auto-converts to text)
    int32 errorcode = $#                     // error code, if any — 0 for conversion failures (no code set)
}

// narrowing is fallible too
IF (myFloat64 AS int32) THEN { pixel = $= }  // out-of-range / NaN → ELSE branch

The classic trap. int32 AS String is invalid — number→text loses nothing, so it is int32 INTO String. Reaching for AS where the conversion is lossless (or INTO where it is lossy) is a compile error, by design. Use the matrix below to pick correctly.


2. Quick matrix — every valid conversion, one row each

✓total = plain value · ✓fallible = (T | STATUS), consume with IF — value via $= (alias $RETURNED), failure STATUS via $!, its error code via $#. Anything not listed is not a valid conversion (compile error) — including the reverse of many one-way rows (e.g. there is no String AS int32; parse is String INTO int32).

2a. Numeric ↔ numeric

From To Operator Shape Notes
int8 float32 / float64 INTO ✓total int→float widening
int8 int16 / int32 / int64 / int128 INTO ✓total widening
int8 uint8 / uint16 / uint32 / uint64 / uint128 AS ✓fallible range-checked sign cross — a negative has no target value
int16 float32 / float64 INTO ✓total int→float widening
int16 int32 / int64 / int128 INTO ✓total widening
int16 uint8 / uint16 / uint32 / uint64 / uint128 AS ✓fallible range-checked sign cross — a negative has no target value
int16 int8 AS ✓fallible range-checked narrowing
int32 float32 / float64 INTO ✓total int→float widening
int32 int64 / int128 INTO ✓total widening
int32 uint8 / uint16 / uint32 / uint64 / uint128 AS ✓fallible range-checked sign cross — a negative has no target value
int32 int8 / int16 AS ✓fallible range-checked narrowing
int64 float32 / float64 INTO ✓total int→float widening
int64 int128 INTO ✓total widening
int64 uint8 / uint16 / uint32 / uint64 / uint128 AS ✓fallible range-checked sign cross — a negative has no target value
int64 int8 / int16 / int32 AS ✓fallible range-checked narrowing
int128 float32 / float64 INTO ✓total int→float widening
int128 uint8 / uint16 / uint32 / uint64 / uint128 AS ✓fallible range-checked sign cross — a negative has no target value
int128 int8 / int16 / int32 / int64 AS ✓fallible range-checked narrowing
uint8 int16 / int32 / int64 / int128 INTO ✓total cross-sign widen — every value fits the target's non-negative half
uint8 float32 / float64 INTO ✓total int→float widening
uint8 uint16 / uint32 / uint64 / uint128 INTO ✓total widening
uint8 int8 AS ✓fallible range-checked sign cross — the upper half has no target value
uint16 int32 / int64 / int128 INTO ✓total cross-sign widen — every value fits the target's non-negative half
uint16 float32 / float64 INTO ✓total int→float widening
uint16 uint32 / uint64 / uint128 INTO ✓total widening
uint16 int8 / int16 AS ✓fallible range-checked sign cross — the upper half has no target value
uint16 uint8 AS ✓fallible range-checked narrowing
uint32 int64 / int128 INTO ✓total cross-sign widen — every value fits the target's non-negative half
uint32 float32 / float64 INTO ✓total int→float widening
uint32 uint64 / uint128 INTO ✓total widening
uint32 int8 / int16 / int32 AS ✓fallible range-checked sign cross — the upper half has no target value
uint32 uint8 / uint16 AS ✓fallible range-checked narrowing
uint64 int128 INTO ✓total cross-sign widen — every value fits the target's non-negative half
uint64 float32 / float64 INTO ✓total int→float widening
uint64 uint128 INTO ✓total widening
uint64 int8 / int16 / int32 / int64 AS ✓fallible range-checked sign cross — the upper half has no target value
uint64 uint8 / uint16 / uint32 AS ✓fallible range-checked narrowing
uint128 float32 / float64 INTO ✓total int→float widening
uint128 int8 / int16 / int32 / int64 / int128 AS ✓fallible range-checked sign cross — the upper half has no target value
uint128 uint8 / uint16 / uint32 / uint64 AS ✓fallible range-checked narrowing
float32 float64 INTO ✓total widening
float32 int32 AS ✓fallible range-checked; NaN/∞ → FAILURE
float64 int32 AS ✓fallible range-checked; NaN/∞ → FAILURE

113 numeric↔numeric operators — 51 total INTO, 62 fallible AS — read from kernel/src/*.ev at build time. Regenerate with python3 docs/build_conversions_table.py; do not hand-edit between the markers.

The table above is generated from the kernel registry. It used to be kept by hand, and it drifted the way a hand-kept list of a hundred-odd rows does: it went on saying sign-mixing and narrowing were unregistered, and that int32 AS int16 was "not yet provided", after both had stopped being true. Brian's Phase 8 ruling 4 made it derived — a generator reads kernel/src/*.ev and rewrites the region between the markers, so the doc cannot disagree with what actually resolves. Everything outside the markers, including this paragraph, is hand-written and carries the rationale the registry has no way to state.

The rule behind every row: every TOTAL conversion has an INTO, every FALLIBLE one an AS. Totality is a range question and nothing else — the source's range fits inside the destination's, or it does not — so the keyword is derived rather than chosen. The integer half is now complete: all 90 ordered pairs across the ten integer types are registered (Phase 8 family #2, 2026-09-21), which closed 59 crossings a developer could name but not spell. Before that, the analyzer refused those pairs with E2007 ("requires IS or Convert") while no Convert existed to reach for.

Every implicit widening of constitution I.J.iii.c — a same-signedness integer to a wider integer, ANY integer to a float, and float32 INTO float64 — is also a registered total OPERATOR INTO (Bug #233/#234). int→float is registered even where wide (int64 INTO float32), following C++'s value conversion exactly as the pre-existing int32 INTO float32 does.

A registered sign cross is still an EXPLICIT one. widens_to (compiler/primitives.py) treats all sign-mixing as non-implicit, so an int64 parameter does not silently accept a uint32 — you write the INTO. That holds even for the ten cross-sign pairs that are total (uint8 and uint16 into the wider signed types, uint32 INTO int64, uint64 INTO int128), where every source value lands in the target's non-negative half and nothing can be lost: Brian's 2026-08-25 ruling is that a sign change is chosen by the developer, and the keyword is how they say the reading changed, even where the value cannot. (#38, and Phase 8 family #2 for the other eight.)

What is still not a conversion is a pair with no operator in either direction — int32 INTO boolean, for instance, since family #4 ruled INTO/AS closed to boolean both ways with b INTO String the sole exception. Those are a clean E2086 at analysis time (Bug #233 hardening; it no longer slips to a clang reject). For a deliberately unchecked truncation, use NumericUtilities (§5), not AS.

2b. Value / char → String (rendering — always lossless + total)

From To Operator Shape Result
int32 int64 int128 uint32 uint64 uint128 String INTO ✓total decimal text, e.g. 42 INTO String → "42"
boolean String INTO ✓total "true" / "false"
float32 float64 String INTO ✓total shortest round-trip decimal (Ryu), e.g. 0.1 INTO String → "0.1"
char8 char16 char32 String INTO ✓total one-character string
char32[] String INTO ✓total code-point array → string (backs print/format auto-convert)

You rarely write INTO String just to print: ->format(...) and the Stdio print methods auto-convert any argument (I.F.vi). Reach for explicit INTO String when you need the String as a value (storing it, concatenating, returning it).

2c. String → value (parse — lossless but can fail)

From To Operator Shape On bad input
String int32 int64 int128 uint32 uint64 uint128 INTO ✓fallible FAILURE status
String float32 float64 INTO ✓fallible FAILURE status
String boolean INTO ✓fallible FAILURE status
IF ("42" INTO int32) THEN { int32 n = $= } ELSE { /* not a number */ }

2d. Text ↔ binary (String ↔ ByteBuffer, UTF-8)

From To Operator Shape Notes
String ByteBuffer INTO ✓total UTF-8 encode — every code point encodes
ByteBuffer String INTO ✓fallible UTF-8 decode — invalid bytes → FAILURE

INTO here assumes UTF-8 (the canonical encoding). Other encodings are bulk operations, not single-pair operators — see UTFCodec (§5). For opaque-byte display (hex), use HexCodec, not this bridge.

2e. char ↔ integer (bit-twins, widening, narrowing)

A code point's own arithmetic is not a conversion. Since 2026-09-10 (I.D.i(b.ii), gh #285) char ± integer is an OFFSET yielding the char's own type — range-checked at runtime against that width's code-unit range — and char - char is a DISTANCE yielding int32. Neither needs a door. What still does: a plain integer landing in a char slot (char digit = 0x30 + d) is a crossing and is refused (E10096); write the offset, char digit = '0' + d. Everything else with a char operand — char + char, n - c, * / ~/ % ^ &* &+ &-, unary minus — is E2150, not a missing conversion.

Bit-equivalent twins — INTO, total, both directions (same storage width):

Pair Operator
uint8 ↔ char8 INTO (either direction)
uint16 ↔ char16 INTO (either direction)
uint32 ↔ char32 INTO (either direction)

A code point never lands in an integer slot implicitly, and never in another char width implicitly. Since 2026-09-18 (Phase 8, I.D.i(b.ii)/(d.i)) every row in the two tables below is a DOOR that must be written: int64 n = c and int64 n = c + p are both E10096 — the offset's result is the code point's own type, so an integer slot receives a crossing — and char16 u = c8 is E10096 too. (c + p) INTO int64 and (c8 INTO char16) are the spellings. The one thing that still crosses nothing is the DISTANCE c1 - c2, which is an int32 from the start. The keyword-bitwise operators (BAND BOR BXOR BNOT LSHIFT RSHIFT) yield an INTEGER across any two char widths (I.D.i(e)), so that result needs no door into an integer slot — and needs one back into a char slot.

Widening into a wider char or a wider/equal integer — INTO, total:

From To (any of) Operator
char8 char16 char32 int16 int32 int64 uint32 uint64 float32 float64 INTO ✓total
char16 char32 int32 int64 uint32 uint64 float32 float64 INTO ✓total
char32 int32 int64 uint64 float32 float64 INTO ✓total
uint8 char16 char32 INTO ✓total
uint16 char32 INTO ✓total
int32 char32 INTO ✓total — total: char32 is 32-bit storage, so any int32 fits (Bug #189)

Integer/float → char, narrowing — AS, fallible (range/scalar-checked):

From To Operator Check
int8 int16 int32 int64 uint16 uint32 uint64 float32 float64 char8 AS ✓fallible must be in [0, 0xFF]
int8 int16 int32 int64 uint32 uint64 float32 float64 char16 AS ✓fallible must be in [0, 0xFFFF]
int8 int16 int64 uint64 float32 float64 char32 AS ✓fallible valid Unicode scalar [0,0x10FFFF], no surrogates

char → narrow integer, narrowing — AS, fallible:

From To Operator Check
char8 char16 char32 int8 AS ✓fallible must be ≤ 0x7F
char16 char32 uint8 AS ✓fallible must be ≤ 0xFF
char16 char32 int16 AS ✓fallible must be ≤ 0x7FFF

2e.i binary — the octet (added 2026-08-28; byte retired 2026-08-29)

binary is the language's 8-bit unsigned octet, and since 2026-08-29 it is the only one. It is deliberately not in GROUP Numeric — a bit pattern is not a number — and since 2026-09-08 it does not widen at all: no implicit conversion crosses the octet boundary in either direction any more, so every row below is an explicit INTO/AS, never the free widening uint8 would give:

From To (any of) Operator Notes
binary uint16 uint32 uint64 uint128 INTO total widening
binary int16 int32 int64 int128 INTO total — 0x00..0xFF fits every signed word of 16 bits and up (added 2026-09-08)
binary float32 float64 INTO total
binary char8 char16 char32 INTO total; the octet reinterpreted as a code unit
binary int8 AS fallible — 128..255 has no int8 spelling

And INTO binary — the inbound ladder (added 2026-08-30). Until 2026-08-29 there was none, because uint8 -> binary was implicit: an octet and a number were interchangeable at the same width. That is what let DynamicByteBuffer.append(uint8) compile at 15 kernel call sites, with clang unable to object because both lowered to uint8_t. Closing the implicit edge without providing an explicit one would leave a denial with no remedy, so:

From To Operator Notes
uint8 binary INTO total — same width, same signedness
char8 binary INTO total — a UTF-8 code unit already is an octet
int8 binary INTO total — every int8 has an octet spelling; -1 is 0xFF
uint16 uint32 uint64 uint128 binary AS fallible — must be ≤ 255
int16 int32 int64 int128 binary AS fallible — both bounds; a negative has no octet spelling
float32 float64 binary (none) a float has no octet meaning — go via an integer

Note the asymmetry with the outbound row above, which is deliberate rather than an oversight: binary AS int8 is fallible because 128..255 has no int8 spelling, while int8 INTO binary is total because every bit pattern is a valid octet. The octet is the wider domain in that pair.

The masked idiom needs none of these. x BAND 0xFF is typed binary — the mask literal fits an octet, so the AND yields one — which is why acc->append(x BAND 0xFF) compiles with no operator at all: the argument the call receives already is the octet type it wants. A masked integer assigned to an integer slot is the other half of the same idiom and stays a number, also with no operator (uint64 masked = word BAND 0xFF) — nothing in it was ever a stored bit pattern, so there is nothing to convert. Which one you get depends on the slot, not the expression; every hash and wire codec in the kernel reads unchanged either way. The AS rows cover the case the compiler cannot bound.

byte is gone. It was a third 8-bit unsigned spelling alongside uint8 and binary, all three lowering to uint8_t, and the primitives table described it as "distinct from uint8 only by intent" — an intent nothing enforced. Every one of its 19 type-position sites in the kernel existed only to support byte itself, and GROUP Numeric listed it while excluding binary, which settled what it actually was: the numeric octet, i.e. uint8. Sites that meant an octet are now binary; sites that meant a small number are now uint8.

No implicit conversion crosses the octet boundary, in either direction, as of 2026-09-08. int32 n = b — reading an octet as a number — used to widen for free, the same as uint8 INTO int32 does; it is refused now, and so is the reverse, binary b = u — writing a number into an octet. The signed INTO rows above (int16/int32/int64/int128) exist because that implicit widening is gone: a caller who wanted a signed target and knew the value fit used to get there by plain assignment (int32 d = raw[i]); the same read now has to say INTO at the call site, which is why the rows have to exist for it to say. Two codes name the two refused directions — E2139 when an octet is read where a number is wanted, E2145 when a number is written where an octet is wanted.

Why this exists. The byte containers store binary[] (§I.J.i(c)), so buf[i] INTO uint64 — the first move of every hash and every wire codec — is ordinary code. It worked only by accident before: the storage was spelled char8[], and char8, being signedness-neutral, carries the full numeric ladder including the signed targets. When the storage became binary[] the idiom stopped compiling in three kernel hot paths at once, which is how the gap was found — and the same audit is what closed the implicit crossing outright rather than patching the one site it broke.

2e.ii boolean — the truth value (added 2026-09-19)

No conversion crosses the boolean boundary implicitly, and INTO/AS is not a door either (I.D.i(b.iii)). There is no conversion operator into a boolean from a number, a code point or an octet, and none out of one — in any position: declaration, assignment, argument or return. All four arms report the same code, E10096, so the crossing is never named two ways at two positions (until this ruling a declaration said E2002 while the other three said E10096, and boolean x = someInt128 said nothing at all).

From To Operator Shape
boolean String INTO ✓total — the one outbound door (§2b)
String boolean INTO ✓fallible (§2c)
boolean any numeric / char* / binary (none) there is no number a truth value is
any numeric / char* / binary boolean (none) IF n != 0 is the test that was meant

The same ruling refuses the operator positions a crossing used to reach through: arithmetic with a boolean operand and unary minus are E2152 ("a boolean is not a number"), ordering is E2118, a boolean array subscript is E2144, and equality against anything but another boolean is E2025.

2f. char-width narrowing (CharWidthConverter)

Widening between char widths is in §2e (INTO, total). Narrowing a wider char to a narrower one can fail (the code point may need multiple code units) — AS, fallible:

From To Operator Succeeds only when
char16 char8 AS ✓fallible code point is single-byte UTF-8 (ASCII)
char32 char8 AS ✓fallible code point is single-byte UTF-8 (ASCII)
char32 char16 AS ✓fallible code point is in the BMP (single UTF-16 unit)

Implicit cross-char-type conversions are REFUSED (E10096). Writing one char type where another is expected without an explicit AS/INTO (e.g. char16 u = someChar8, or returning a char8 from a RETURNS char16) is a compile error — a code unit of one encoding is not the same unit in another. Every char-type pair, with no exemption, in every position (declaration, assignment, argument, return). It is the Envzn-native parallel to clang's -Wcharacter-conversion, and --no-clang-char-warnings silences clang's, not Envzn's. The refusal is exempt inside an OPERATOR AS/INTO body whose declared target is the destination width (that is where the conversion is defined).

Changed 2026-09-18 (Phase 8, I.D.i(d.i)). This was the warning W10095, with char16→char32 exempt on the grounds that a non-surrogate UTF-16 unit is the same UTF-32 scalar. Half of the char16 space — the surrogates 0xD800..0xDFFF — is not a UTF-32 scalar at all, which is the case a decoder exists to handle, and §2f already calls the reverse direction fallible. W10095 is retired; E10096 is the one code, and the ASSIGNMENT arm had been reporting it on all nine pairs all along.

2g. number and complex (the value-class numeric primitives, §I.D.i(g))

number holds an int64 XOR a uint64 (the UNSIGNED overflow arm, #38) XOR a float64; complex is a pair of numbers. Widening into either is implicit/total; narrowing out of number is lossy ⇒ AS (fallible); extracting a real out of complex is lossless-but-precondition'd ⇒ INTO (fallible). (Hosts: NumberConversions.ev, ComplexConversions.ev.)

From To Operator Shape Notes
int8…int64 / uint8…uint64 / float32 / float64 number INTO ✓total implicit widen-in (a bare number n = anyInt is the same total widen). A uint64 past int64_max value-classifies to the UNSIGNED arm (#38) — still total
int128 / uint128 number AS ✓fallible may exceed the uint64 arm → range-checked
number int8 / int16 / int32 / int64 / uint8 / uint16 / uint32 / uint64 AS ✓fallible a float-subtype value, or one out of the target's range, → FAILURE. An UNSIGNED-arm value extracts totally via AS uint64, but fails AS int64 and every narrower type (it is > int64_max)
number float32 AS ✓fallible overflow (float subtype beyond float32 max) → FAILURE; int subtype always succeeds (precision loss tolerated)
number float64 INTO ✓total int arm widens to double, float arm is identity
number String INTO ✓total tag-aware: integer subtype → 5, float subtype → Ryu 2.0
int8…int64 / uint8…uint32 / float* / number complex INTO ✓total real widens to complex(value, 0) (wide/sign-cross sources go through the AS-into-number arm)
complex float64 (any real) INTO ✓fallible lossless-fallible: succeeds iff the imaginary part is zero; nonzero imaginary → FAILURE
complex a real (AS) — — compile error E2106 — Re-vs-magnitude ambiguous; use an accessor (c.real / c.magnitude) then convert, or complex INTO <real>

number/complex are Equatable only (not Hashable/Comparable): fine as values, never a Dictionary/Set key or SortedList element. There is no number AS String (text rendering is lossless ⇒ INTO), and no lossy-total cell here either — every AS above is fallible.

2h. decimal128 (the exact base-10 primitive, §I.D.i)

decimal128 is an IEEE 754-2008 exact base-10 numeric ({ int128 coefficient; int32 exponent }). Unlike the bare primitives, the kernel cannot construct it from a plain operator — so every inbound … INTO/AS decimal128 is compiler-assembled (a kernel helper returns the parts, the compiler builds the value); the outbound operators are ordinary OPERATORs in DecimalConversions. Widening a ≤64-bit int into decimal128 is exact/total; everything else inbound is INTO fallible — exact-when-representable, else FAILURE, never a silent round (decimal128's exactness contract; that is the lossless-fallible cell, so it is INTO, not AS). Narrowing out is lossy ⇒ AS (fallible). The fallible inbound rows return the pipe-XOR shape, not a plain value — so decimal128 d = (f INTO decimal128) is E6064 and the consume is IF (f INTO decimal128) THEN { d = $= }. These pairs are compiler-ASSEMBLED rather than registry-declared, and until 2026-09-19 the exemption that kept them out of the "no operator" check returned before the fallibility was stamped, so the shape check never saw them and the emitted _EvTuple<Decimal128, _EvStatus> reached a bare Decimal128 slot (Phase 8 family #3, ruling R5). (Hosts: DecimalConversions.ev; inbound parts-helpers DecimalText / FloatToDecimal / NumberToDecimal / DecimalCodec.)

From To Operator Shape Notes
int8…int64 / uint8…uint64 decimal128 INTO ✓total exact widen + implicit (≤20 digits, always fits)
int128 / uint128 decimal128 INTO ✓fallible exact when < 10³⁴; a wider value → FAILURE (never silently rounds). Explicit — not implicit-widen (Bug #269)
float32 / float64 decimal128 INTO ✓fallible shortest round-trip decimal (Ryu); non-finite → FAILURE
number decimal128 INTO ✓fallible tag-aware (int arm exact, float arm via Ryu)
complex decimal128 INTO ✓fallible real projection — succeeds iff the imaginary part is zero
String decimal128 INTO ✓fallible exact decimal-text parse ("19.99"); bad text → FAILURE
ByteBuffer decimal128 INTO ✓fallible IEEE BID 16-byte decode; wrong length / non-canonical / >34 digits → FAILURE
decimal128 String INTO ✓total EXACT decimal text — the headline display feature (19.99→"19.99", cohort preserved)
decimal128 ByteBuffer INTO ✓total IEEE BID 16-byte big-endian wire encoding (simple 113-bit form)
decimal128 int8…int64 / uint8…uint64 / int128 / uint128 AS ✓fallible non-integral or out-of-range → FAILURE
decimal128 float32 / float64 AS ✓fallible lossy (binary can't hold most decimals exactly); overflow → FAILURE
decimal128 number AS ✓fallible integral → exact number(int64); else the lossy float64 arm; float64 overflow → FAILURE

decimal128 stays "clean": a decimal128 operand in arithmetic/comparison combines only with another decimal128 or a ≤64-bit integer (which widens exactly). A float/number/complex/int128 counterpart is a compile error (E2121) — convert it explicitly so precision is never silently lost.

decimal128 IS Hashable + Comparable (cohort-normalized), so it is a valid Dictionary/Set key and SortedList element — the contrast with number/complex above. There is no decimal128 AS String (text is lossless ⇒ INTO), and the ByteBuffer ⇄ decimal128 pair is the IEEE BID wire codec (a single canonical encoding, hence AS/INTO, not a parameterized §5 codec).


3. Formatting (->format) and auto-conversion — what is handled, what is not

Conversion and formatting are related but distinct. ->format(...) (and the Stdio print methods, I.F.vi) auto-convert their arguments to text for you, which is why you rarely write an explicit INTO String just to print. But the auto-convert covers a fixed set of argument types — anything outside it either renders as a placeholder (with a compile warning) or must be converted by hand first. This section is the line between the two.

How a format call works

String msg := ("Processing item $1 of $2")->format(current, total)   // $1..$9 filled in order
String hex := ("color = #$1:0>6X")->format(v)                        // "color = #0000FF"

$1..$9 are the placeholders (filled left-to-right by the arguments); $$ is a literal $. Each placeholder may carry a spec $N:<fill><align><sign><width><type> mirroring Python's mini-language — type letters :d :x :X :o :b (integer bases) and :s (default render). The compiler chooses the right render for each argument at the call site, so a value never has to be threaded through (value INTO String) before a format call.

✓ Auto-converted (these render with no manual conversion)

Argument type Renders as
any primitive numeric (int*, uint*, float*, char) its value as text; numeric base controlled by :d/:x/:X/:o/:b
boolean "true" / "false"
String itself, directly
DynamicString snapshotted to String first (you do not pre-toString())
binary byte, ByteBuffer space-separated uppercase hex — "DE AD BE" — by default; :x/:X/:o/:b re-bases, packed without spaces
DynamicByteBuffer snapshotted to ByteBuffer first, then as hex
a class instance with METHOD toString() RETURNS String whatever its toString() returns
a class handle whose value is EMPTY the literal "EMPTY"

✗ NOT auto-converted (placeholder render, warning, or you convert first)

Argument What happens What to do
a class instance without toString() renders [??] and the compiler warns add METHOD toString() RETURNS String to the class
an opaque value with no recognizable carried type renders "??" extract/convert the real value before formatting
an array or collection (int32[], Array[T], Dictionary, Set, …) not in the accepted argument set — not a single formattable value format the elements yourself (e.g. build a DynamicString, or loop and ->format each)
a ByteBuffer you want shown as text, not hex the default render is hex, not the decoded characters convert first: bb INTO String (§2d, fallible UTF-8 decode) and pass the String
a ByteBuffer you want shown as a specific non-default form (Base64, etc.) only the integer bases (:x/:X/:o/:b/:d) are reachable via the spec use the matching codec (§5) — e.g. Base64Codec->toBase64(bb) — and pass the resulting String

Rule of thumb

If the value is a single primitive, a String/DynamicString, a byte container (and hex is what you want), or a class with a toString(), just pass it — ->format handles it. If it is a collection, a byte buffer you want as text, or a class without toString(), the auto-convert stops and you reach for an explicit conversion (§2, §5) or render the parts yourself. The format auto-convert is a convenience over the conversion surface in this document, not a replacement for it.


4. Per-host detail (where each operator lives + edge cases)

You never name these at the call site; they are listed so you know where to edit the surface and what each guarantees. All are CONVERSIONS hosts in kernel/.


5. Not AS/INTO — reach for these instead

Some type crossings are deliberately not checked conversions, so they are ordinary NAMESPACE free-function calls (Host->method(...)), not operators. Use these when the matrix above has no row for what you want:

You want… Use Example
ByteBuffer/binary → hex String (and back) HexCodec HexCodec->toHex(bb) · HexCodec->fromHex(s) (fallible)
bytes ↔ Base64 text Base64Codec Base64Codec->toBase64(bb) · Base64Codec->fromBase64(s) (fallible)
number ↔ bytes with a chosen endianness ByteOrderCodec ByteOrderCodec->toBytes(n, endian) · ->toInt32(bb, endian) (fallible)
bulk transcode between UTF-8/16/32 arrays UTFCodec UTFCodec->encodeToUTF8(char32[]) (fallible)
an unchecked, data-losing truncation/reinterpret cast NumericUtilities NumericUtilities->truncateToInt32(int64) · ->toUint64(int32)
a char/string predicate (is-digit, is-alpha…) CharClassifier CharClassifier->isDigit(c) — a test, not a conversion

Why they are not operators: a codec is parameterized (endianness, encoding) or many-to-one (bulk arrays), and AS/INTO are single-source/single-target; and NumericUtilities is intentionally unchecked — making it an AS would collide with the checked AS for the same type pair and hide the data loss the caller explicitly wants. (DateTime construction from parts/strings is fallible too but lives on the DateTimeFactory singleton, since Envzn has no static methods or fallible INIT.)


6. Boundaries / what is not here


Maintenance: when you add or change an OPERATOR in any CONVERSIONS host under kernel/, update the matching row here. To re-verify the full inventory: grep -nE 'OPERATOR (INTO|AS)' kernel/{PrimitiveConversions,NumberConverter,FloatConverter,CharConverter,CharWidthConverter,TextConverter,NumberConversions,ComplexConversions,DecimalConversions}.ev (plus the compiler-assembled … INTO decimal128 set — sources in compiler/analyze/expr_diagnostics.py _ASSEMBLED_DECIMAL_SRC and the build_ir/lower_expr.py parts-helper map.)