Values and types
Normative rules: spec §2 Lexical structure and §5 The type system.
Bindings
let declares a binding you won’t change. mut declares one you will.
Types are inferred, and you can annotate when you want to pin one down:
fun main() {
let name = "Ada";
mut count = 0;
count += 1;
let wide: i53 = 1000i53;
print(i"{name} {count} {wide}");
}
One thing to know up front: everything in Vilan is a value. Assigning a value to a new binding gives you a copy, not a second name for the same thing. If that sounds strange coming from JavaScript, start with Coming from JavaScript, then read the memory model when you’re ready for the full story.
Primitives
bool:trueandfalse.str: immutable strings.- Signed integers
i8 i16 i32 i53and unsignedu8 u16 u32 u53. A bare literal like42isi32. Other widths take a suffix:0xFFu8,60000u16,9007199254740992i53. - Floats:
f64(a bare2.5, or thefsuffix) andf32(2.5f32). BigInt: arbitrary precision, with thensuffix (7n).
Why i53 and not i64? Because Vilan runs on JavaScript, and JavaScript
numbers are 64-bit floats. Every integer up to ±2^53 is exact in a float.
Beyond that, precision silently disappears. Rather than offer an i64
that quietly isn’t one, Vilan names the type for what it delivers. If
you need more than 53 bits, use BigInt.
The compiler checks every literal against its type’s range, so an out-of-range literal is a compile error rather than a wrong value.
fun main() {
print(7 / 2); // 3 — integer division truncates
print((3.9).as_i32()); // 3 — conversions are explicit, via as_*
print((300).as_u8()); // 44 — narrowing folds into the target width
let byte = 0xFFu8;
print(byte.as_f64() + 0.25);
}
Two rules that differ from JS:
- Integer division truncates toward zero.
7 / 2is3, and-7 / 2is-3. Float division works the way you expect. - There are no implicit conversions between numeric types. Mixing an
i53and ani32in one expression is a compile error. Convert explicitly with theas_*methods, or suffix the literal.
That second rule has one trap. If stamp is an i53,
write stamp + 1000i53, not stamp + 1000. The bare 1000 is an i32,
and the mix won’t compile.
Going deeper. The
as_*conversions use Rust’sassemantics: floats truncate toward zero, and integers fold two’s-complement into the target width, so(-1).as_u8()is255. Conversions on literals fold at compile time. Arithmetic that overflows a type’s range is undefined behavior: the compiler checks literals, not runtime math. Details in spec §7.2a.
Strings and interpolation
"…" is a plain string, and Vilan does not interpret {} inside it.
To interpolate, prefix the string with i:
fun main() {
let name = "John";
print("Hello, {name}!"); // Hello, {name}! — a plain string
print(i"Hello, {name}!"); // Hello, John!
print(i"literal \{braces\}");
}
Concatenation is +, and an interpolated string is exactly that: a hole
is a + operand, so both spellings take the same right-hand values —
another str, a number, or a bool. Anything else has no string form
and is refused rather than printed as its runtime shape, which is what
"p=" + point would have done (p=1,2). Render it yourself:
import std::display::Display;
struct Point { x: i32, y: i32 }
impl Point with Display {
fun to_string(self): str {
i"({self.x}, {self.y})"
}
}
fun main() {
let point = Point { x = 1, y = 2 };
print("p=" + point.to_string());
print(i"p={point.to_string()}");
}
The order matters too, because the expression takes its type from its
left operand: "n=" + count concatenates, and count + "n=" is an
error rather than a silently i32-typed string.
The full method list (split, trim, contains, and so on) is in the strings reference.
For text that spans lines, """…""" is a raw multiline string:
nothing follows the opening delimiter on its line, the closing delimiter
sits alone on its line, and the whitespace before it is the indentation
stripped from every line, so the literal can sit at the indentation of
the code around it. Prefix it with i to get holes as well:
fun main() {
let name = "John";
print("""
raw: \n stays two characters, {braces} are literal
""");
print(i"""
Hello, {name}!
This line keeps its extra indentation.
Braces are written \{like this\}.
""");
}
The trimming is decided before the holes are: a line that opens with a
hole is indented like any other line. Inside i"""…""" exactly two
escapes exist (\{ and \}); everything else is raw, as in the plain
form.
The triple-quoted forms are the only ones that span lines. A "…" or
i"…" must close on the line it opens; a raw line break inside one is
an error that names both fixes ("""…""" for multi-line text, \n for
a single break):
let wrong = "first line
second line"; // error: a string cannot span lines
let short = "first line\nsecond line"; // one break: write it `\n`
That is a small restriction with a large payoff: a closing quote you
forget is reported on its own line, instead of the string running on to
the next " somewhere below and burying the real mistake.
Tuples
(a, b) groups a few values without declaring a struct. Take them
apart with a destructuring let, or reach one element by position:
fun main() {
let pair = (1, "one");
let (number, word) = pair;
print(i"{number} = {word}");
print(pair.1);
}
Tuple types are written the same way: (i32, str). Positional access
(pair.0, pair.1, chains like nested.0.1) types as that element;
through a mut binding you can also assign one (pair.0 = 5).
Building one out of another
A .. entry spreads a tuple you already have — its elements go into
the new tuple, so the type is the two concatenated. Spreads work in any
position, as many as you like, mixed with ordinary entries:
fun main() {
let pair = (1, 2);
let lead = (..pair, 3); // (i32, i32, i32)
let mid = (0, ..pair, 9); // (i32, i32, i32, i32)
let both = (..pair, ..pair); // (i32, i32, i32, i32)
print(lead.2);
print(mid.3);
print(both.3);
}
Note this is a shallow concatenation: ..outer contributes outer’s
elements, so an element that is itself a tuple stays one. And .. only
means this at the start of an entry — a..b after an expression is not
a spread, and vilan has no range operator for it to be confused with.
Collections
List<T> is built in and has literal syntax. Map<K, V> and Set<T>
come from std:
import std::map::Map;
import std::set::Set;
fun main() {
mut items: List<i32> = [1, 2, 3];
items.push(4);
print(items.len());
print(items[0]);
mut scores: Map<str, i32> = Map::new();
scores.insert("ada", 100);
mut seen: Set<i32> = Set::new();
seen.insert(7);
print(seen.contains(7));
}
An empty literal usually needs a type annotation, like
let xs: List<str> = [];. There is nothing inside it to infer the
element type from.
Collections are values like everything else, so let copy = items;
really copies. If you’re used to passing an array around and mutating it
from several places, that’s the habit to unlearn. The
memory model chapter shows what to do instead.
When the size is fixed and known, [T; n] is a fixed-length
array: the length is part of the type, so it can’t grow or shrink,
and [i32; 3] is a different type from [i32; 4]. Write [value; n]
to fill n slots, or a plain literal under a [T; n] annotation:
fun main() {
let buffer = [0; 4]; // [i32; 4] — four zeros
mut rgb: [u8; 3] = [255u8, 128u8, 0u8];
rgb[2] = 64u8; // indexed like a List
print(buffer[0]); // 0
print(rgb[2]); // 64
print(rgb.len()); // 3 — known at compile time
mut sum = 0;
for channel in rgb {
sum = sum + channel.as_i32();
}
print(sum); // 447
}
Reach for [T; n] over List<T> when the count never changes (a
color, a matrix row, a lookup table). Everything else (push, growing)
is what List is for. .len() on an array is free: the length lives
in the type, so the compiler folds it to the constant. And because the
length is known, let [r, g, b] = rgb; destructures one irrefutably,
with the element count checked against the type (works in parameter
position too).
Going deeper.
MapandSetkey by value. Scalar keys (i32,str) and backed enums (enum Align { Start = "flex-start" }— the enum is the string) work directly; a struct, an unbacked enum, or aListkey works once it derivesHashable([derive(Hashable)]), so two equal values are the same key. See collections.
Where’s null?
There isn’t one. A value that might be absent is an Option<T>, and the
compiler makes you handle the None case before you can use the value.
This is one of the big shifts from JavaScript, and
Control flow shows how natural it becomes. (null
technically exists at the host boundary, for externs that can return JS
null, but ordinary Vilan code never sees it.)