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
-
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.
-
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). -
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 anIF/WHILE; in the success branch read the value with$=(the alias$RETURNEDis equivalent), and in the failure branch read the failureSTATUSwith$!and its numeric error code with$#.$#is not always meaningful: it is0whenever the failure carried no numeric code — which is the case for every conversion operator here (they raiseFAILURE("message")with a message but no code), so rely on$!for conversion failures and treat$#as0. The code is only non-zero forSTATUSvalues from sources that set one (e.g. an errno-bearingSETS_ERRNOFFI 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 |
- You never name the host. Write
x INTO Torx 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 ownCONVERSIONShost 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 Stringis invalid — number→text loses nothing, so it isint32 INTO String. Reaching forASwhere the conversion is lossless (orINTOwhere 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 |
int16 / int32 / int64 / int128 |
INTO |
✓total | widening (Bug #233) |
int16 |
int32 / int64 / int128 |
INTO |
✓total | widening (Bug #233) |
int32 |
int64 / int128 |
INTO |
✓total | widening |
int64 |
int128 |
INTO |
✓total | widening |
uint8 |
uint16 / uint32 / uint64 / uint128 |
INTO |
✓total | widening (Bug #233) |
uint16 |
uint32 / uint64 / uint128 |
INTO |
✓total | widening (Bug #233) |
uint32 |
uint64 / uint128 |
INTO |
✓total | widening (Bug #233) |
uint64 |
uint128 |
INTO |
✓total | widening (Bug #233) |
uint32 |
int64 |
INTO |
✓total | cross-sign widen — every uint32 fits the non-negative half of int64 (2026-08-25) |
uint64 |
int128 |
INTO |
✓total | cross-sign widen — every uint64 fits the non-negative half of int128 (#38) |
int8…int128 / uint8…uint128 |
float32 / float64 |
INTO |
✓total | int→float widening (Bug #233) |
float32 |
float64 |
INTO |
✓total | widening (Bug #234) |
int64 |
int32 |
AS |
✓fallible | range-checked |
int128 |
int64 |
AS |
✓fallible | range-checked |
uint128 |
uint64 |
AS |
✓fallible | range-checked |
uint64 |
uint32 |
AS |
✓fallible | range-checked |
float32 / float64 |
int32 |
AS |
✓fallible | range-checked; NaN/∞ → FAILURE |
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 now also a registered totalOPERATOR INTO(Bug #233/#234). int→float is registered even where wide (int64 INTO float32), following C++'s value conversion exactly as the pre-existingint32 INTO float32does (the widening model treats int→float as total, I.J.iii.c). What is not registered: sign-mixing (int32 INTO uint32) and narrowing — an unregisteredINTO/ASpair is now a clean E2086 at analysis time (Bug #233 hardening; it no longer slips to a clang reject).Two cross-sign pairs ARE registered, and they are not exceptions to that rule — they are total.
uint32 INTO int64anduint64 INTO int128go from an unsigned type to a signed one at least one width wider, so every source value lands in the target's non-negative half and nothing is lost. They are still explicit:widens_to(compiler/primitives.py) treats all sign-mixing as non-implicit, so anint64parameter does not silently accept auint32— you write theINTO. The general sign-mix ban above is about pairs where a value genuinely can be lost (uint32 INTO int32loses the top half,int32 INTO uint32loses the negatives). The workhorse down-narrowings are registered:int64 AS int32,int128 AS int64,uint128 AS uint64,uint64 AS uint32(the 128→64 / 64→32 range-checked pairs). Other integer narrowings not in this table (e.g.int32 AS int16) are not yet provided as operators — see §6. For a deliberately unchecked truncation, useNumericUtilities(§5), notAS.
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 Stringjust to print:->format(...)and theStdioprint methods auto-convert any argument (I.F.vi). Reach for explicitINTO Stringwhen you need theStringas 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 |
INTOhere assumes UTF-8 (the canonical encoding). Other encodings are bulk operations, not single-pair operators — seeUTFCodec(§5). For opaque-byte display (hex), useHexCodec, 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(c.ii), gh #285)
char ± integeris an OFFSET yielding the char's own type — range-checked at runtime against that width's code-unit range — andchar - charis a DISTANCE yieldingint32. 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) |
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.
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 warn (
W10095). Writing one char type where another is expected without an explicitAS/INTO(e.g.char16 u = someChar8, or returning achar8from aRETURNS char16) firesW10095— a code unit of one encoding is not the same unit in another. Every char-type pair warns EXCEPTchar16→char32(a non-surrogate UTF-16 unit is the same UTF-32 scalar). It is the Envzn-native parallel to clang's-Wcharacter-conversion; both fire by default, and--no-clang-char-warningssilences clang's (not Envzn's). The warning is exempt inside anOPERATOR AS/INTObody (that is where the conversion is defined).
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/complexareEquatableonly (notHashable/Comparable): fine as values, never aDictionary/Setkey orSortedListelement. There is nonumber AS String(text rendering is lossless ⇒INTO), and no lossy-total cell here either — everyASabove 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). (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 |
decimal128stays "clean": adecimal128operand in arithmetic/comparison combines only with anotherdecimal128or a ≤64-bit integer (which widens exactly). Afloat/number/complex/int128counterpart is a compile error (E2121) — convert it explicitly so precision is never silently lost.
decimal128ISHashable+Comparable(cohort-normalized), so it is a validDictionary/Setkey andSortedListelement — the contrast withnumber/complexabove. There is nodecimal128 AS String(text is lossless ⇒INTO), and theByteBuffer⇄decimal128pair is the IEEE BID wire codec (a single canonical encoding, henceAS/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/.
-
PrimitiveConversions.ev— the numeric/char widen+narrow matrix (§2a, §2e). Orders early in the kernel (depends only on primitives +STATUS), so every kernel class can use it.int32 INTO char32is deliberately total (Bug #189):char32is 32-bit storage, so no bits are lost; Unicode-scalar validity is not this conversion's concern. -
NumberConverter.ev— integer/boolean→String(total) andString→ integer/booleanparse (fallible) (§2b, §2c). Orders early (depends only onString/DynamicString) so foundational classes can render withx INTO String. 128-bit values render via hand-rolled decimal (no libc path). -
FloatConverter.ev—float32/float64→String(total), via the Ryu/FloatFormatengine: shortest decimal that round-trips. (Float parse lives inNumberConverter.) -
CharConverter.ev—char8/char16/char32→Stringandchar32[]→String(all total). Thechar32[]operator backs the compiler's error-message print auto-convert. -
CharWidthConverter.ev— the fallible char-width narrowings only (§2f). Delegates the actual encode toUTFCodecand succeeds only when the result is exactly one code unit. -
TextConverter.ev— theString↔ByteBufferhard line (§2d), UTF-8.String INTO ByteBufferis total (every code point encodes);ByteBuffer INTO Stringis fallible (arbitrary bytes may not be valid UTF-8). -
NumberConversions.ev— thenumbernarrow-out matrix (§2g):number AS int*/uint*andnumber AS float32(lossy, fallible — range-checked, float32 overflow detected via the finiteness identity(f - f) == 0since a float32-max literal needs V2 scientific notation), plus the totalnumber INTO float64and the tag-awarenumber INTO String. Widening intonumberis implicit (no operator). -
ComplexConversions.ev—complex INTO String(total, canonical(a + bim), sign-aware on the imaginary part) andcomplex INTO float64(lossless-fallible, succeeds iff the imaginary part is zero).complex AS <real>is intentionally absent — it is a compile error (E2106, Re-vs-magnitude ambiguity). Real →complexwidening is implicit. -
DecimalConversions.ev— thedecimal128OUTBOUND matrix (§2h):decimal128 INTO String(total, exact decimal text),decimal128 INTO ByteBuffer(total, IEEE BID encode), and thedecimal128 AS int*/uint*/float*narrowings (lossy, fallible). The INBOUND… INTO decimal128conversions are NOT operators here — the kernel can't construct the value-class primitive, so the compiler assembles each from a parts-helper (String→DecimalText.parseDecimalParts,float*→FloatToDecimal,number/complex→NumberToDecimal,ByteBuffer→DecimalCodec.bidToParts,int*→an exact-widenDecimal128(coeff, 0)). -
DecimalCodec.ev— the INBOUND half of the IEEE BID wire codec:bidToParts(ByteBuffer)→(DecimalParts | STATUS), decoding 16 big-endian BID bytes (simple 113-bit form) and rejecting a wrong length, the non-canonical "11" combination form, an out-of-range exponent, or a >34-digit coefficient. The compiler'sByteBuffer INTO decimal128lowering calls it and builds the value.
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
- The
C.*FFI boundary conversion is NOT anAS/INTOoperator (shipped 2026-08-06, Constitution I.D.x).C.size_t·C.ssize_t·C.long·C.unsignedLongname a C typedef at aFOREIGN/FOREIGN BINDdeclaration and lower verbatim to that spelling. You never write a conversion for them and you never declare one: they are legal only in a foreign declaration (else E2143), so noC.*value exists in Envzn code to convert. The caller passes and receives an ordinary Envzn integer (uint64,int64) and the compiler converts at the boundary, in both directions, for a by-value argument and for theLOAD C.<type>in/out out-param alike. Inbound is CHECKED: a value outside the platform's range for that C type raises theMathErrorof I.F.ii(a.iii) rather than truncating — a silently narrowedsize_tis precisely the wrapped-size defect the memory-safety floor forbids. Outbound is widening on every supported target and needs no check. Why the family exists at all: a fixed-width primitive cannot express a pointer to a C typedef —size_tisunsigned longwhereuint64_tisunsigned long longon Darwin, and the two swap on Linux, so the wrong spelling compiles on one target and fails on the other. By value the old advice merely worked by implicit conversion; by pointer it could not work at all. W2146 warns when aLOADuses a 64-bit fixed-width type instead. - No
String AS Tand no lossy-total cell. Parse isString INTO T(lossless-fallible). EveryASis fallible. - Not every integer narrowing has an operator. Today the checked
ASinteger narrowings are those in §2a (int64 AS int32,float* AS int32) plus the char-target narrowings. Other pairs (e.g.int32 AS int16,int64 AS uint32) are not yet provided — if you need a deliberate unchecked narrowing,NumericUtilitiescovers it; a new checked pair is a one-operator addition toPrimitiveConversions.ev. - Class ↔ class / custom types. A module may declare its own
CONVERSIONShost withOPERATOR AS/INTOfor its own types (IV.A.viii); the samex INTO T/x AS Tsurface and$=/$RETURNED/$!/$#consumption apply. - No
AS/INTOon arrays or N-D arrays. A value-arrayT[]and a dense N-D arrayT[,]/T[m,n]/T[3,4](#31) are containers, not convertible scalars — there is noT[] AS …/float64[,] INTO …. Convert the elements (loop +x INTO U), or reach for the relevant codec (§5). N-D arithmetic / reshaping is the numeric-module (AdvancedMath) surface, not a conversion.
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.)