Guides · 02

Values and bindings

Everything in a Vyi program starts as a value with a name. This guide covers how names are bound (con, mut, type), what the primitive types are, how literals work, and how values move between types. The rules are few and strict: bindings are immutable unless you say otherwise, every binding takes a value, and no numeric type converts to another without you writing the conversion down.

con and mut

con declares an immutable binding; mut declares a mutable one. The type is inferred from the value, or stated after a colon:

vyi
con width = 42            // immutable, type inferred (i32)
con pi: f64 = 3.14159     // immutable, type stated

mut total = 0             // mutable
total = total + width

con is the default choice — reach for mut only when you actually reassign. Most bindings never change, and saying so lets the reader (and the compiler) rule out mutation at a glance. Assigning to a con is an error:

vyi-error
con limit = 10
limit = 20        // error: cannot reassign immutable binding "limit"

Every binding takes a value. There is no "declare now, initialise later" and no implicit zero — a binding without = value doesn't even parse:

vyi-error
mut count: i32    // error: a binding always takes a value

This is deliberate: a name never exists in a half-made state, so there is no uninitialised-variable class of bug to have. If you don't have the real value yet, write the placeholder explicitly (mut count = 0) — the 0 is visible in the source.

A mut binding keeps its type for life — you can give it new values, not a new type.

Shadowing

Declaring a name that already exists creates a new binding that shadows the old one. The right-hand side still sees the old binding, which makes shadowing handy for refining a value in steps without reaching for mut:

vyi
con x = 5
con x = x * 2      // new binding; the right-hand side sees the old one
if true {
    con x = 100    // inner scope shadows the outer x
}
con y = x          // 10 — the inner shadow ended with its block

Shadowing is not mutation: each con x is a separate immutable value, and when a scope ends, its shadows go with it.

type bindings

type binds a name in type position. The everyday use is an alias, and an alias is fully interchangeable with the type it names:

vyi
import { stdout } from "@io"

type Meters = i32
type Point  = struct { x: i32, y: i32 }

fn addMeters(a: Meters, b: Meters) Meters {
    return a + b
}

fn main() !u8 {
    con d: Meters = 30
    con p = Point{ x: 10, y: 2 }
    stdout.writeInt(i64(addMeters(d, p.x) + p.y))   // prints 42
    stdout.write("\n")
    return Ok(0)
}

Meters is i32 — no wrapping, no conversion.

type is more general than aliasing, though: it hoists any known value (a value the compiler computes at compile time) into type position. In Vyi a plain value used as a type means "the type inhabited by exactly that value":

vyi
import { stdout } from "@io"

type Two  = 2
type Mode = "fast" | "slow"

fn main() !u8 {
    con t: Two = 2            // only the value 2 inhabits Two
    con m: Mode = "fast"
    stdout.writeInt(i64(t))   // prints 2
    stdout.write(" ")
    stdout.write(m)           // prints fast
    stdout.write("\n")
    return Ok(0)
}

And since a known value can be computed, type is also the entry point to generics — a generic type is just a function that returns a type:

vyi
import { stdout } from "@io"

fn Box(known T: type) type {
    return struct { value: T }
}

type IntBox = Box(i32)     // a known value computed by calling a function

fn main() !u8 {
    con b = IntBox{ value: 7 }
    stdout.writeInt(i64(b.value))   // prints 7
    stdout.write("\n")
    return Ok(0)
}

Guide 11 — Known values and generics goes deep on this.

Primitive types

TypeWhat it is
i8 i16 i32 i64signed integers, by exact width
u8 u16 u32 u64unsigned integers, by exact width
f32 f64IEEE floats
booltrue / false
charone Unicode codepoint (32-bit)
typethe type whose values are types
anyadmits a value of any type

There is no int. Every integer states its width, and i32 is the conventional default in user code (it's what a bare integer literal becomes). The widths are kept honest by a strict rule: no numeric type mixes with another implicitly — not even in the "safe" widening direction:

vyi-error
con a: i32 = 1
con b: i64 = 2
con c = a + b        // error: i32 and i64 do not mix implicitly
vyi
con a: i32 = 1
con b: i64 = 2
con c = i64(a) + b   // widen explicitly

The payoff is that every width change in a program is written in the source — there is no table of promotion rules to memorise and no silent change of range or signedness.

char is a full 32-bit codepoint, and deliberately not a u8: one character of text and one byte of memory are different things (a char may be several bytes once UTF-8-encoded). Converting between them is explicit:

vyi
con c = 'A'                // char, the codepoint U+0041
con emoji = '😀'           // one char, even though it's 4 bytes in UTF-8
con n = u32(c)             // 65 — casts are explicit
con back = char(n)

type rounds out the list because types are ordinary values in Vyi:

vyi
con T: type = i32       // a type is itself a value of type `type`
con x: T = 5            // and usable wherever a type is expected

The unit type ()

A function that returns nothing returns () — the empty tuple. There is no void keyword; instead of a special "no value" marker, Vyi uses a perfectly ordinary value that happens to carry zero bits:

vyi
fn greetNobody() {         // omitted return type means ()
    return
}

fn unit() () {             // or write it out
    return ()
}

fn main() !u8 {
    con u = unit()         // a real value; it just carries nothing
    return Ok(0)
}

Because () is a real type, it composes where a "nothing" needs to slot into a larger shape — for example ()!T is a result whose error carries no data (guide 07).

Literals

Integers are written in decimal, hex (0x), binary (0b), or octal (0o), with _ allowed anywhere as a digit separator:

vyi
con dec = 1_000_000        // underscores group digits
con hex = 0xFF             // 255
con bin = 0b1010           // 10
con oct = 0o17             // 15

An integer literal has no fixed type of its own — it becomes whatever concrete type the surrounding code asks for, and defaults to i32 when nothing asks:

vyi
fn takesI64(x: i64) i64 { return x }

fn main() !u8 {
    con a = 7              // no context: defaults to i32
    con b: i64 = 7         // annotation: the literal is i64
    con c = takesI64(7)    // parameter: the literal is i64
    con d = u8(7)          // cast target: the literal is u8
    return Ok(0)
}

A literal that doesn't fit its target is a compile error, not a silent wrap:

vyi-error
con b: u8 = 300    // error: integer literal 300 overflows u8

Floats are written with a decimal point and default to f64; annotate (or cast) to get f32. Like the integers, the two float widths never mix implicitly:

vyi
con d = 0.5            // no context: defaults to f64
con a: f32 = 1.5       // annotation makes the literal f32
con c = a * f32(2)     // f32 arithmetic; f32 and f64 never mix implicitly

Bools are true and false, and only bool works with !, &&, ||, and if — integers are not truthy.

Chars use single quotes and hold one codepoint. Escapes cover the usual suspects (\n, \t, \\, \'), plus \xHH for a codepoint by hex byte and \u{…} for any codepoint:

vyi
con nl  = '\n'
con hex = '\x41'         // 'A' by codepoint
con uni = '\u{1F600}'    // '😀'

Strings use double quotes. A string literal is UTF-8 bytes baked into the binary, and it flows into [*]u8 — a pointer-plus-length view of bytes. That "of bytes" matters: indexing gives you u8s and .len counts bytes, so multi-byte characters occupy more than one index. (In a string, \u{…} expands to the codepoint's UTF-8 bytes.)

vyi
fn shout(msg: [*]u8) {}

fn main() !u8 {
    con s = "héllo\n"        // UTF-8 bytes, baked into the binary
    con first: u8 = s[0]     // indexing yields bytes (u8), not chars
    con n = s.len            // byte length — 7 here, not 6
    shout(s)                 // a string flows into [*]u8
    return Ok(0)
}

A maybe-absent T is the optional type ?T, and its absent case is None — an ordinary enum variant, not a keyword (guide 07).

Casts and truncation

A cast is spelled like a call: TargetType(x). Casts convert between the numeric types (and char), and they do exactly what they say at the cast site — a narrowing integer cast truncates to the target width immediately, and a float-to-int cast drops the fraction:

vyi
con big: i32 = 300
con b = u8(big)        // 300 mod 256 = 44 — truncated at the cast
con f = f64(3)         // int → float
con n = i32(2.9)       // float → int drops the fraction: 2

The split is deliberate: a literal that overflows its target is a compile error (the compiler can see it), but a cast of a runtime value truncates — you wrote the cast, so the narrowing is on purpose. And since nothing converts implicitly, a cast is the only place truncation can ever happen:

vyi-error
con small: u8 = 7
con wide: i32 = small    // error: even widening needs an explicit cast

See also: reference/types, reference/lexical.