Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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}");
}

Where there is no let to annotate, as names a type in place. It directs inference exactly as an annotation would, and it never converts anything:

import std::reactive::SignalCell;

fun main() {
	let ratio = 7 as f64 / 2.0;
	let sizes = SignalCell::new([] as List<usize>);
	print(i"{ratio} {sizes.get().len()}");
}

7 as f64 makes the literal an f64 the way let seven: f64 = 7 would, and the empty list is a List<usize> because the ascription says so. A value that already has another type is refused rather than converted: with n: i32, n as f64 is an error that points you at n.as_f64(). The method converts; the keyword constrains.

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.

Reserved words

These words are the language’s own; none of them can be a binding, a parameter, a field or a type name. Most you will meet as you go.

async    await    const    css      else     enum     export   external
false    for      fun      if       impl     import   in       is
let      macro    match    mod      mut      null     ret      struct
trait    true     type     use

A few more words mean something only in one position and are ordinary identifiers everywhere else: own (a parameter that takes ownership — see the memory model), jump (the prefix on break and continue), lazy, dyn, with, borrows, self, Self, void, context, sync, as, auto, and the attribute names in [...]. So let own = owner();, a field called with and a method called jump are all fine, beside the keyword readings in the same file. Even a reserved word names a MEMBER — a field type, a method match, read as event.type and called as shape.match() — because a member is reached through a receiver or a :: path, never on its own; it still cannot name a binding, a parameter or a free function. Spec §2.2 lists them, and §A.2 is the table.

Primitives

  • bool: true and false.
  • str: immutable strings.
  • Signed integers i8 i16 i32 i53 and unsigned u8 u16 u32 u53. A bare literal like 42 is i32. Other widths take a suffix: 0xFFu8, 60000u16, 9007199254740992i53.
  • usize, the index type: a position, a length, a count. It is its own type (convert with as_usize() / as_i32()), and subtracting one past zero is unspecified — reach for checked_sub when zero is possible. Numbers has the whole story.
  • Floats: f64 (a bare 2.5, or the f suffix) and f32 (2.5f32).
  • BigInt: arbitrary precision, with the n suffix (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 / 2 is 3, and -7 / 2 is -3. Float division works the way you expect — and which one you get is decided by the CONTEXT, because an unsuffixed literal has no type of its own: let ratio: f64 = 7 / 2; is 3.5, while the same expression with nothing to take a type from defaults to i32 and truncates.
  • There are no implicit conversions between numeric types. Mixing an i53 and an i32 in one expression is a compile error. Convert explicitly with the as_* methods, or suffix the literal.

A bare literal is not a mix: it takes the type of what it meets. If stamp is an i53, stamp + 1000 and 1000 + stamp are both i53 arithmetic, and so is let delay = 1000; stamp + delay — an unannotated binding of a literal takes its type from its first typed use, and is an i32 only when no use says otherwise.

Going deeper. The as_* conversions use Rust’s as semantics: floats truncate toward zero, and integers fold two’s-complement into the target width, so (-1).as_u8() is 255. 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.

Naming the positions

A tuple’s positions can carry labels, so a reader does not have to remember which slot is which. Write them in the type with : and in the value with =, the way a struct literal names its fields:

fun bounds(xs: List<i32>): (min: i32, max: i32) {
	mut low = xs[0];
	mut high = xs[0];
	for x in xs {
		x < low then low = x;
		x > high then high = x;
	}
	(min = low, max = high)
}

fun main() {
	let b = bounds([3, 1, 4, 1, 5]);
	print(i"{b.min}..{b.max}");
	let (max = top, min = bottom) = b;            // destructure by name
	print(top - bottom);
	let point: (x: f64, y: f64) = (y = 7, x = 5); // by name: (5, 7)
	print(point.x);
	print(point.0);                               // positions still work
}

A labelled value goes anywhere its unlabelled shape goes, and back: labels are names for positions, not part of the type. The one thing refused is a label that would land at a different position — (x: f64, y: f64) into (y: f64, x: f64) — because nobody can tell whether that was a reorder or a swap; the error offers both rewrites. Every slot is labelled or none, and (x = 5) is a one-slot tuple whose label makes it one. dbg prints the labels too: b = (min = 1, max = 5).

Reach for a struct instead once the shape has an identity: it is named in more than one signature, it needs methods or derives, it crosses the wire, or it has more than three fields.

Collections

List<T> is built in and has literal syntax. HashMap<K, V> and HashSet<T> come from std:

import std::hash_map::HashMap;
import std::hash_set::HashSet;

fun main() {
	mut items: List<i32> = [1, 2, 3];
	items.push(4);
	print(items.len());
	print(items[0]);

	mut scores: HashMap<str, i32> = HashMap::new();
	scores.insert("ada", 100);

	mut seen: HashSet<i32> = HashSet::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).

Arrays compare with ==, key a HashMap, print through Debug, and convert with to_list() and iter() at any length. Code of your own can work over every length too: const N names a length parameter, read in the body as a usize, and an impl head can bind one (_ when the body never names it):

trait Count {
	fun count(self): usize;
}

impl [type T; _] with Count {
	fun count(self): usize {
		self.len()
	}
}

fun total<const N>(values: [i32; N]): i32 {
	mut sum = 0;
	for value in values {
		sum = sum + value;
	}
	sum
}

fun main() {
	let pair: [str; 2] = ["a", "b"];
	print(pair.count());           // 2
	print(total([1, 2, 3]));       // 6 — N is 3 here
	print(pair == ["a", "b"]);     // true
}

Going deeper. HashMap and HashSet key 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 a List key works once it derives Hashable ([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.)