Informative translation. The Russian text (русский оригинал) is normative.

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

MechanismWhenExample
asinfallible numeric/newtype/sum cast, compile-time, no runtime code42 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-deriveFahrenheit.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()
#coercedeclarative 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 → ToViaSemantics
i8 → i16/i32/i64/intassign-extend
u8 → u16/u32/u64/intaszero-extend
i8/u8 → f64asexact (any int64 representable as f64)
f32 → f64asexact

Narrowing (potential precision loss)

From → ToViaSemantics
i64 → i32/i16/i8aswraparound (modulo 2^N)
u64 → u32/u16/u8/byteaswraparound
f64 → f32asIEEE rounding (precision loss)
f64/f32 → iN/uNassaturation + 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 → ToViaFailure
str → ints.to_int(radix: int = 10)non-digit / overflow / (custom radix) invalid radix
str → i64/u64s.to_i64() / s.to_u64()no extra range-check (same width as the engine)
str → i8/i16/i32/u8/u16/u32s.to_i8() / s.to_i16() / s.to_i32() / s.to_u8() / s.to_u16() / s.to_u32()+ range-check into the target width
str → f64s.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 → ToViaFailure
str → bools.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 → ToVia
int/iN/uN → strn.to_str()
f64/f32 → strf.to_str()
bool → strb.to_str()
char → strc.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)

ViaSemantics
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().

ViaFailure
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()?.

ViaFailure
(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:

ViaFailure
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:

FormTypeSemantics
bs.to_str()-> Result[str, Utf8Error]checked decode; Utf8Error{byte_offset} points at the first invalid byte
bs.to_str_lossy()-> strinfallible, invalid sequences are replaced with a replacement character
unsafe { bs.to_str_unchecked() }-> strunchecked, the caller guarantees valid UTF-8
unsafe { bs.consume.into_str_unchecked() }-> stras 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 (D410as_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 → ToViaSemantics
bool → intastrue=1, false=0
bool → byte / bool → f64asthe same
bool → strb.to_str()"true" / "false"
int/byte/f64/etc → boolforbiddenuse 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).

ViaSemantics
n as MyNewtypeidentity (same C representation)
nt as intidentity
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 || cond2cond 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-consume method with a ro return (a borrow, no allocation);
  • finalize — a consume method 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:

  1. In Rust, conversion bounds are a crutch for the lack of overloading; in Nova overloading exists (D84), and From/Into as a generic bound was never used in live std, NOT ONCE.
  2. ? does not do auto-From error conversion (D325: one XError per domain, conversion is an explicit .map_err(...)).
  3. All real .into() calls in the tree played the role of “value to string” — that is the to_str() axis, not ownership transfer.
  4. The compiler magic of synthesis goes away (§3 compiler-conventions): the blanket identity From, auto-derive From→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 without try_ (example — s.to_int(), not s.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() / the to_* 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

LanguageWhere close to Nova
Rustas semantics, from/try_from naming, char::from_u32
Swiftstrict bool, no implicit coerce, Int(throwing:)
Kotlinstrict if-cond:bool, .toInt()/.toIntOrNull()
Go_ = strconv.ParseInt(s) ≈ try_from
Pythonstr(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 full SignedInts/UnsignedInts set
  • 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/TryInto protocols 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 on Option/Result — 2026-07-07
  • ⛔ The old sum syntax without the enum marker — D406 (2026-07-01)

References

Last updated August 21, 2026