Informative translation; the Russian text is normative.
Russian original (normative): conversions.md
Nova — type conversions
A consolidated page of all conversion rules in one place. Full
D-decisions: D54 (as),
D52 (newtype/alias/sum),
D325 (the unified fallible std contract),
D410 (the to_str/bytes family),
D429 (#coerce — zero-cost implicit),
D430 (checked narrowing try_to_*).
From/Into/TryFrom/TryInto as protocols were retracted
2026-07-06 (D73/D77) —
details in the “from/try_from naming” section below.
The three mechanisms
| Mechanism | When | Example |
|---|---|---|
as | infallible numeric/newtype/sum cast, compile-time, no runtime code | 42 as f64, n as i16 |
.to_str() | universal conversion of a value to a string (bare-T blanket + specializations) | 42.to_str(), bs.to_str() |
T.from(v) / T.try_from(v) | a concrete static constructor — a naming convention, NOT a protocol/auto-derive | Fahrenheit.from(c), u32.try_from(port_str) |
consume @into_TARGET() | consuming ownership transfer (a concrete name on the source) | sb.into_str(), wb.into_bytes() |
#coerce | declarative implicit zero-cost conversion in a position with a known expected type (view/finalize) | w.write(s) — str implicitly .bytes() |
Important (2026-07-06 retraction, see below): .from(v) / .try_from(v) —
this is a PAIR of concrete static methods on a concrete type, not a generic
From[T]/TryFrom[T,E] protocol. The compiler does not synthesize the
reverse form (.into()/.try_into()) automatically — the programmer writes
exactly what they declared. There is no “universal” .into() in the language
anymore.
Numeric ↔ numeric
Widening (no precision loss)
| From → To | Via | Semantics |
|---|---|---|
i8 → i16/i32/i64/int | as | sign-extend |
u8 → u16/u32/u64/int | as | zero-extend |
i8/u8 → f64 | as | exact (any int64 representable as f64) |
f32 → f64 | as | exact |
Narrowing (potential precision loss)
| From → To | Via | Semantics |
|---|---|---|
i64 → i32/i16/i8 | as | wraparound (modulo 2^N) |
u64 → u32/u16/u8/byte | as | wraparound |
f64 → f32 | as | IEEE rounding (precision loss) |
f64/f32 → iN/uN | as | saturation + NaN→0 + ±∞→bounds |
Float→int saturation — defined behavior on any input (unlike C/C++ UB). Consistent with Rust 1.45+.
ro n = 1e20 as int // saturates to INT64_MAX
ro m = (-1.0) as u32 // saturates to 0
ro nan = 0.0 / 0.0 as i16 // 0
Checked narrowing — try_to_* (D430, 2026-07-20)
as between integer widths is always wraparound (silent loss of high bits).
If you need a check instead of silent wrap — a bounded blanket
@try_to_<T>() on any type from the Ints set, symmetric for all target
widths (i8/i16/i32/i64/int/u8/u16/u32/u64/uint):
ro ok = (100 as u32).try_to_u8() // Ok(100 as u8)
ro err = (300 as u32).try_to_u8() // Err(RangeError) — не влезло
ro neg = (-1 as i32).try_to_u8() // Err(RangeError) — отрицательное → unsigned
RangeError — a unit type (“didn’t fit”, no payload — the fact itself is
exhaustive). as remains the fast truncating cast, unchanged — try_to_*
does not replace it, but adds a checked alternative alongside.
Numeric ↔ str
str → numeric (parse, fallible) — a method ON THE SOURCE, not a static on the target
Canon (Plan 174.1, 2026-07-08, owner decision — superseded the early
static-constructor design T.parse(s)/T.try_from(s)): converting a string
to a number is a method on str (s.to_int()), not a static constructor
on the target type. Mirrors the s.to_str() family in reverse.
| From → To | Via | Failure |
|---|---|---|
str → int | s.to_int(radix: int = 10) | non-digit / overflow / (custom radix) invalid radix |
str → i64/u64 | s.to_i64() / s.to_u64() | no extra range-check (same width as the engine) |
str → i8/i16/i32/u8/u16/u32 | s.to_i8() / s.to_i16() / s.to_i32() / s.to_u8() / s.to_u16() / s.to_u32() | + range-check into the target width |
str → f64 | s.to_f64() | invalid number format |
fn parse_decimal(s str) -> Result[int, ParseIntError] =>
Ok(s.to_int()?) // radix 10 по умолчанию, Ok(42)
fn parse_hex(s str) -> Result[u32, ParseIntError] =>
Ok(s.to_u32(radix: 16)?) // hex-парсинг
fn parse_decimal_f64(s str) -> Result[f64, ParseFloatError] =>
Ok(s.to_f64()?) // Ok(3.14)
Errors — structural enums: type ParseIntError enum Empty | InvalidDigit | Overflow | InvalidRadix and type ParseFloatError enum Empty | Invalid
(std/runtime/string/parse.nv).
str → bool (parse, fallible)
Canon (Plan 232.1 T1, owner decision “add”, 2026-07-26):
s.to_bool() — strictly "true"/"false", lowercase-only (the Rust
str::parse::<bool> canon; no case-insensitive/"1"/"0"/"yes" aliases).
| From → To | Via | Failure |
|---|---|---|
str → bool | s.to_bool() | empty → Err(Empty); anything other than exactly "true"/"false" → Err(Invalid) |
fn parse_flag(s str) -> Result[bool, ParseBoolError] => s.to_bool()
assert("true".to_bool() == Ok(true))
assert("TRUE".to_bool().is_err()) // регистр не lowercase → Err(Invalid)
type ParseBoolError enum Empty | Invalid (std/runtime/string/parse.nv)
— the same two-variant pattern as ParseFloatError.
numeric → str (format, infallible) — a single entry point .to_str()
Canon (Plan 174.2, 2026-07-14): str.from(scalar) was retracted.
The only public entry point “value → string” is the bare-T blanket
fn[T] T @to_str() -> str => "${@}" (D410
amend), specialized by concrete overloads where a different
arity/semantics is needed (e.g. decode for []u8, see below).
| From → To | Via |
|---|---|
int/iN/uN → str | n.to_str() |
f64/f32 → str | f.to_str() |
bool → str | b.to_str() |
char → str | c.to_str() |
ro s = 42.to_str() // "42"
ro f = 3.14.to_str() // "3.14"
Interpolation ("${n}") lowers into the same path directly (for primitives —
into a Display helper at the C level, without re-calling .to_str() — no
recursion).
Char / Byte / []byte / str
char → str (UTF-8 encode)
| Via | Semantics |
|---|---|
c.to_str() | infallible UTF-8 encode (1-4 bytes) — a specialization of the to_str() blanket, byte-identical to the former str.from(char) |
str → char (single codepoint, fallible)
Canon (Plan 232.1 T1, owner decision “add”, 2026-07-26):
s.to_char() parses EXACTLY one Unicode codepoint (not a byte — "é".to_char()
succeeds, even though é is 2 UTF-8 bytes). A receiver form on the source,
the same principle as str @to_int().
| Via | Failure |
|---|---|
s.to_char() -> Result[char, ParseCharError] | empty → Err(Empty); >1 codepoint → Err(TooManyChars) |
assert("a".to_char() == Ok('a'))
assert("ab".to_char() == Err(TooManyChars)) // строгий отказ, не first-char silently
type ParseCharError enum Empty | TooManyChars (std/runtime/string/parse.nv)
— does NOT reuse CharFromError (see the “int → char” section below): that
domain is a codepoint outside the Unicode scalar value range/surrogates,
unreachable for str→char (the bytes of a str are already valid UTF-8, R-UTF8).
int → char (codepoint range-check, fallible)
Canon (owner, 2026-07-09): a receiver form on the source
((cp int).to_char()), not a static char.try_from(n) — the same chaining
principle as str @to_int(): (32 + off).to_char()?.
| Via | Failure |
|---|---|
(cp int).to_char() -> Result[char, CharFromError] | cp < 0 / cp > 0x10FFFF / surrogate [0xD800, 0xDFFF] |
fn describe(cp int) -> str =>
match cp.to_char() {
Ok(c) => "codepoint ${cp} = '${c}'"
Err(CharFromError) => "codepoint ${cp} вне диапазона"
}
char → byte (only if codepoint < 256, fallible)
This pair stayed a static form (did not migrate to a receiver) — the only
case where try_ remained on the target type:
| Via | Failure |
|---|---|
u8.try_from(c char) -> Result[u8, TryFromCharError] | codepoint > 0xFF (not Latin-1) |
Exception: 'A' as byte, 'A' as int, 'A' as u8 — allowed
for char literals (compile-time-known codepoint), see D54.
[]byte ↔ str — the unified to_str family (D325/174.1)
Canon: []u8 decode also goes through to_str() — a concrete
overload (arity/semantics of decode, not format) beats the bare-T blanket
by the “concrete beats generic” rule (D84).
str.try_from([]u8) / the separate str.from_bytes(...) — historical
names, withdrawn, only the forms below are current:
| Form | Type | Semantics |
|---|---|---|
bs.to_str() | -> Result[str, Utf8Error] | checked decode; Utf8Error{byte_offset} points at the first invalid byte |
bs.to_str_lossy() | -> str | infallible, invalid sequences are replaced with a replacement character |
unsafe { bs.to_str_unchecked() } | -> str | unchecked, the caller guarantees valid UTF-8 |
unsafe { bs.consume.into_str_unchecked() } | -> str | as above, but a consuming zero-copy move of the buffer |
fn decode(bytes []u8) -> str =>
match bytes.to_str() {
Ok(s) => s
Err(Utf8Error{byte_offset}) => "invalid UTF-8 at ${byte_offset}"
}
str → []byte (view, infallible, zero-copy) — a bare view, not a
transformation: s.bytes() -> ro []u8 (D410 —
as_bytes was renamed to bytes; this same name is the first declared
#coerce pair, see the “Zero-cost implicit conversions” section below).
Bool ↔ everything
| From → To | Via | Semantics |
|---|---|---|
bool → int | as | true=1, false=0 |
bool → byte / bool → f64 | as | the same |
bool → str | b.to_str() | "true" / "false" |
int/byte/f64/etc → bool | forbidden | use n != 0 |
ro s = true.to_str() // "true"
ro n = 5
ro ok = if n != 0 { true } else { false } // explicit != 0, не truthy-int
str → bool — see the TODO above (not found in std as of this revision).
Newtype ↔ underlying
A newtype (type X Y, without alias, D52) —
a type separate from the source; conversion is an explicit as (identity,
same C-repr). This differs from alias (type X alias Y) — there X and Y
are interchangeable without any cast (not a separate type).
| Via | Semantics |
|---|---|
n as MyNewtype | identity (same C representation) |
nt as int | identity |
type UserId int
ro u UserId = 42 as UserId
ro n int = u as int // 42
The implicit half and its boundary (D55 amend, 2026-08-21). At a position with an explicit expected type a newtype wraps itself — but for an UNTYPED CONSTANT only. A typed variable needs the explicit form; the boundary is Go’s, which D52 cites when recommending the form.
type Row int
ro a Row = 100 // ok -- a constant
ro b Row = 40 + 60 // ok -- constant arithmetic
ro n = 100
ro c Row = n // ERROR E7301 -- a typed variable
ro d Row = Row(n) // ok
ro e Row = n as Row // ok
Sums are untouched: SqlValue.I(x) is still inserted for a variable — there
the compiler DERIVES the only matching variant instead of inventing the author’s
claim. For the old softness on your own newtype, declare it as a
#coerce pair.
Sum-variant ↔ int (discriminant)
A sum type requires the enum marker after the name (D406,
2026-07-01 — the old syntax with a leading | without enum is revoked):
type ErrorCode enum NotFound = 404 | InternalError = 500
ro code = NotFound as int // 404
int → Sum via as is forbidden (a number may not hit any variant).
Use pattern matching.
Strict if cond:bool / while cond:bool
if cond, while cond, cond1 && cond2, cond1 || cond2 —
cond must be bool. Truthy-int (if a where a: int)
is forbidden.
ro n int = 5
if n { ... } // ❌ compile error
if n != 0 { ... } // ✅
Precedents: Rust, Swift, Kotlin — all require bool. Python/C/JS — truthy, a known bug-class.
Zero-cost implicit conversions — #coerce (D429, Plan 214/214.1)
Separately from the explicit mechanisms above — the declarative #coerce
attribute on a unary function declares an implicit conversion I → O,
inserted by the compiler in positions with a known expected type (call-arg,
ro/mut with an annotation, return, collection element) — WITHOUT an
explicit call on site:
The form is shown on a fresh example (str @bytes()/StringBuilder @into_str() —
pairs already declared in std; showing them again here would mean a
declaration conflict):
type Meters { ro raw f64 }
type Boxed consume { ro payload int }
#coerce
fn Meters @value() -> ro f64 => @raw // view — Meters → ro f64
#coerce
fn Boxed consume @unbox() -> int => @payload // finalize — потребляющий move
The call-site canon is a bare value, not an explicit call. The real std
pair str @bytes() -> ro []u8 kicks in automatically where the position
expects []u8 and a str is on hand:
import std.runtime.write_buffer.{WriteBuffer}
fn write_greeting(mut wb WriteBuffer, s str) -> () =>
wb.write_bytes(s) // s неявно .bytes() — не пишем это руками
Two “lanes”, both guaranteed zero-cost:
- view — a non-
consumemethod with aroreturn (a borrow, no allocation); - finalize — a
consumemethod with an owning return (a move; the receiver is discharged at the insertion point; use-after — an ordinary linearity compile error).
Rules (see D429 in full): exactly one declaration per pair (I, O);
one level (chains are NOT unfolded, coercions do not compose with each other
or with a single-wrapper — a conflict is an error, not a silent choice);
exact-match always beats coercion; a #coerce function must be effect-free.
The first declarations in std: str @bytes() -> ro []u8, StringBuilder consume @into_str() -> str, WriteBuffer consume @into_bytes() -> []u8. The mechanism
also works for generic patterns (Json[T] @data() -> T, bound removal in
Plan 214.1, 2026-07-24).
as does not engage #coerce (D429 R10) — as remains a closed,
documented-in-spec set of conversions; #coerce is an open user registry;
mixing the two would give a third door to one pair.
from/try_from naming — a convention, not a protocol (⛔ retraction 2026-07-06)
Until 2026-07-06 From[T]/Into[U]/TryFrom[T,E]/TryInto[U,E] were
generic protocols with auto-derivation of the reverse form (“4-way auto-derive”):
you wrote T.from(v) — the compiler synthesized v.into() itself. By the
owner’s decision all four protocols are abolished entirely:
- In Rust, conversion bounds are a crutch for the lack of overloading; in
Nova overloading exists (D84), and
From/Intoas a generic bound was never used in live std, NOT ONCE. ?does not do auto-Fromerror conversion (D325: oneXErrorper domain, conversion is an explicit.map_err(...)).- All real
.into()calls in the tree played the role of “value to string” — that is theto_str()axis, not ownership transfer. - The compiler magic of synthesis goes away (§3 compiler-conventions):
the blanket identity
From, auto-deriveFrom→Into, 4-step resolution.
What remains (three independent naming conventions, each an ordinary Nova function with no protocol behind it):
- (a)
.from(x)/.try_from(x)— concrete static methods, constructor-conversion by naming convention (not generic-bound-able).try_— only when there is an infallible sibling with the same name without the prefix (R3, D325); a lone fallible operation without a sibling — a bare name withouttry_(example —s.to_int(), nots.try_int()). - (b)
consume @into_TARGET()— a concrete name for a consuming ownership transfer (into_str,into_raw,into_bytes,into_str_unchecked). Not the general.into()operation — a generic version no longer exists; each name is declared on its own type explicitly. - (c)
.to_str()/ theto_*family — representation and transformation (see D410).
The compiler synthesizes NOTHING between these three — neither the reverse form nor a chain. If a type wants both directions — the programmer writes both explicitly, under different names.
type Celsius f64
type Fahrenheit f64
fn Fahrenheit.from(c Celsius) -> Self =>
Self((c as f64) * 9.0 / 5.0 + 32.0)
// Компилятор НЕ синтезирует c.into() — Into больше нет. Если нужна
// обратная форма — пишем отдельную функцию явно:
fn Celsius.from(f Fahrenheit) -> Self =>
Self(((f as f64) - 32.0) * 5.0 / 9.0)
The fallible version is the same, but the static returns a Result:
fn Port.try_from(n u16) -> Result[Self, str] =>
if n == 0 { Err("port 0 reserved") } else { Ok(Port(n)) }
ro p = Port.try_from(8080)?
Precedents by language
| Language | Where close to Nova |
|---|---|
| Rust | as semantics, from/try_from naming, char::from_u32 |
| Swift | strict bool, no implicit coerce, Int(throwing:) |
| Kotlin | strict if-cond:bool, .toInt()/.toIntOrNull() |
| Go | _ = strconv.ParseInt(s) ≈ try_from |
| Python | str(x)/int(s) ≈ from/try_from but not type-safe |
| C/C++ | (int)x without checks — UB-class, Nova does not repeat it |
Current status (updated after the 2026-07-26 revision)
Implemented and stable:
- ✅
as-cast (numeric/newtype/sum), narrowing wraparound, float→int saturation - ✅
str @to_*parse family (to_int/to_i64/to_u64/to_i8/to_i16/to_i32/to_u8/to_u16/to_u32/to_f64) — Plan 174.1, the fullSignedInts/UnsignedIntsset - ✅
str @to_bool()/str @to_char()— Plan 232.1 T1 (2026-07-26) - ✅ bare-
T @to_str()blanket + specializations (char,[]u8) — Plan 174.2 - ✅
[]u8 @to_str()/@to_str_lossy()/@to_str_unchecked()/@into_str_unchecked()— D325 - ✅
(cp int).to_char(),u8.try_from(c char)— D54/D77-naming - ✅ Checked narrowing
@try_to_i8()..@try_to_uint()— D430 (2026-07-20) - ✅
#coerce(view/finalize) — D429/214.1, three std pairs + generic patterns
Retracted (do not resurrect without a new sign-off):
- ⛔ The
From/Into/TryFrom/TryIntoprotocols and their auto-derive synthesis — 2026-07-06 - ⛔ The
str.from(scalar)static constructor — 2026-07-14 (replaced by.to_str()) - ⛔
str.try_from([]u8)/str.from_bytes(...)— replaced by the[]u8 @to_str()family - ⛔ The
.unwrap()/.unwrap_or()/.unwrap_or_else()methods onOption/Result— 2026-07-07 - ⛔ The old sum syntax without the
enummarker — D406 (2026-07-01)
References
- 03-syntax.md → D54 — the
asoperator - 03-syntax.md → D44 — numeric literals
- 03-syntax.md → D410 — the
to_str/bytes/into_*name family - 02-types.md → D52 — newtype/alias/sum declarations
- 02-types.md → D406 — the
enummarker of a sum type - 02-types.md → D429 —
#coerce(zero-cost implicit view/finalize) - 04-effects.md → D430 — checked narrowing
try_to_* - 04-effects.md → D325 — the unified fallible std contract (Result-everywhere)
- 08-runtime.md → D73 —
From/Into(⛔ protocol retracted 2026-07-06) - 08-runtime.md → D77 —
TryFrom/TryInto(⛔ protocol retracted 2026-07-06)