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:
con width = 42 // immutable, type inferred (i32)
con pi: f64 = 3.14159 // immutable, type stated
mut total = 0 // mutable
total = total + widthcon 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:
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:
mut count: i32 // error: a binding always takes a valueThis 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:
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 blockShadowing 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:
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":
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:
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
| Type | What it is |
|---|---|
i8 i16 i32 i64 | signed integers, by exact width |
u8 u16 u32 u64 | unsigned integers, by exact width |
f32 f64 | IEEE floats |
bool | true / false |
char | one Unicode codepoint (32-bit) |
type | the type whose values are types |
any | admits 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:
con a: i32 = 1
con b: i64 = 2
con c = a + b // error: i32 and i64 do not mix implicitlycon a: i32 = 1
con b: i64 = 2
con c = i64(a) + b // widen explicitlyThe 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:
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:
con T: type = i32 // a type is itself a value of type `type`
con x: T = 5 // and usable wherever a type is expectedThe 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:
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:
con dec = 1_000_000 // underscores group digits
con hex = 0xFF // 255
con bin = 0b1010 // 10
con oct = 0o17 // 15An 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:
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:
con b: u8 = 300 // error: integer literal 300 overflows u8Floats 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:
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 implicitlyBools 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:
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.)
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:
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: 2The 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:
con small: u8 = 7
con wide: i32 = small // error: even widening needs an explicit castSee also: reference/types, reference/lexical.