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

The vilan documentation

How to use the vilan language, its standard library, and the frameworks built on top of it. If you’re wondering where something lives: this book is about using vilan. Design history and rationale live in vilan/proposal/.

Parts

  • Tour — the language itself, taught informally. Start with Coming from JavaScript if that’s your background, then read in order. Come back any time you need a syntax reminder.
  • Guides — the frameworks, task by task: reactive state, building UI, styling, routing, talking to a server. Each guide reads front to back and links into the reference for exact signatures.
  • std reference — the standard library, signatures first. Go here to answer “what were the parameters again?”.
  • Specification — the formal definition: grammar, type rules, the memory model, execution. This is the advanced tier. The tour teaches; the spec defines; where they disagree, the spec wins.
  • Appendix — the error index (“you saw this message, go here”), the gotchas checklist, and the glossary.

Reading this as a website

The book renders with mdBook: mdbook serve vilan/docs gives a local live-reloading site with search and a sidebar (cargo install mdbook once). Pushes to main publish it to GitHub Pages automatically. Everything also reads fine as plain files — the site adds navigation, it doesn’t replace the markdown.

Conventions

  • Examples are complete programs unless explicitly labelled a fragment — copy, vilan build, run.
  • Every example compiles as part of the test suite (cargo test --test docs): a fenced ```vilan block must compile for the node target, ```vilan,browser for the browser target, ```vilan,norun compiles but needs external services to actually run, and ```vilan,fragment is prose-only (used sparingly, always labelled).
  • Maintenance rule: a change to std, a framework, or the language updates the affected docs page in the same commit.

Contents

Tour

ChapterCovers
Coming from JavaScriptthe three big shifts, a JS→vilan phrasebook
Hello vilanthe CLI, vilan.toml, packages & workspaces, imports
Values & typesbindings, primitives & numeric widths, strings & interpolation, tuples, collections
Functions & closuresfun, closure types, named-fn coercion, async closures and their seams, context clauses
Data & traitsstructs, enums, impl, generics & bounds, traits, derives
Control flowmatch/is, loops, ret, Option/Result idioms, ! and ?.
The memory modelvalue semantics, views, mut/own, Shared, Arena/Handle
Asyncimplicit await, async expr spawn, promises, timers
Resourcesresource, moves & loans, Drop, drop(x), Option.take, Database, OwnedNursery
Macros & constconst evaluation, derive macros, macro { … } blocks
Platformsstd layers, full-stack packages, externs, assets

Guides

ChapterCovers
Reactive statesignals, derived state, effects, ownership & disposal, turns, optimistic, Draft
Building UIview chaining, binds, events, lists, conditionals, mounting
Stylingconst typed styles, lengths/colors, dynamic values
Routingenum routes, parse/href, link, swap, navigation
Services & RPC[service]/[rpc]/[expose], Wire, mirrors, reconnection, the server side
Persistence & the serverstd::db (SQLite), the http server, files, the process
A full-stack walkthroughthe Notes app end to end — every layer meeting, quoted from a real, tested example

std reference

PageModules
collectionsList, Map, Set, Range, Iterator
option & resultOption, Result and their method sets
stringsstr, Display, Debug, Into
numbersthe sized numerics, math, random
traitscompare, default, the operator traits, Try/Lift
cellsShared, Arena/Handle
timeInstant, Duration, timers
encodingjson, wire, binary, bytes, base64
netfetch, ws
reactivethe full std::reactive API
stylethe full std::style API
rpcstd::rpc — transports, clients, frames
browserstd::dom, std::ui, std::router, std::storage
processdb, http, fs, process, rpc_server
miscio, promise, context, crypto, jwt, asset

Specification

ChapterDefines
§1 Introductionconformance, notation, processing phases
§2 Lexical structuretokens, keywords, literals, operators
§3 Grammarthe full EBNF, precedence, patterns, types
§4 Names & modulesscopes, resolution, imports, namespaces
§5 The type systemtypes, generics & inference, traits, coercions, !/?.
§6 The memory modelthe four rules, views, projections, the await rule
§7 Execution & asyncentrypoint, evaluation order, the async model
§8 Contextsambient values: run/get, coverage, injected closures
§9 Const evaluationcompile-time values, the const environment, assets
§10 Macrosattribute/derive/block macros, macro_std, splicing
§11 Platform model & manifestslayers, coloring, fences, vilan.toml
§A Appendixprecedence & keyword tables, lang items

Coming from JavaScript

vilan compiles to JavaScript and runs on node and in the browser. If you know JS or TypeScript, most of the syntax will feel familiar. A handful of ideas are genuinely different, though. This page is the five-minute orientation. Each topic links to the chapter that teaches it properly.

The three big shifts

1. Values are copied, not shared.

In JavaScript, objects and arrays are passed around by reference. Two variables can point at the same object, and a change through one is visible through the other. In vilan, assigning or passing a value gives the receiver its own copy. Nothing is shared unless you ask for sharing explicitly.

import std::print;

struct Point { x: i32, y: i32 }

fun main() {
	mut a = Point { x = 1, y = 2 };
	mut b = a;      // b is a copy
	b.x = 99;
	print(a.x);     // 1 — a is untouched
}

This removes a whole class of spooky-action bugs. When you do want two places to see the same state, you say so with a tool built for it. The memory model chapter walks through all of them.

2. There is no null or undefined.

Absence is a real type called Option. A value that might be missing is Option<T>, and the compiler makes you handle the missing case before you can touch the value. The same idea covers errors: a function that can fail returns Result, not a thrown exception. Control flow shows the idioms, and they are shorter than they sound.

3. await is implicit.

Calling an async function just gives you the value. You don’t write await, you don’t see Promise<T> in return types, and a function becomes async automatically when it calls something async. You only reach for the explicit keywords when you don’t want to wait. The async chapter explains the whole model.

A quick phrasebook

You write in JS/TSYou write in vilan
function f(x) { … }fun f(x: i32): i32 { … }
const x = … / let x = …let x = … / mut x = …
x === yx == y (type-checked equality)
`Hello ${name}`i"Hello {name}"
[1, 2, 3][1, 2, 3] (a List<i32>)
{ x: 1, y: 2 }a struct value: Point { x = 1, y = 2 }
(x, y) => x + y|x, y| x + y
switch / discriminated unionsenum + match
x?.fieldx?.field (on Option, and it’s type-checked)
null / undefinedOption<T> (Some(v) / None)
throw / try / catchResult<T, E> + ! to propagate
await fetchThing()fetch_thing() (awaiting is implicit)
class with methodsstruct + impl block
interfaces / duck typingtraits, checked at compile time

Things that look the same and mostly are

Closures (|x| x * 2 instead of x => x * 2), string concatenation with +, if/else, comments with //, modules as files. The standard library is small and imported explicitly, so files start with a few import lines, like ES modules.

Things to unlearn

  • Don’t mutate through a shared reference. list.push(x) works on your list. If you were relying on “everyone sees the update”, you want a Signal (reactive state) or a Shared cell, both explicit.
  • Don’t check for null. Match on the Option.
  • Don’t wrap things in try/catch. Errors are values. Look at the Result and decide.
  • Don’t sprinkle await. It’s already there.

Where to go next

Read Hello vilan to get a program running, then follow the tour in order. When you start building UI or talking to a server, the guides take over. And whenever something surprises you, check the gotchas page — if it surprised us first, it is written down there.

Hello vilan

vilan compiles to JavaScript. Your programs run on node (or deno, or bun) and in the browser. One tool, the vilan binary, does everything: build, run, check, format, test.

A first program

import std::print;

fun main() {
	print("hello");
}

Save that as hello.vl and run it:

vilan run hello.vl      # build + run
vilan build hello.vl    # just compile — writes hello.js
vilan check hello.vl    # just type-check — writes nothing

fun main is the entrypoint. It runs automatically, so there is no main() call at the bottom of the file.

Two small things you’ll notice compared to JS. First, the standard library is imported explicitly — even print. Your files will start with a few import lines, just like ES modules. Second, indentation is tabs by convention, and vilan fmt will format files for you.

The CLI

CommandWhat it does
vilan build [path]compile to <file>.js (no path: use the nearest vilan.toml)
vilan check [path]type-check and report problems, write nothing
vilan run [path] [args…]build and run; extra args reach process::args()
vilan fmt [paths…]format source files in place (--check to verify only)
vilan test [path]run *_test.vl files (a failed assert panics = test fails)

Flags you’ll actually use: --watch rebuilds (or re-runs, or re-checks) whenever a source file changes. --platform browser builds for the browser instead of node (--target also works). --stdout prints the JS instead of writing a file.

Projects: vilan.toml

A single .vl file is fine for experiments. Real projects get a folder with a vilan.toml manifest. An application looks like this:

[package]
name = "hello"
target = "browser"          # node (default) | deno | bun | browser

[package.dependencies]
common = { path = "../common" }

A library is the same idea, but it has no entrypoint. It exists to be imported by other packages:

[library]
name = "common"

A dependency can also live in a git repository, pinned to one exact point — a tag or a commit, never a branch:

[package.dependencies]
shapes = { git = "https://github.com/someone/shapes", tag = "v1.2.0" }
pinned = { git = "https://github.com/someone/other", rev = "9f2c1ab" }

The repository must be a [library] with its vilan.toml at the root. vilan build (or check, or run) fetches it once into ~/.vilan/git-deps/ and reuses that checkout forever after — so builds work offline, and a moved tag cannot change what you already built. Nothing else fetches: the editor uses the cache when it’s there and never reaches the network.

A workspace groups several packages so they build together with one vilan build . at the root:

[project]
packages = ["common", "client", "server"]

[project.dependencies]
shapes = { git = "https://github.com/someone/shapes", tag = "v1.2.0" }

Dependencies declared at the workspace root are there for the members to share, so the version lives in one file. A member takes one by name:

[package.dependencies]
shapes = { project = true }

That is the whole form — project = true and nothing else, once per dependency you want. It is opt-in on purpose: a member that stays silent gets nothing, so adding a dependency at the root can never change what another package sees. A path written at the root is relative to the root, since that is where you wrote it. And a member that declares its own shapes = { path = "…" } simply uses that one — there is no shadowing rule to remember, because inheritance only happens where you ask for it.

By default the source root is the package directory and the entry file is main.vl. You can point elsewhere with root = "src" and entry = "app.vl" if you prefer.

Imports

import std::print;                          // one item
import std::reactive::{ Signal, combine };  // several at once
import std::option::Option::{ self, Some, None };  // a type plus its variants
import pkg::routes::{ Route, parse };       // another file in YOUR package
import common::{ Task, KoltClient };        // a dependency, by its name

There are three places an import can come from:

  • std::… is the standard library.
  • pkg::… is your own package. pkg::routes means “the file routes.vl next to my entry file”. A module is just a file — there is no separate module declaration.
  • Anything else is a dependency, under the name you gave it in vilan.toml.

The { self, Some, None } form is worth remembering: it imports the Option type and its variants, so you can write Some(x) without qualifying it.

The shape of a full-stack app

When you get to building a client + server app, the smallest layout is one package with two entries — the browser client and the node server build from the same source tree:

[package]
name = "app"

[entry.client]
target = "browser"

[entry.server]
app/
  vilan.toml
  src/
    client.vl     the browser entry
    server.vl     the node entry
    …             everything else, shared by whichever entry reaches it

Larger apps split into a [project] workspace of packages and libraries, as above. Either way, the compiler knows which standard-library modules exist on which platform: if code the browser entry can reach calls into std::db (a server thing), you get a clear compile error naming the call chain — importing the module is fine, reaching it is what’s checked. The platforms chapter has the details, and the walkthrough builds a whole app in this shape.

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:

import std::print;

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

  • booltrue 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.
  • 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 actually 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.

import std::print;

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.
  • 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.

That second rule has one trap worth memorizing. 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’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:

import std::print;

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 +. The full method list (split, trim, contains, and so on) is in the strings reference.

Tuples

(a, b) groups a few values without declaring a struct. Take them apart with a destructuring let, or reach one element by position:

import std::print;

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

Collections

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

import std::print;
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:

import std::print;

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. Map and Set key by value. Scalar keys (i32, str) work directly; a struct, enum, or 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.)

Functions & closures

Normative rules: spec §3 Grammar, §5.8 Coercions, §7.4 Async closures.

Functions

fun declares a function. The last expression in the body is the return value, and ret returns early:

import std::print;

fun clamp(value: i32, low: i32, high: i32): i32 {
	if value < low {
		ret low;
	}
	if value > high {
		ret high;
	}
	value
}

fun main() {
	print(clamp(15, 0, 10));
}

Notice there is no return on the last line. A bare expression at the end of a block is the block’s value. You’ll see this everywhere in vilan. if, match, and plain blocks all work the same way.

Generic functions take type parameters. Bounds say what the body is allowed to do with them:

fun largest<T: PartialOrd>(a: T, b: T): T { … }

Closures

A closure is an inline function value. Where JavaScript writes x => x * 2, vilan writes |x| x * 2. Parameter types are usually inferred from where the closure is used. Annotate them when they aren’t:

import std::print;

fun apply(seed: i32, transform: |i32| i32): i32 {
	transform(seed)
}

fun main() {
	print(apply(21, |n| n * 2));
	let label = |count: i32| i"{count} items";
	print(label(3));
}

Closure types are written |T| U. A closure with no parameters is || U, and one that returns nothing is || void. These appear as parameter types, in let annotations, and as struct fields.

Closures capture their surroundings by value at the moment they are created. vilan copies, remember. When a closure needs to share mutable state with its creator, they hold a Shared cell together. The memory model explains that pattern.

Named functions as closures

When a function already does what your closure would do, pass the function itself:

import std::print;
import std::reactive::Signal;

fun exclaim(text: str): str {
	text + "!"
}

fun main() {
	let words = Signal::new("hello");
	let loud = words.map(exclaim);   // instead of .map(|w| exclaim(w))
	print(loud.get());
}

This works for plain vilan functions. It does not work for generic functions, methods, async functions, or externs. For those, write the small wrapping closure — the compiler will tell you when you hit one.

Async closures

You can skip this section until you start storing callbacks that do async work.

A closure type can carry an async marker: async |T| U. Calls through a value of that type are awaited automatically, the same way direct calls to async functions are (see Async). The marker is allowed in exactly two places: parameter types and let annotations.

struct Draft<T> {
	commit: |T| Option<str>,          // struct fields store the plain type
}
…
let commit: async |T| Option<str> = self.commit;   // re-mark at a let
let outcome = commit(value);                        // this call awaits

That “store plain, re-mark at a let” dance is the standard pattern for async callbacks kept in struct fields, because the marker doesn’t exist on field types yet.

There is one more rule, and it works in your favor. Passing an async closure where a plain closure is expected is an error if the plain type returns a value — you would receive a promise pretending to be the value. But if the plain type returns void, it is allowed, and the call becomes fire-and-forget. This is why UI event handlers can await things freely without any ceremony.

Context clauses

You will use this feature constantly without writing it. It is how the UI framework passes things like “the current owner” invisibly. You only write it yourself when building framework-level helpers, so feel free to skim this on a first read.

A parameter’s closure type can declare that the closure reads an ambient context:

fun mount_root(id: str, body: (sync || View) context owner_scope): Owner
fun turn<T>(policy: FlushPolicy, body: (sync || T) context turn_scope): T

When you pass a closure literal into such a parameter, the ambient value (the current Owner, the current Turn) is threaded to it at the call site, through any depth of ordinary function calls in between. This is the machinery behind “every effect registers with the nearest boundary” in the UI layer. Your component functions never mention owners, and ownership still flows to the right place.

Going deeper. Two rules keep contexts sound. First, closures capture their contexts when they are created. A closure created outside a scope and called inside it would see nothing, so the compiler rejects that shape outright. Second, a function that reads a context can’t be passed around as a plain value, because the context channel would be severed. Both produce clear errors when you hit them.

Traps

  • Chained element access on a call result (read()[i]) can lose the element type. Bind, then index. (Tuple .0/.1 access isn’t implemented at all yet — destructure tuples instead.)

Data and traits

Normative rules: spec §5 The type system.

Structs

A struct is a named record type:

import std::print;

struct Task {
	id: i32,
	name: str,
}

fun main() {
	let task = Task { id = 1, name = "write docs" };
	print(task.name);
}

Literal fields use =, not :. When a field’s value is a binding with the same name, you can write it once: Task { id, name }. There are no field defaults — derive(Default) (below) covers the all-defaults case.

If you’re coming from TypeScript: a struct is like an interface plus an object literal in one, except it’s a real nominal type. Two structs with identical fields are still different types.

Enums

An enum is a type with a fixed set of variants, and variants can carry data. If you’ve used discriminated unions in TypeScript, this is that idea with language support:

import std::print;

enum Shape {
	Circle(f64),
	Rectangle(f64, f64),
	Point,
}

fun area(shape: Shape): f64 {
	match shape {
		Shape::Circle(let radius) => 3.14159 * radius * radius,
		Shape::Rectangle(let width, let height) => width * height,
		Shape::Point => 0.0,
	}
}

fun main() {
	print(area(Shape::Rectangle(3.0, 4.0)));
}

match takes the value apart, and the compiler checks that you handled every variant. Add a variant later and every match that misses it becomes a compile error — that’s the feature.

Option and Result are ordinary enums from std. No special cases.

impl — methods and statics

Methods live in impl blocks, separate from the data:

import std::print;

struct Counter {
	value: i32,
}

impl Counter {
	// A static: no self. Called as Counter::new().
	fun new(): Counter {
		Counter { value = 0 }
	}

	// A method: self is the receiver (a value; &mut self to mutate in place).
	fun doubled(self): i32 {
		self.value * 2
	}

	fun bump(&mut self) {
		self.value += 1;
	}
}

fun main() {
	mut counter = Counter::new();
	counter.bump();
	print(counter.doubled());
}

The &mut self on bump matters: it means “mutate the actual receiver, not a copy”. Plain self receives a copy, like every other value in vilan. The memory model chapter makes this precise.

Generics and bounds

Type parameters work on functions, structs, enums, and impls. A bound constrains what the code may do with the parameter:

import std::print;
import std::compare::PartialOrd;

struct Pair<T> {
	first: T,
	second: T,
}

impl Pair<type T: PartialOrd> {
	fun larger(self): T {
		if self.first > self.second {
			self.first
		} else {
			self.second
		}
	}
}

fun main() {
	let pair = Pair { first = 3, second = 8 };
	print(pair.larger());
}

Note the impl-side syntax: impl Pair<type T: PartialOrd>. The type T declares the parameter at the impl, and the bound says these methods exist only when T can be compared.

Going deeper. Generics are monomorphized: each concrete use of a generic function or impl compiles to its own specialized code, so generic dispatch has no runtime cost. This is unlike TypeScript, where generics are erased. It also means the compiler checks bounds at each call site, not at the declaration alone.

Traits

A trait declares a capability. impl Type with Trait provides it. Trait methods can have default bodies written in terms of the required ones:

import std::print;

trait Greet {
	fun name(self): str;

	// A default, in terms of the required method.
	fun greet(self): str {
		"hello, " + self.name()
	}
}

struct Robot {
	id: i32,
}

impl Robot with Greet {
	fun name(self): str {
		i"unit-{self.id}"
	}
}

fun main() {
	print(Robot { id = 7 }.greet());
}

Traits are like interfaces, with two differences worth knowing. They’re implemented explicitly (impl Robot with Greet), never structurally. And they appear as bounds on generics (T: Greet) rather than as standalone types — let x: Greet = … is a compile error. When you want “one of several things at runtime”, use an enum.

Operators are traits too. + dispatches through Add, == through PartialEq, < through PartialOrd, and so on. Implement the trait and your type gets the operator. std::time does exactly this so that instant + duration works.

Derives

[derive(…)] generates trait impls from a type’s shape, so you don’t write the boilerplate:

DeriveGives you
PartialEqstructural ==
Debug.debug() — a developer-facing rendering
DefaultDefault::default() built from the fields’ defaults
JsonJSON encode/decode (std::json)
Wireserialization for rpc payloads (std::wire)
import std::print;
import std::debug::Debug;

[derive(PartialEq, Debug)]
struct Point {
	x: i32,
	y: i32,
}

fun main() {
	let a = Point { x = 1, y = 2 };
	let b = Point { x = 1, y = 2 };
	print(a == b);
	print(a.debug());
}

The standard shape for a type that crosses the wire is [derive(Wire, PartialEq, Debug)].

Going deeper. Derives are ordinary macros, and you can write your own — see Macros & const. Wire and Json check that every field is itself serializable, recursively, and report at the derive site when one isn’t.

Control flow

Normative rules: spec §3 Grammar and §5.10 ! and ?..

if / else

if is an expression. Both branches produce a value, and the whole thing has that value:

import std::print;

fun main() {
	let n = 7;
	let label = if n % 2 == 0 { "even" } else { "odd" };
	print(label);
}

There is no ternary operator because if already is one.

match

match is the workhorse, a switch that can take values apart and that the compiler checks for completeness. Arms are pattern => expression. Payloads bind with let, and _ catches everything else:

import std::print;
import std::option::Option::{ self, Some, None };

fun describe(slot: Option<i32>): str {
	match slot {
		Some(let value) => i"got {value}",
		None => "empty",
	}
}

fun main() {
	print(describe(Some(3)));
	print(describe(None));
}

If you forget a variant, the compiler tells you. That’s most of the reason enums plus match replace flag fields and null checks.

When you only need a yes/no answer instead of a full match, is tests a pattern as a boolean:

let present = entry.map(|current| current is Some(let _task));

One edge to know: a match can’t sit directly inside a larger operator expression. Bind it to a local first.

Arms that never produce a value — a ret, a panic, a jump break/continue — simply don’t participate in the match’s type. The other arms decide it:

let value = match slot {
	Some(let text) => text,     // the match is a str
	None => panic("missing"),   // this arm diverges; no annotation needed
};

Loops

for covers every loop. It has three forms:

import std::print;
import std::range::Range;

fun main() {
	for item in ["a", "b", "c"] {
		print(item);
	}

	for i in Range::new(0, 3) {   // 0, 1, 2 — the end is exclusive
		print(i);
	}

	mut count = 0;
	for count < 3 {               // a while loop: runs while the condition holds
		count += 1;
	}
	print(count);
}

There is also a bare for { … } for an infinite loop. Loop control is spelled jump break and jump continue. Iterating with for _ in … skips the binding.

One more form matters once you care about performance: for e in &mut list iterates views of the elements so you can mutate them in place. That’s a memory model topic.

Early return: ret

fun parse(path: str): Route {
	if parts.len() == 0 {
		ret Route::Home;      // early exit
	}
	Route::NotFound           // the final expression is the return value
}

Option and Result

vilan has no null and no exceptions. Instead:

  • A value that might be absent is an Option<T> — either Some(value) or None.
  • An operation that might fail is a Result<T, E> — either Ok(value) or Err(error).

Both are plain enums with a rich set of helper methods (unwrap_or, map, is_some, and friends — the full list). You can always just match on them. Two operators make the common patterns short.

! unwraps, or propagates the failure. Think of it as “give me the value, and if there isn’t one, return the failure from this function”:

import std::print;
import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };

fun to_number(text: str): Result<i32, str> {
	match text.parse_i32() {
		Some(let value) => Ok(value),
		None => Err(text),
	}
}

fun sum(a: str, b: str): Result<i32, str> {
	let left = to_number(a)!;    // an Err returns early, carrying the error
	let right = to_number(b)!;
	Ok(left + right)
}

fun main() {
	match sum("2", "40") {
		Ok(let total) => print(total),
		Err(let bad) => print(i"not a number: {bad}"),
	}
}

This is what try/catch becomes: failures travel up through return types, visibly, and the caller decides what to do.

! returns the failure as-is, so the value’s error type must already be the function’s — vilan doesn’t convert it behind your back. When the types differ, convert at the value, before the !: .map_err(…) changes a Result’s error, and .ok_or(err) turns an Option’s None into an Err you supply.

import std::print;
import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };

fun lookup(key: str): Option<i32> {
	if key == "answer" { Some(42) } else { None }
}

fun read(key: str): Result<i32, str> {
	let value = lookup(key).ok_or(i"no entry for {key}")!;  // None -> Err
	Ok(value)
}

fun main() {
	match read("answer") {
		Ok(let value) => print(value),          // 42
		Err(let why) => print(why),
	}
	match read("missing") {
		Ok(let value) => print(value),
		Err(let why) => print(why),             // no entry for missing
	}
}

?. reaches inside the container. It looks like optional chaining from JS, and on Option it plays the same role — with the compiler checking it:

import std::print;
import std::option::Option::{ self, Some, None };

struct Book {
	title: str,
}

fun find(key: str): Option<Book> {
	if key == "hit" {
		Some(Book { title = "dune" })
	} else {
		None
	}
}

fun main() {
	print((find("hit")?.title).unwrap_or("?"));   // dune
	print((find("miss")?.title).unwrap_or("?"));  // ?
}

find("hit")?.title means: if the option holds a book, take its title and wrap it back up; if it’s None, stay None. It works on Result too, passing an Err through untouched.

A bare ? lifts a whole expression. Where ?. continues a member chain, a ? on its own lifts the rest of the surrounding expression — operators included:

import std::print;
import std::option::Option::{ self, Some, None };

fun main() {
	let price = Some(40);
	let tax = Some(2);
	print((price? * 2).unwrap_or(-1));     // 80 — lift, multiply, rewrap
	print((price? + tax?).unwrap_or(-1));  // 42 — Some only if BOTH are
	let missing: Option<i32> = None;
	print((price? + missing?).unwrap_or(-1));   // -1
}

With two ?s the region is good only when every receiver is — and it short-circuits left to right, so a receiver right of a None/Err never evaluates (like &&). On Result, the first Err wins and passes through unchanged, which also means every Result receiver in one expression must carry the same error type (convert first with .map_err(…)). The lift stops at natural boundaries: a call argument, a struct field, parentheses — describe(status?) is an error rather than something spooky, and (a? + 1) * 2 seals the lift inside the parens. A ? in an if condition is rejected too: the condition would become an Option<bool>, so match on the lifted value instead.

Going deeper. Both operators are trait-driven, not hard-coded to Option and Result. ! dispatches through Try, and ?. through Try plus the Lift marker (std::operators). Your own two-outcome type can implement them and join in. A ?. continuation that itself produces the container flattens instead of nesting, so find(key)?.shelf() on an Option-returning method stays a single Option. (A bare ? lifts the std pair only for now — a user Lift container lifts through ?. chains.)

Panics and asserts

panic(message) stops the program with a message. Use it for states that should be impossible, not for expected failures — those are Results. assert(condition, message) panics when the condition is false, and it’s how vilan test decides a test failed.

The memory model

Normative rules: spec §6 The memory model.

This is the chapter where vilan differs most from JavaScript, so it goes slowly. The one-sentence version: values are copied, and sharing is something you ask for explicitly.

In JavaScript, objects and arrays are shared by reference. Passing one to a function means the function can mutate your data. Storing one in two places means a change shows up in both. That’s convenient right up until it isn’t, and then it’s a bug hunt.

vilan flips the default. Everything copies. Then it gives you four tools, each for one kind of sharing, so that when data is shared, the code says so. Here’s the decision tree you’ll internalize quickly:

  1. Default: plain values. Copies everywhere.
  2. A function should mutate my thing in place: pass a view (&mut).
  3. Two long-lived places need the same mutable state: a Shared<T> cell.
  4. Things need to point at each other (graphs, cycles): an Arena with Handle ids.

Values: the default

let binds immutably. mut allows reassignment and field mutation. Either way, binding an existing value makes a copy:

import std::print;

struct Counter { value: i32 }

fun main() {
	mut original = Counter { value = 10 };
	mut copy = original;
	copy.value = 99;
	print(original.value); // 10 — a binding copies
}

Passing to a function is the same. The callee gets its own value, and nothing it does leaks back to you. If you’ve ever defensively structuredCloned an object before handing it out, this is that, everywhere, for free.

Going deeper. “Copy” describes the semantics, not necessarily the machine code. The compiler skips copies whenever no program could tell the difference (for example, when the source is never used again). You reason in copies; it optimizes.

Views: lending a value out

Sometimes you want a function to mutate your value. You lend it a view with &mut:

import std::print;

struct Counter { value: i32 }

fun bump(&mut c: Counter) {
	c.value = c.value + 10;
}

impl Counter {
	fun increment(&mut self) {
		self.value = self.value + 1;
	}
}

fun main() {
	mut c = Counter { value = 10 };
	c.increment();
	bump(&mut c);
	print(c.value); // 21 — both mutated the original through views
}

A view aliases the original instead of copying it. &mut views can write; & views can only read. The &mut self receiver is the same idea for methods: “this method changes the actual object”.

The two behaviors side by side:

mut b = a          — a COPY               let v = &mut a    — a VIEW

a ──▶ ┌─────────┐                         a ──▶ ┌─────────┐
      │ x=1 y=2 │                               │ x=1 y=2 │
      └─────────┘                          v ──▶└─────────┘
b ──▶ ┌─────────┐
      │ x=1 y=2 │                          two names, ONE box:
      └─────────┘                          writing through v changes a

two boxes: b.x = 9
never touches a

Views come with a deliberate restriction: they don’t outlive the moment. A view can be a parameter or a short-lived local. It cannot be stored in a struct field, put in a list, returned into long-lived state, or held across an await. Lend, use, done. That confinement is what makes views safe without a whole lifetime system — if you’ve heard Rust horror stories, this is the part vilan deliberately keeps small.

Views also make loops that mutate in place. A plain for x in list copies each element; for e in &mut list gives you writable views instead:

import std::print;

fun main() {
	mut xs: List<i32> = [1, 2, 3];
	for e in &mut xs {
		e = *e * 10;
	}
	print(xs[2]); // 30
}

Inside the loop, *e reads the element and assigning to e writes through to the list.

Going deeper. A method may return a view that projects its receiver, like fun get(&mut self, i: i32): &mut T. The compiler infers which parameter the view borrows from, and the returned view obeys the same short-lived rules at the call site — anchored to what it projects, so a list.push(..) while a view from list.at(0) is live is the same compile error a direct &mut list[0] would raise. Option<&T> covers “a view, maybe” (map lookups). The compiler is precise about which mutations invalidate: only calls that may change a container’s geometry (grow, shrink, reallocate — inferred per method) conflict with a live view; a method that just writes fields or elements through &mut self passes freely. Hover a function to see both inferred effects (borrows, bumps). Spec §6 has the precise rules.

Shared<T>: one cell, many holders

When two places genuinely need to see the same mutable state — a closure and its creator, most commonly — reach for Shared<T>. It’s a small heap cell. Copying the Shared value copies the handle, and both handles point at one cell. That’s the point:

import std::print;
import std::shared::Shared;

fun main() {
	let count = Shared::new(0);
	let bump = || {
		count.write() = count.read() + 1;
	};
	bump();
	bump();
	print(count.read()); // 2 — the closure and main share the cell
}

Two methods, two behaviors, one trap:

  • read() gives you a copy of the contents.
  • write() gives you a writable view of the contents, to assign to or mutate through, within the same statement.
  • The trap: shared.read().push(x) mutates the copy and is lost. Write through the cell: shared.write().push(x).

If you’re reaching for Shared just to “avoid a copy” on a hot path, don’t — values are cheap, and the compiler already elides copies it can prove away.

Arena + Handle: graphs and cycles

Values copy, and views can’t be stored. So how do you build a tree with parent pointers, or a graph where nodes reference each other? With an arena: a container that owns all the nodes, handing you small copyable Handle ids. Edges are handles, and a handle is a plain value you can store anywhere:

mut nodes: Arena<Node> = Arena::new();
let a: Handle = nodes.insert(Node { … });
let b: Handle = nodes.insert(Node { edges = [a] });
// `get` hands back a view; copy it out, add the edge, write it back.
match nodes.get(a) {
	Some(let node) => {
		mut updated = *node;
		updated.edges.push(b);      // a cycle, no aliasing
		nodes.set(a, updated);
	},
	None => {},
}

Deleting is safe too. Removing a node and reusing its slot bumps a generation counter, so a stale handle comes back as None instead of pointing at the new occupant. The full API is in the cells reference.

The async rule

One rule ties this chapter to the async model: a view may not be held across an await. While your function is suspended, other code runs and may change or replace whatever the view pointed into. The compiler rejects the shape. The fix is always the same — re-derive after the suspension:

let row = &mut rows[i];
send(row.id);            // suspends —
row.text = "sent";       // ✗ rejected: view held across await

send(rows[i].id);        // ✓ re-derive after the suspension
rows[i].text = "sent";

Traps

  • “Why didn’t my mutation stick?” You mutated a copy. Either take &mut at the function boundary, or restructure around Shared or a signal’s set_with.
  • shared.read().push(x) is lost (it mutates a copy). Use shared.write().push(x).
  • Signals hold values too. Mutate a signal’s list with signal.set_with(|list| …), never by mutating a get() result.

Async

Normative rules: spec §7 Execution & async.

If you know async/await in JavaScript, here is the whole model in one line: vilan keeps the machinery and deletes the keywords. Calling an async function just gives you the value. You don’t write await, you don’t mark functions async, and you never see Promise<T> in a return type. The compiler figures out which functions suspend and awaits the calls for you.

import std::print;
import std::time::{ sleep_for, Duration };

fun fetch_label(): str {
	sleep_for(Duration::millis(1));   // suspends — so fetch_label is async
	"ready"
}

fun main() {
	print(fetch_label());   // implicitly awaited; main becomes async too
}

fetch_label sleeps, so it’s async. main calls it, so main is async too. The return type stays the honest str. Asyncness spreads through the call graph on its own, the way it always wanted to.

Opting out of waiting: async and await

The explicit keywords exist for the one thing implicit awaiting can’t express: not waiting.

  • async expr spawns: start the work, don’t wait for it. It gives you a Task<T> — a handle to the running work.
  • async { … } spawns a block.
  • await task collects a task you spawned earlier.
import std::print;
import std::time::{ sleep_for, Duration };

fun step(label: str): str {
	sleep_for(Duration::millis(1));
	label
}

fun main() {
	let pending = async step("concurrent");   // running; we haven't waited
	print(step("first"));                     // awaited inline
	print(await pending);                     // now collect the spawned one
}

So in JS you mark the async case and waiting is explicit. In vilan you mark the concurrent case and waiting is the default. Fire-and-forget is just spawning and dropping the task: let _done = async save(entry);. To wait on many at once, Task::settle_all(tasks) from std::task.

A task’s failure can’t crash the program from the outside: if the spawned work panics, a later await receives the panic, and a task nobody ever awaits reports the error to the console — with the name of the function that spawned it — and execution continues.

Async closures

This section matters once you store async callbacks. Until then, skim.

A call through a closure value can’t be seen by the compiler’s asyncness inference (there’s no fixed callee to look at). Two things close the gap: the type carries the marker — async |T| U, written at any contract position — and unannotated bindings adopt asyncness from the closure they hold. Calls through either are awaited implicitly, like direct calls.

// 1. An async-friendly callback parameter — sync closures pass fine too
//    (awaiting a plain value just resolves):
fun draft<T: PartialEq>(initial: T, commit: async |T| Option<str>): Draft<T>

// 2. A struct field storing an async callback — reads await when called:
struct Poller {
    tick: async || i32,
}

// 3. A function handing one back — `make()()` and
//    `let go = make(); go()` both await:
fun make(): async || i32

The marker is accepted at parameters, let annotations, struct fields, and function return types. An unannotated let (or a mut, through every rebind) holding an async closure needs no marker at all — the binding adopts the closure’s asyncness. Functions & closures covers the same seams from the closure side.

Higher-order functions adapt

A plain, value-returning closure parameter does something better than refuse: it adapts. Passing an async closure instantiates an async copy of the function — its calls through the parameter are awaited — while every sync call site keeps the untouched original. map is one function, not two:

import std::print;
import std::time::sleep;

fun fetch_len(url: str): i32 {
	sleep(1);
	url.len()
}

fun main() {
	let urls = ["ab", "cdef"];
	print(urls.map(|url| fetch_len(url)));   // awaited per element: [2, 4]
	print(urls.map(|url| url.len()));        // the plain instance, no awaits
}

The contract is sequential: each callback settles before the next begins — a 100-element map whose callback takes a second takes a hundred seconds. When the elements are independent, opt into concurrency by starting them all first:

import std::print;
import std::time::sleep;

fun fetch_len(url: str): i32 {
	sleep(1);
	url.len()
}

fun main() {
	let lens = ["ab", "cdef"]
		.map(|url| async fetch_len(url))   // all in flight (List of tasks)
		.map(|t| await t);                 // settle in order: total ≈ max
	print(lens);
}

An adapting function traverses a snapshot of its receiver — the list as of the call — so work interleaved during the awaits can’t tear the iteration. Adaptation follows the closure through plain parameters (fun helper(xs, f) { xs.map(f) } adapts end-to-end), but it cannot cross a host (external) boundary or a trait/generic dispatch, and it never touches a parameter marked sync:

// The callback completes inside the reactive graph's synchronous
// protocol — an async closure here is refused, not adapted:
fun map<U>(self, transform: sync |T| U): Signal<U>

Signal::map, turn, batch, and the UI render callbacks are sync positions: move async work into a turn of its own (an awaiting body holds the turn), Draft, or a spawned async block instead.

The remaining boundaries keep the refusal rule. An async closure flowing where a plain closure type is declared on a struct field or a function’s declared return type is a compile error if that closure returns a value, because the reader would receive a promise disguised as the value (declare the field or return async || T instead). If it returns void, it’s allowed anywhere — the call just becomes fire-and-forget. That’s why UI event handlers can await freely with no ceremony.

Nurseries: structured spawning

A dropped task keeps running with nobody responsible for it. When every spawn should be accounted for, run the work in a nursery from std::task:

import std::print;
import std::time::sleep;
import std::task::nursery;

fun announce(label: str, ms: i32) {
	let _ = async {
		sleep(ms);
		print(label);
	};
}

fun main() {
	let value = nursery(|n| {
		announce("slow", 20);   // a helper's spawn is still inside the extent
		announce("quick", 10);
		print("body");
		7
	});
	print(value);   // body, quick, slow, 7 — nothing outlives the nursery
}

The contract:

  • Everything joins. nursery(body) returns only when the body and every task spawned in its dynamic extent have settled — spawns made by the body, by functions it calls, and by the tasks themselves (grandchildren). No plumbing: the extent is ambient, like a context.
  • The value passes through. The nursery’s value is the body’s value. Spawns outside any nursery keep their free-floating behavior.
  • First failure wins, the rest are absorbed. If the body throws, that failure is the nursery’s; otherwise the earliest-settled task failure is, re-raised from the nursery call with the name of the function that spawned the task. Either way the other tasks are observed and their failures discarded — a losing task can never crash the program later.

Cancellation

The body’s handle cancels the whole extent:

import std::print;
import std::time::sleep;
import std::task::{ nursery, Task };

fun fetch_from(source: str, ms: i32): str {
	sleep(ms);   // stands in for a real request
	source
}

fun main() {
	let winner = nursery(|n| {
		let a = async fetch_from("mirror-a", 300);
		let b = async fetch_from("mirror-b", 10);
		let first = Task::race([a, b]);   // first settled wins
		n.cancel();                       // abort the loser's IO
		first
	});
	print(winner);   // mirror-b — and mirror-a's sleep was cut short
}

n.cancel() fires the nursery’s host AbortSignal. std’s IO carries that signal automatically — a sleep or an in-flight fetch inside the extent rejects promptly instead of running out — and those cancellation rejections are echoes, absorbed at the join rather than treated as failures. The first real failure cancels the same way, so one task’s error stops its siblings’ work early. Details:

  • Cancellation doesn’t preempt: your code runs until its next (cancellable) suspension. Pure-compute loops can poll n.is_cancelled() between chunks.
  • Code after n.cancel() in the body still runs, and the body’s value is still returned — cancel kills the children, not the body. (If the body itself is suspended on IO when cancellation lands, that IO rejects and the cancellation becomes the nursery’s outcome.)
  • Nurseries nest: an outer cancel reaches every inner nursery’s IO.
  • To hand the signal to a host API std doesn’t wrap, read std::task::ambient_signal().

Prefer a nursery whenever spawned work has an owner that should wait for it; keep bare spawns for genuine fire-and-forget.

Timers

From std::time: sleep_for(duration) and sleep(millis) suspend. Duration::millis/seconds/minutes/hours/days build durations. now() reads the clock. Details in the time reference.

What async does NOT do

  • No promise-colored signatures. Return types are the plain values. A Task<T> appears only where you spawned and kept one.
  • No hidden concurrency. Everything waits, in order, unless you spawn. Same single-threaded event loop as JS underneath.
  • No views across a suspension. A &/&mut view held across an await is rejected. Re-derive after — see the memory model.

Traps

  • On node, the process exits when nothing is left to do: once main finishes, only live host handles (a running timer, a socket, a listening server) keep it alive. A dropped task that is merely pending — awaiting something that will never wake — is silently abandoned at exit, and whether a spawn outlives main depends on what it holds. Joining spawns in a nursery makes the question moot; a long-lived client must keep main open by awaiting something that ends with the app.
  • Don’t expect a spawned write to be visible immediately after the spawn. Spawned work interleaves with yours per the event loop, like any promise.

Going deeper. The reactive layer batches signal writes into “turns”, and turns interact with suspension: a UI turn settles at the handler’s first await, and writes after it land in later waves unless an explicit turn with an awaiting body holds them. That’s a reactive guide topic, not a language rule.

Resources

Normative rules: spec §6.8 Resources and destruction.

Almost everything in vilan is a value: it copies, and the copy is yours (the memory model chapter). A few things can’t work that way. A database handle that copied would close twice. A task owner that copied would cancel the wrong tasks. These are resources: values with a single owner, that move instead of copying, and that are torn down deterministically when their owner’s scope ends.

You mark one with resource, and — if it needs cleanup — give it a Drop:

import std::print;
import std::drop::Drop;

resource struct Guard {
	label: str,
}

impl Guard with Drop {
	fun drop(&mut self) {
		print(i"dropped {self.label}");
	}
}

fun main() {
	let first = Guard { label = "first" };
	let second = Guard { label = "second" };
	print("body");
}

That program prints body, then dropped second, then dropped first. Two things to notice: drop ran on its own at the end of main, with no call from you — and the two guards tore down in reverse order, the way a stack unwinds.

Moving, not copying

Binding a resource to a new name moves it. The old name is now empty, and using it is an error:

let a = Guard { label = "a" };
let b = a;           // a moves into b
print(a.label);      // ✗ error: use of `a` after it was moved

The compiler points at the use and, in a note, at the move: a resource has one owner, so once you hand it over the old binding is spent. The same happens when you pass a resource to an own parameter, return it, or match it by value.

Loaning instead of moving

Usually you don’t want to give a resource away — you want to use it and keep it. Lend a loan (a view, & or &mut), exactly as with ordinary values:

import std::print;
import std::drop::Drop;

resource struct Guard { label: str }
impl Guard with Drop {
	fun drop(&mut self) { print(i"dropped {self.label}"); }
}

fun inspect(&g: Guard) {
	print(i"inspecting {g.label}");
}

fun main() {
	let g = Guard { label = "g" };
	inspect(&g);
	inspect(&g);
	print("done");
}

A loan changes no ownership, so g is still yours after each call — the program prints inspecting g twice, then done, then dropped g when main ends. Method calls (&self, &mut self) are loans too; that is how a resource’s own methods reach it without consuming it.

Teardown happens on every exit

Because teardown is tied to the owner’s scope, it runs however the scope ends — falling off the bottom, an early ret, a jump out of a loop, even a panic unwinding through. There are no drop flags and nothing to remember: if a binding still owns a resource when control leaves the scope, it drops.

Tearing down early: drop(x)

Sometimes you want a resource gone before its scope ends. Move it into drop:

import std::print;
import std::drop::{ Drop, drop };

resource struct Guard { label: str }
impl Guard with Drop {
	fun drop(&mut self) { print(i"dropped {self.label}"); }
}

fun main() {
	let a = Guard { label = "a" };
	let b = Guard { label = "b" };
	drop(a);                 // a is torn down right here
	print("after drop(a)");
}

This prints dropped a, after drop(a), dropped b. drop takes its argument by move, so a is spent at that line — there is no close() to call and no way to use a afterward by mistake. (On plain data, drop(x) just means “I’m done with this”; it does nothing.)

Conditional teardown: Option.take

A resource can live in an Option, which is the one container that holds one. take() moves the resource out and leaves None behind — exactly what “tear it down only if it’s there” needs:

import std::print;
import std::drop::{ Drop, drop };
import std::option::Option::{ self, Some, None };

resource struct Guard { label: str }
impl Guard with Drop {
	fun drop(&mut self) { print(i"dropped {self.label}"); }
}

fun main() {
	mut slot: Option<Guard> = Some(Guard { label = "held" });
	match slot.take() {
		Some(let g) => drop(g),
		None => {},
	}
	print("after take");
}

After the take, slot is None, so nothing drops a second time at the end of main. take is also how a resource leaves a struct field — the one sanctioned way to move a resource out of something that is still alive.

A real resource: Database

The std Database is a resource: opening one gives you a handle that closes itself on drop. A short-lived database closes when its function returns; a server that runs forever wants the opposite, so it keeps the database at module level, where it lives for the whole process and never drops:

import std::print;
import std::db::{ Database, Row };
import std::option::Option::{ self, Some, None };

let db: Database = Database::open(":memory:");

fun setup() {
	db.exec("CREATE TABLE items (name TEXT)");
	db.prepare("INSERT INTO items (name) VALUES (?)").run(["widget"]);
}

fun count_items(): i32 {
	let statement = db.prepare("SELECT COUNT(*) AS n FROM items");
	match statement.first([]) {
		Some(let row) => row.integer("n"),
		None => 0,
	}
}

fun main() {
	setup();
	print(i"items: {count_items()}");
}

Every function reaches db by loan (a method call is a loan), never by moving it. A module-level resource is loan-only for exactly this reason: moving or droping it would close the shared handle out from under the rest of the program, so the compiler rejects that. When you do want a database that closes at the end of a scope, open it in a local instead — or drop(db) to close it early.

A closure may reach a module-level resource too. A closure that references db isn’t capturing an owner — it borrows the same process-lifetime storage, per call, exactly as a function does. This is what gives the module-level idiom its reach: request handlers, injected hooks, and background tasks can all touch the database, as long as it lives at module level.

import std::db::Database;
import std::shared::Shared;

let db: Database = Database::open(":memory:");

fun main() {
	db.exec("CREATE TABLE account (username TEXT)");
	// A hook closure that reaches the module-level `db` — not a captured
	// owner, just a per-call loan of process-lifetime storage.
	let insert = |username: str| {
		db.prepare("INSERT INTO account (username) VALUES (?)").run([username]);
	};
	let hook = Shared::new(insert);
	hook.read()("alice");
}

A local resource is different: a closure that captures one would become a second owner, so that stays rejected (below).

Owning background work: OwnedNursery

A closure can’t capture a local resource (it would become a second owner), which is a problem for background tasks that need to outlive the function that starts them. OwnedNursery is the answer: a resource that owns the tasks spawned inside its enter, and cancels them when it drops.

import std::print;
import std::time::sleep;
import std::task::OwnedNursery;

fun main() {
	let owner = OwnedNursery::new();
	owner.enter(|| {
		let _ = async {
			sleep(50);
			print("background work");
		};
	});
	print("enter returned without joining");
}

Unlike a nursery, enter does not wait for the spawned work — it returns right away, and the tasks keep running under owner. When owner drops (at the end of main, or at an explicit drop(owner)), the tasks are cancelled. That is the whole point: the owner’s lifetime bounds the work, and the owner is an ordinary resource the scope rules already know how to tear down.

Traps

  • “Why can’t I use it again?” You moved it. Loan it (&x / &mut x, or a method call) instead of binding, passing, or returning it by value.
  • A resource can’t go in a List, Map, or Set — the compiler can’t see inside those. Use an Option, or a struct field.
  • A closure or spawn can’t capture a local resource. Pass a loan into the call, give the resource to a struct (or an OwnedNursery) that owns the closure’s lifetime, or keep it at module level — a module global is loan-only and process-lifetime, so a closure may reach it without becoming an owner.
  • Drop is only for resources, must be exactly fun drop(&mut self), and must be synchronous and context-free (no await, no signal writes). Cancel owned tasks through an OwnedNursery rather than awaiting them.

Macros & const

vilan has two tools that run at compile time. const computes values ahead of time. Macros generate code. In both cases the emitted JavaScript carries only the results, never the computation.

Most days you’ll use these indirectly: [derive(…)] is a macro, and const style() is how the styling system works. Writing your own comes up rarely, so treat the second half of this chapter as reference.

const — compute it at compile time

Put const in front of an expression and the compiler evaluates it during the build, then writes the result into the output as a literal:

import std::print;

fun squares(): List<i32> {
	mut result: List<i32> = [];
	for i in [1, 2, 3, 4] {
		result.push(i * i);
	}
	result
}

let TABLE = const squares();   // the emitted JS holds the literal list

fun main() {
	let folded = const 1 + 2 * 3;
	print(folded);
	print(TABLE.len());
}

Three rules to know:

  • const captures greedily: everything to the end of the expression folds. Parenthesize to narrow it — in (const square(4)) + square(2), the second call runs at runtime.
  • The expression can only use things the compiler can know: literals, imports, and immutable bindings whose own initializers are const.
  • No host calls. const now() is an error, because the answer wouldn’t be a constant.

The flagship user is styling: const style()… chains evaluate at build time and emit CSS. See the styling guide.

Derive macros — impls from a type’s shape

You’ve already seen [derive(PartialEq, Debug)]. A derive is a macro: a function that runs at compile time, receives the type it annotates as data, and returns source code to splice into the program. You can write your own:

import std::print;
import std::display::{ Display, format };

macro fun derive_display(item: Item): Source {
	import macro_std::source;
	import macro_std::meta::{ Item, Source, StructItem };
	import macro_std::option::Option::{ self, Some, None };

	let target = match item.as_struct() {
		Some(let found) => found,
		None => StructItem { name = "?", fields = [] },
	};
	mut arms = "";
	mut first = true;
	for field in target.fields {
		if first {
			first = false;
		} else {
			arms = arms + " + \", \" + ";
		}
		arms = arms + i"\"{field.name}=\" + format(self.{field.name})";
	}
	source(i"impl {target.name} with Display \{
	fun to_string(self): str \{
		import std::display::format;
		{arms}
	\}
\}
")
}

[derive_display]
struct Point {
	x: i32,
	y: i32,
}

fun main() {
	print(format(Point { x = 1, y = 2 }));
}

How to read that:

  • macro fun declares the macro. Its body is ordinary vilan, but it compiles against macro_std — a small compile-time standard library with source, the meta types (Item, StructItem, …), and the basics. Its imports are its own; it can’t reach into your program.
  • The macro receives the annotated item as data. item.as_struct() gives the struct’s name and fields.
  • It returns Source: text, usually built with interpolation. Literal braces in generated code are escaped as \{ and \}.
  • The returned source is spliced in before type checking, so generated code is checked exactly like code you wrote by hand.

macro { … } blocks

An anonymous macro that expands on the spot. In item position it stamps out a family of items; in expression position it folds to a value:

import std::print;

macro fun labeled(name: str, value: i32): str {
	i"fun {name}(): i32 \{ {value} \}\n"
}

macro {
	mut generated = "";
	mut index = 0;
	for index < 3 {
		generated = generated + labeled(i"constant_{index}", index * 10);
		index = index + 1;
	}
	source(generated)
}

fun main() {
	print(constant_0() + constant_1() + constant_2());
}

For plain value folding, prefer const — reach for an expression-position macro block only when you’re generating code, not computing a value.

Choosing the right tool

You needReach for
a computed constant or lookup tableconst
CSS or other build-time assetsconst calling std’s emitters
an impl derived from a type’s shapea derive macro
a family of near-identical itemsmacro { … } in item position
transforming a whole item (like [service] does)an attribute macro

Going deeper. Macro expansion is fueled: a runaway macro is a compile error rather than a hung build, and the limits are tunable via [macro] fuel / depth in vilan.toml. Macros see one item at a time — there is no whole-program reflection. The [service], [rpc], and [derive(Wire)] attributes you meet in the guides are this same mechanism, shipped in std.

Platforms

One language, several runtimes. A package builds for node (the default), deno, bun, or the browser — set target in vilan.toml, or pass --platform on the CLI.

The standard library is layered so each build only uses what its platform can actually do. Call a server function from code a browser build can reach, and you get a clear compile error naming the call chain — not a runtime crash. That’s the whole idea of this chapter.

Going deeper. The check is on reachable code, not on imports. A file may import std::fs and compile for the browser, as long as no code the browser entry can reach actually calls into it. The compiler colors every function with the platforms it can run on (seeded by the std layers, flowing through calls — the same way async is inferred), and checks the colors only along paths that start at your main. When a path crosses onto the wrong platform, the error shows that path. Module-level lets follow the same rule: a binding’s initializer runs (and is checked, and is bundled) only if something reachable references it — a server-only global in a shared file costs the browser build nothing. const initializers run at build time and ship as plain values, so they never color anything. Where a team wants an explicit boundary, [platform("browser")] on a function declares the platforms it promises to run on — the compiler checks the promise on every compile (entry or not, whatever the build target), and a violation lands at the fence with its chain instead of at some distant entry in a dependent build. Patterns use the manifest layers’ vocabulary: "node", "browser", families like "@process", or several at once for code that must stay neutral. The editor shows the same information as you write: violations appear as live diagnostics at the offending call, and hovering a function shows its inferred requirement and how it got it — e.g. requires the `process` layer of `std` (via `save → write_file (std::fs)`).

The std layers

  • Base — platform-neutral, available everywhere: collections, Option/Result, strings, numbers, reactive, shared, time, json/wire/binary, the rpc client machinery, style, fetch, crypto, and friends.
  • Browser layerstd::dom, std::ui, std::router, std::storage. Browser builds only.
  • Process layer (node/deno/bun) — std::db, std::http, std::fs, std::process, std::rpc_server. Server builds only.

Full-stack packages

A client + server app fits in one package with two entries — each [entry.<name>] names an entry file and the platform it builds for:

[package]
name = "app"

[entry.client]
target = "browser"

[entry.server]
# target defaults to node; path defaults to <name>.vl under src/
app/
  vilan.toml
  src/
    client.vl     the browser entry
    server.vl     the node entry
    store.vl      the service, next to its resources
    todo.vl       shared types — anything both entries import

vilan build compiles every entry for its own target into dist/<name>.js (browser entries first, so a server that ships bundles finds them fresh); vilan run builds everything and starts the one node entry; vilan check checks all entries, always. Reachability does the sorting: the same store.vl may use std::fs freely, because only the server entry reaches into it — if client code ever calls that far, the build fails with the call chain.

Larger apps can still split into a workspace of packages — a shared [library] for payload types, a browser package, a server package — and each member may declare its own entries:

app/
  vilan.toml       [project] packages = ["common", "client", "server"]
  common/          [library] — payload types (base layer only)
  client/          [package] target = "browser"
  server/          [package] (node)

vilan build . at the root builds every member the same way. The compiler checks each against its own platform, including that common stays platform-neutral. Either shape, the service lives next to its resources (see Services).

Externs — talking to the host

You’ll mostly consume host bindings through std. But when you need a node API or browser API that std doesn’t wrap yet, you can bind it yourself with an extern declaration. This is exactly how std’s own bindings are written:

// A function from a host module (node:crypto):
[extern("node:crypto", "randomBytes")]
external fun random_bytes_sync(length: i32): HashBuffer;

// An opaque host object, with methods bound one by one:
external struct HashBuffer;
impl HashBuffer {
	[extern(method, "toString")]
	external fun to_string_encoded(self, encoding: str): str;
}

// An async host function — promise-returning; callers implicitly await:
[extern("node:timers/promises", "setTimeout")]
async external fun sleep(ms: i32): void;

The four binding forms:

FormBinds
[extern("module", "name")]an import from a host module
[extern("global.path")]a dotted global, like history.pushState
[extern(method, "name")]a method on a host object
[extern(get, "prop")] / [extern(set, "prop")]a property read / write

Keep externs in platform-specific packages (they are host-specific by nature). When a binding proves itself, consider promoting it into std rather than copying it between apps.

Assets

Browser builds produce <entry>.js, plus <entry>.css when styles were emitted. Your server serves those two files and an HTML shell — the services guide shows the standard fallback shape.

Going deeper. Build assets come from std::asset::emit(kind, content), callable only during const evaluation. The styling system’s const style() chains call it to write CSS rules. Libraries can also declare platform overlays of their own (a base root plus per-platform roots in [library.layer]), which is how std itself is layered — most libraries never need this.

Reactive state

std::reactive is vilan’s state layer. If you’ve used signals in Solid or Preact, you’ll be at home immediately. If you’re coming from React, think of a signal as a piece of state that components subscribe to directly — there is no re-render, no dependency array, no memoization dance. When a signal changes, exactly the code that watches it runs.

Four ideas make up the layer, and this chapter takes them in order:

  • Signals hold values.
  • Effects run code when signals change.
  • Owners decide when effects die.
  • Turns decide when changes become visible.

The UI layer, the rpc mirrors, and the router are all built on these, so this chapter pays for itself quickly.

import std::print;
import std::reactive::{ Signal, Owner, run_with_owner };

fun main() {
	let count = Signal::new(0);
	let owner = Owner::new();
	run_with_owner(owner, || {
		count.effect(|value: i32| print(value));
	});
	count.set(1);
	count.set(2);
}

Signals

A Signal<T> is a mutable cell whose readers can subscribe to changes.

Signal::new(value: T): Signal<T>       // a fresh signal
signal.get(): T                        // current value
signal.set(value: T)                   // write + notify subscribers
signal.set_with(transform: sync |T| T) // read-modify-write in one step

Signals hold values. vilan copies, so get hands you a copy, and the only way to change what subscribers see is set or set_with. To update a list inside a signal, transform it:

import std::print;
import std::reactive::Signal;

fun main() {
	let items: Signal<List<str>> = Signal::new([]);
	items.set_with(|list| {
		mut updated = list;
		updated.push("first");
		updated
	});
	print(items.get().len());
}

(If you tried items.get().push("first"), you’d be mutating a copy. The memory model chapter explains why that’s a feature.)

Derived state: map, combine, flatten

Build state as a graph and let it recompute itself:

  • signal.map(transform) gives a signal of the transformed value:

    import std::print;
    import std::reactive::Signal;
    fun main() {
    	let count = Signal::new(2);
    	let doubled = count.map(|n: i32| n * 2);
    	print(doubled.get());
    	count.set(5);
    	print(doubled.get());
    }
    
  • combine((a, b, …)) gives a signal of the tuple of several signals’ values. It fires when any of them changes. Takes two or more.

  • nested.flatten() on a Signal<Signal<U>> follows whichever inner signal is current, and detaches from a replaced one.

import std::print;
import std::reactive::{ Signal, combine };

fun main() {
	let first = Signal::new("Ada");
	let last = Signal::new("Lovelace");
	let full = combine((first, last)).map(|pair: (str, str)| {
		let (a, b) = pair;
		a + " " + b
	});
	print(full.get());
	first.set("Grace");
	print(full.get());
}

A named function can stand in for the closure (signal.map(parse)) — see functions & closures.

Reacting: effect and sub

Two ways to run code on change. Use effect by default.

  • signal.effect(observer) runs the observer now with the current value, re-runs it on every change, and cleans itself up automatically when its surrounding UI (or other owner) goes away. Nothing to remember.
  • signal.sub(observer): Subscription is the manual version. It fires only on later changes, and you keep the Subscription and call dispose() on it yourself.

Ownership: who cleans up

Every effect is a subscription, and subscriptions must die when the thing that created them goes away — otherwise a page you navigated off keeps reacting forever. That’s a memory leak in any reactive system. vilan’s answer is owners, and the good news is that in normal app code you never manage them: the UI layer creates owners exactly where subtrees can die (a mounted root, a list row, a conditional block), and every effect you create automatically registers with the nearest one.

For tests, or when you’re building your own machinery:

  • Owner::new() makes an owner; owner.dispose() disposes everything registered with it.
  • run_with_owner(owner, || …) runs a block with that owner ambient. Every effect inside — however deep in function calls — registers into it.
  • get_owner() reads the ambient owner, e.g. to attach custom cleanup with owner.defer(…).
import std::print;
import std::reactive::{ Signal, Owner, run_with_owner };

fun main() {
	let source = Signal::new(0);
	let owner = Owner::new();
	run_with_owner(owner, || {
		source.effect(|value: i32| print(value));
	});
	source.set(1);
	owner.dispose();
	source.set(2); // not printed: the effect died with its owner
}

Creating reactive state outside any owner is a compile error. That sounds strict, but it’s the property that makes leaks impossible by construction, and in practice mount_root already gave you an owner before your first line of UI code ran.

Going deeper. Ownership flows through the context mechanism (functions & closures): the owner_scope context carries the current owner, and closure parameters marked context owner_scope receive it invisibly. comp runs a block under a fresh owner and returns (result, owner) — it’s the primitive under mount_root.

Turns: when changes become visible

If an event handler sets five signals, you want watchers to see the final state once, not five intermediate states. vilan batches writes into turns. Inside a turn, set just records. When the turn settles, each affected watcher runs once with the final values:

click ──▶ the handler runs inside a fresh turn
          │
          │  count.set(1)    ┐
          │  items.set(…)    │   writes are recorded, not delivered
          │  count.set(2)    ┘
          │
          └─ the handler's sync part ends → the turn SETTLES
                 │
                 ├─▶ the count watcher runs once   (sees 2 — never 1)
                 └─▶ the items watcher runs once

one turn  =  one consistent wave, no matter how many writes

You mostly never manage turns, because the framework opens them at its boundaries: every UI event handler runs in one, every mount_root build runs in one, and every rpc handler on the server runs in one. This is like React’s automatic batching, generalized.

For the rare explicit cases:

turn(policy, || …)   // run a block in a fresh turn; an awaiting body HOLDS it
batch(|| …)          // join the current turn, or create one
flush()              // drain the ambient turn early

Going deeper. Suspension is where the shapes differ. An explicit turn adapts to its body: a synchronous body settles when it ends (the atomic turn), and an awaiting body holds every notification — before the first await and in every continuation — until the whole body finishes, then settles once: a true transaction. A boundary turn around a fire-and-forget handler (a UI event) can’t wait for the handler’s continuations, so it settles at the end of each synchronous stretch — one wave per segment:

handler:              |── writes ──|─── await ───|── writes ──|

boundary turn:                   settle ▲              settle ▲
(a UI event)                     (wave 1)              (wave 2)

turn, awaiting body:                                   settle ▲
                                                     (one wave)

Writes that land after a turn already settled (from spawned work) are grouped per continuation segment and drained in a microtask, so you never observe half a wave.

Optimistic writes and local-first drafts

Two ready-made lifecycles for “update the UI now, confirm with the server after”. They differ in what happens on failure, and the difference is the point:

optimistic(signal, value, commit) paints the value immediately, runs your async commit, and on failure rolls back. Use it for one-shot actions like a delete button — if the delete failed, the row should come back.

draft(initial, commit) is for editing. It keeps the user’s text on failure (rolling back mid-typing would eat their input) and retries naturally on the next push. Bind an input to a draft and every keystroke can safely commit through an rpc:

struct Draft<T> {
	local: Signal<T>,          // bind inputs to this
	state: Signal<DraftState>, // Synced | Dirty | Failed(str)
	…
}
draft(initial: T, commit: async |T| Option<str>): Draft<T>
draft.push(value)   // set local + spawn the commit (never waits on the wire)
draft.adopt(remote) // fold in a remote change

The commit closure returns None on success or Some(reason) on failure, so an rpc-calling closure drops straight in.

The whole lifecycle in one picture — note that the input never waits on the wire, and every remote change funnels through adopt’s three rules:

you type ──▶ local (Signal) ──▶ the input shows it INSTANTLY
                │
                └─ push: spawn the commit ──▶ rpc ──▶ server
                                                        │
                          the mirror broadcasts  ◀──────┘
                                │
                             adopt(remote):
                    ├─ same as last synced?  an ECHO — do nothing
                    ├─ local has no edits?   take the remote value
                    └─ local is DIRTY?       your text wins for now
import std::print;
import std::reactive::{ draft, Draft, DraftState };
import std::option::Option::{ self, Some, None };
import std::shared::Shared;

fun main() {
	let saved: Shared<List<str>> = Shared::new([]);
	let name = draft("seed", |value: str| {
		saved.write().push(value);
		None
	});
	name.push("edit");         // local is "edit" immediately
	print(name.local.get());
	name.adopt("edit");        // the server echoing it back: no-op
	name.adopt("remote-edit"); // a genuine remote change: adopted (local is clean)
	print(name.local.get());
}

Going deeper. push is per-keystroke safe: a generation counter means a slow older commit that lands late is discarded rather than clobbering a newer one. adopt follows three rules — an echo of your own push changes nothing, a clean local adopts the remote edit, and a dirty local wins (last-write-wins: the remote value is remembered so your eventual push knowingly overwrites it). The reactive reference states all of it precisely, and bind_draft in Building UI is the input-side wiring.

Keyed reconciliation

reconcile(old_keys, old_items, new_items, key) computes a minimal update plan for keyed lists (keep this row, refresh that one, these are gone). It’s the pure engine underneath ui’s bind_each. You’d only call it directly to build your own list-rendering primitive.

Traps

  • sub gives you a Subscription to dispose manually. Prefer effect and let the owner handle it.
  • Disposal stops future deliveries. A watcher already queued in the currently-settling turn may fire one final time.
  • Derived signals (map/combine) live as long as their sources, by design. They don’t need owners, and they don’t leak into disposed subtrees.

Building UI

std::ui is a declarative view layer with no virtual DOM. A View describes a DOM element. Methods chain to build it. Where React re-runs components and diffs the result, vilan binds individual DOM properties to signals — when a signal changes, exactly that text node or attribute updates and nothing else runs.

Available in browser builds (target = "browser" in vilan.toml, or vilan build --target browser).

import std::ui::{ view, View, mount_root };
import std::reactive::Signal;

fun main() {
	let count = Signal::new(0);
	let _root = mount_root("app", || {
		view("div")
			.child(view("p").bind_text(count.map(|n: i32| i"clicked {n} times")))
			.child(view("button").text("+1").on("click", || count.set_with(|n| n + 1)))
	});
}

Read that top to bottom: make a div, give it a paragraph whose text follows the counter, give it a button that bumps the counter. That’s the whole mental model.

Views

view(tag) makes a fresh element. Methods chain, and each returns the view so you can keep going:

  • Static content: .text(content), .class(name), .attr(name, value), .styled(style) (see Styling).
  • Structure: .child(view), .children(views).
  • Events: .on(event, handler), or .on_event(event, |e| …) when you need the DOM event itself (prevent_default, key(), modifiers).
  • Reactive bindings: .bind_text(signal), .bind_class(signal), .bind_attr(name, signal), .style_var(name, signal).

Every bind_* sets the property now and re-sets it whenever the signal changes. There is no render loop to trigger.

Components are just functions

A “component” is a function that returns a View. No registration, no special types, no props system — parameters are the props:

import std::ui::{ view, View, mount_root };
import std::reactive::Signal;

fun labelled_input(label: str, value: Signal<str>): View {
	view("label")
		.text(label)
		.child(view("input").bind_value(value))
}

fun main() {
	let name = Signal::new("");
	let _root = mount_root("app", || labelled_input("Name", name));
}

mount_root(id, body) builds the body and attaches it to the page element with that id. It also establishes the root owner, which is why you never think about cleanup: every binding you create, at any depth of function calls, registers with the nearest owner automatically (the reactive guide explains owners).

If you build UI outside any root you’ll get a compile error mentioning owner_scope. It means “wrap this in mount_root” (or run_with_owner in a test).

Events run in turns

Each event dispatch runs your handler inside a fresh turn: all the signal writes one click causes are batched, and watchers see the final state once. Handlers die with their DOM node, so there is nothing to unsubscribe.

.on("click", || count.set_with(|n| n + 1))
.on_event("keydown", |pressed| {
	if pressed.key() == "Enter" { submit(); }
})

Inputs

Two ways to wire an <input>, for two different situations:

bind_value(signal) is the simple two-way bind: the input shows the signal, typing writes it back. Use it for local state — a search box, a “new item” field.

bind_draft(draft) binds the input to a local-first draft whose commit is typically an rpc. Typing updates the input instantly and commits in the background. A remote update folds in without re-sending. An echo of your own edit never moves the caret. Use it for fields that edit server state as you type:

import std::ui::{ view, View, mount_root };
import std::reactive::{ draft, Draft, DraftState };
import std::option::Option::{ self, Some, None };

fun main() {
	let name = draft("initial", |value: str| {
		let _would_send = value; // an rpc call in a real app
		None
	});
	let _root = mount_root("app", || {
		view("div")
			.child(view("input").bind_draft(name))
			.child(view("span").bind_text(name.state.map(|state: DraftState| match state {
				DraftState::Synced => "",
				DraftState::Dirty => "saving…",
				DraftState::Failed(let reason) => i"failed: {reason}",
			})))
	});
}

Lists: bind_each

bind_each(source, key, render) renders one row per element of a Signal<List<T>>. Rows are keyed, like React’s key prop, and the key does real work here:

  • A row whose key survives a change is reused. Its element moves to the new position with its state and subscriptions intact.
  • A row whose key survives but whose value changed re-renders just that row (that’s why T: PartialEq).
  • Removed rows are disposed properly — each row is its own owner, so a row’s bindings die with the row.
import std::ui::{ view, View, mount_root };
import std::reactive::Signal;

[derive(PartialEq)]
struct Todo {
	id: i32,
	title: str,
}

fun main() {
	let todos: Signal<List<Todo>> = Signal::new([
		Todo { id = 1, title = "write docs" },
	]);
	let _root = mount_root("app", || {
		view("ul").bind_each(todos, |todo| todo.id, |todo| {
			view("li").text(todo.title)
		})
	});
}
fun bind_each<T: PartialEq, K: PartialEq>(
	self,
	source: Signal<List<T>>,
	key: sync |T| K,
	render: (sync |T| View) context owner_scope,
): View

Conditionals: show, when, swap

Three primitives. Pick by what should happen to the content while it’s not visible:

Content while offStateUse for
.show(condition)mounted, hiddenpreservedtabs, collapsibles — anything that should keep its input text
.when(condition, body)unmounted, disposeddroppedcontent that shouldn’t exist while off (an editor for a missing record)
.swap(source, render)previous subtree disposed on changeper-valuepages on a route signal, any value-driven subtree
.show(open)                             // Signal<bool>
.when(present, || task_editor(…))       // Signal<bool> + (|| View)
.swap(route, |current| match current {  // Signal<T> + (|T| View)
	Route::Home => home_page(),
	Route::NotFound => not_found(),
})

when and swap build their content under a fresh owner each time, so everything inside cleans up when the content goes away. swap re-renders only when the value actually changes (T: PartialEq), so navigating to the page you’re already on does nothing.

The ownership picture

Here is the whole cleanup model in one picture. Owners exist at the places marked — the boundaries where a subtree can die. Every binding registers with the nearest boundary above it, no matter how many plain function calls sit in between:

◆ mount_root("app", …)                the root owner — lives forever
│
├── view("header")                     static: no boundary of its own
│     └─ .bind_text(title)             → registers with the ROOT
│
├── ◆ .swap(route, |page| …)           one owner PER PAGE shown
│     └─ home_page()
│           └─ .bind_text(…)           → registers with the PAGE
│
└── ◆ .bind_each(todos, key, |t| …)    one owner PER ROW
      ├─ row(id = 1)
      │     └─ .bind_class(…)          → registers with ROW 1
      └─ row(id = 2)
            └─ .on("click", …)         → dies with ROW 2's DOM node

Navigate away, and the page’s owner is disposed — every binding the page created dies with it. Delete row 2, and only row 2’s bindings die. This is why there is no unsubscribe code anywhere in a vilan app: the tree of boundaries is the cleanup logic, and the framework already placed them where subtrees end.

Server-side rendering

The same component code runs on the server. On @process (a node build) std::ui builds an HTML string instead of live DOM, and render(view) serializes it — first paint and SEO, before any JavaScript (A7, proposal/ssr.md). A route handler calls your own app() and splices the markup into its HTML shell.

import std::ui::{ view, View, render };
import std::reactive::Signal;
import std::print;

fun greeting(name: Signal<str>): View {
	view("p").class("greeting").bind_text(name)
}

fun main() {
	let name = Signal::new("world");
	print(render(greeting(name)));
	// <p class="greeting">world</p>
}

Two rules make one component serve both legs:

  • Bindings read once. bind_text, bind_attr, bind_each, when, and swap embed the signal’s value at render time — no subscription is created, and nothing survives the request (create, serialize, discard). Build pure, bind reactive: a component that leans on effect side-channels at build time renders stale. Text and attribute values are escaped, so a hostile string is inert.
  • No mount/mount_root on the server. Mounting is a client entry, not a renderable view, so the natural factoring is a shared fun app(): View with a per-leg main: mount_root("app", app) in the browser, render(app()) on the server. Event handlers (on) are accepted and discarded — a server-rendered <button> is just a button. std::dom stays browser-only, so a component reaching for raw DOM cannot SSR; the cross-platform error says so at the import.

Escaping to the DOM

View is a thin wrapper over std::dom::Element (it’s right there as view.element). For anything the chain doesn’t cover, use std::dom directly: get_element_by_id, query_selector, element.set_attribute, and so on. See the browser reference.

Traps

  • show keeps bindings live while hidden — they keep firing. If the hidden content is expensive, use when.
  • bind_value fights remote updates (every keystroke overwrites). For server-backed fields, use bind_draft.
  • The owner_scope compile error means you built UI outside every boundary. Wrap the entry point in mount_root.
  • Don’t create owners per element or per component function. Boundaries belong where subtrees can die: roots, rows, conditionals. The framework already puts them there.

Styling

std::style gives you typed, checked CSS without writing a stylesheet. You build a Style value in code, the compiler evaluates it during the build and writes real CSS rules into your bundle’s .css file, and at runtime the style is nothing but a set of class names on an element.

If you’ve used Tailwind, the feel is similar — small composable pieces, a spacing scale, color ramps — except the pieces are typed function calls, so a typo is a compile error instead of a silently-ignored class.

import std::ui::{ view, View, mount_root };
import std::style::{ style, space, Style, Color, Length, Display, FlexDirection };

let card = const style()
	.display(Display::Flex)
	.flex_direction(FlexDirection::Column)
	.gap(space(2))
	.padding(space(4))
	.radius(space(1))
	.background(Color::gray(100));

fun main() {
	let _root = mount_root("app", || {
		view("div").styled(card).child(view("p").text("hello"))
	});
}

The model

  • style() starts an empty style. Every method fills one property and returns the new style, so you chain.
  • Styles are built inside const — that’s the compile-time evaluation prefix (see Macros & const). The rules are emitted during the build.
  • view.styled(card) puts the style’s classes on the element.

At runtime you can still select and combine styles you already built. a + b merges two styles (per property, the right side wins), and picking one of two styles in an if is fine. What you can’t do is construct new rules at runtime — a bare style() chain outside const is a compile error. That restriction is what keeps the CSS static and the bundle predictable.

let button = const style().padding_x(space(3)).radius(space(1));
let primary = const button + style().background(Color::blue(600)).color(Color::white());

Values

  • space(step) is the spacing scale: space(1) is 0.25rem, and the steps grow like Tailwind’s. It’s the usual argument to padding, gap, margin, and radius.
  • Length covers everything else: Length::px(1.0), Length::rem(1.5), Length::pct(50.0), Length::auto(), and Length::var("--w") for a CSS variable (see dynamic values below).
  • Color has Color::white(), Color::black(), Color::transparent(), Color::hex("#663399"), and stepped ramps like Color::gray(300), Color::blue(600), Color::red(500), Color::green(500).
  • Keyword properties use enums: Display, Position, FlexDirection, AlignItems, JustifyContent, TextAlign, Cursor, Overflow.

For anything the typed surface doesn’t cover, escape hatches:

.raw("font-family", "system-ui, sans-serif")
.with_length("scroll-margin-top", space(4))
.with_color("outline-color", Color::blue(300))

States and breakpoints

Hover, focus, and friends take an inner style. Everything in the inner style applies under that condition:

let button = const style()
	.background(Color::blue(600))
	.hover(style().background(Color::blue(500)))
	.focus(style().raw("outline", "2px solid"))
	.disabled(style().opacity(0.5));

Available: .hover, .focus, .active, .disabled, .first, .last, .dark (dark mode via prefers-color-scheme), and .pseudo(name, inner) for anything else. Breakpoints work the same way: .sm(inner), .md(inner), .lg(inner), .xl(inner), or .media(min_width, inner).

Dynamic values

Styles are static, so how does a progress bar grow? Through CSS custom properties. The style declares a variable, and the element binds the variable to a signal with style_var:

import std::ui::{ view, View, mount_root };
import std::style::{ style, Style, Length, Color };
import std::reactive::Signal;

let bar = const style()
	.height(Length::rem(0.5))
	.width(Length::var("--progress"))
	.background(Color::green(500));

fun main() {
	let progress = Signal::new("40%");
	let _root = mount_root("app", || {
		view("div").styled(bar).style_var("--progress", progress)
	});
}

The rule is compiled once. Only the variable’s value changes at runtime. This one channel covers most “dynamic styling” needs; for the rest, bind_class swaps between prebuilt styles.

Going deeper. Each property-under-a-condition becomes one atomic CSS rule with a generated class name, deduplicated across the whole build — two styles that both say padding(space(4)) share one class. styled sets class_list(), the space-joined class names. A breakpoint can’t wrap another media-conditioned style (you’ll get a compile-time panic saying so).

Traps

  • A style() chain outside const fails with an “emission outside const” error. Build styles in const, select and merge them at runtime.
  • + is a per-property override, not CSS specificity. The right operand’s value replaces the left’s for the same property and condition.
  • .class(name) and .styled(style) both set the class attribute, so the later call wins. Use one mechanism per element (custom classes can ride along via .raw).

Full method table: the style reference.

Routing

Routers you’ve used probably match URL pattern strings: "/w/:id/task/:tid". vilan’s router doesn’t. Routes are enums. You describe your app’s pages as an enum, write one function that parses a path into it and one that prints it back, and the type system takes it from there. Every link targets a page that exists. Every page receives exactly the parameters it declares. When you add a page, the compiler points at every match that now needs to handle it. A pattern-string router can’t promise any of that.

std::router supplies the primitives: the live path signal, navigate, the link helper, and segments for parsing.

The route model

Here’s a small two-level app: a home page, and workspace pages that have their own sub-pages.

import std::ui::{ view, View, mount_root };
import std::router::{ current_path, navigate, segments, link, Routable };
import std::reactive::Signal;
import std::option::Option::{ self, Some, None };

[derive(PartialEq)]
enum Route {
	Home,
	Workspace(i32, WorkspaceRoute),
	NotFound,
}

[derive(PartialEq)]
enum WorkspaceRoute {
	Overview,
	Task(i32),
}

// The inverse pair. `parse` consumes `segments(path)`; `href` prints the
// same shape back. Keep them adjacent — they must agree.
fun parse(path: str): Route {
	let parts = segments(path);
	if parts.len() == 0 {
		ret Route::Home;
	}
	if parts[0] == "w" && parts.len() >= 2 {
		match parts[1].parse_i32() {
			Some(let id) => {
				if parts.len() == 2 {
					ret Route::Workspace(id, WorkspaceRoute::Overview);
				}
				if parts.len() == 4 && parts[2] == "task" {
					match parts[3].parse_i32() {
						Some(let task) => ret Route::Workspace(id, WorkspaceRoute::Task(task)),
						None => {},
					}
				}
			},
			None => {},
		}
	}
	Route::NotFound
}

fun href(route: Route): str {
	match route {
		Route::Home => "/",
		Route::Workspace(let id, let inner) => match inner {
			WorkspaceRoute::Overview => i"/w/{id}",
			WorkspaceRoute::Task(let task) => i"/w/{id}/task/{task}",
		},
		Route::NotFound => "/",
	}
}

impl Route with Routable {
	fun to_path(self): str {
		href(self)
	}
}

fun main() {
	let route = current_path().map(parse);
	let _root = mount_root("app", || {
		view("div").swap(route, |current| match current {
			Route::Home => view("h1").text("Home"),
			Route::Workspace(let id, let _inner) => view("h1").text(i"Workspace {id}"),
			Route::NotFound => view("h1").text("Nothing here"),
		})
	});
}

That’s the whole pattern. Yes, parse is more code than a pattern string. In exchange it’s ordinary code: type-checked, debuggable, and free to do things pattern strings can’t (validation, aliases, redirects). Now the pieces one at a time.

The live path becomes a route signal

current_path() is a Signal<str> of location.pathname. It stays current across navigate calls and the browser’s back/forward buttons. Derive your typed route from it once:

let route = current_path().map(parse);

(Passing parse by name instead of |p| parse(p) is the named-function coercion from the tour.)

Pages swap on the route

View.swap(route, render) is the page container. When the route changes, it tears down the old page (disposing all its bindings) and builds the new one. When the route doesn’t change — say the user clicks a link to the page they’re on — nothing happens at all. That’s why route enums derive PartialEq.

Nesting works the way you’d hope: the workspace page can swap on its own WorkspaceRoute while the outer swap only rebuilds when the workspace id changes.

link(label, route) renders a real <a href=…>. Middle-click, ctrl-click, and copy-link-address all behave like a normal link. Only a plain left click is intercepted and turned into an in-app navigation. And because it takes your route enum rather than a string, a dead link is a compile error:

link("← All workspaces", Route::Home)
link(task.name, Route::Workspace(workspace_id, WorkspaceRoute::Task(task.id)))

For programmatic navigation (after a sign-out, after creating a thing):

navigate(href(Route::Home));

navigate joins the caller’s current turn, so a handler’s state changes and the page change land together as one update.

When someone loads /w/3/task/7 fresh, the request goes to your server, which has to answer with the app shell no matter the path. That’s the standard history-API fallback, and the catch-all in your http handler does it:

serve_service(4000, protocol, |request| {
	match request.path() {
		"/client.js" => …,
		"/client.css" => …,
		_ => …app shell html…,   // every route serves the shell
	}
})

On the client side, a deep-linked page usually needs data that hasn’t synced yet. Mount it under when(present) so it appears when the data does — see Services & RPC.

Traps

  • Keep parse and href next to each other and test them as a pair. Their agreement is the one thing the type system can’t check for you. A parse that drops a segment silently turns a working deep link into a NotFound.
  • segments already forgives trailing and duplicate slashes (they produce no segment). Don’t special-case them in parse.
  • Query strings and hash fragments aren’t modelled yet (a deliberate deferral). segments sees only the pathname.

Services & RPC

This is the chapter where vilan’s full-stack story comes together. The short version: you write one ordinary struct on the server, mark a few things on it, and you get a typed client, live data sync, and reconnect handling without writing any protocol code.

A service is that struct. Three attributes do the work:

  • [service(ClientName)] on the struct names the generated client type.
  • [rpc] on a method makes it callable from the client.
  • [expose] on a Signal<T> field mirrors it: every connected client gets a live copy that updates when the server writes it.

No REST endpoints, no fetch calls, no JSON shapes to keep in sync by hand. The compiler knows both sides.

Here’s a complete little server:

import std::print;
import std::reactive::Signal;
import std::json::json_codec;
import std::http::Response;
import std::rpc_server::serve_service;
import std::shared::Shared;

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	text: str,
}

[service(NotesClient)]
struct Notes {
	[expose] entries: Signal<List<Note>>,
	next_id: Shared<i32>,
}

impl Notes {
	[rpc]
	fun add(self, text: str): i32 {
		let id = self.next_id.read();
		self.next_id.write() = id + 1;
		self.entries.set_with(|list| {
			mut updated = list;
			updated.push(Note { id = id, text = text });
			updated
		});
		id
	}
}

fun main() {
	let notes = Notes {
		entries = Signal::new([]),
		next_id = Shared::new(1),
	};
	serve_service(4000, notes.dispatcher().into_protocol(json_codec()), |request| {
		Response::builder().body("app shell here").build()
	}, || print("listening on :4000"));
}

And a client. NotesClient::connect gives you an object whose exposed fields are live local signals and whose rpc methods are ordinary calls that return Result:

import std::print;
import std::reactive::Signal;
import std::json::json_codec;
import std::result::Result::{ self, Ok, Err };
import std::shared::Shared;

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	text: str,
}

[service(NotesClient)]
struct Notes {
	[expose] entries: Signal<List<Note>>,
	next_id: Shared<i32>,
}

impl Notes {
	[rpc]
	fun add(self, text: str): i32 {
		let id = self.next_id.read();
		self.next_id.write() = id + 1;
		id
	}
}

async fun main() {
	match NotesClient::connect("/", json_codec()) {
		Ok(let client) => {
			// The mirror: fires on every server-side change, on every client.
			let _sync = client.entries.sub(|list: List<Note>| print(list.len()));
			// An rpc call: implicitly awaited, Result-typed.
			match client.add("hello") {
				Ok(let id) => print(id),
				Err(let error) => print(i"rpc failed: {error.debug()}"),
			}
		},
		Err(let error) => print(i"connect failed: {error.debug()}"),
	}
}

In a real app the service lives in its own module, next to the resources its bodies use, and the client entry imports the generated NotesClient from it — see Where the service lives below for why the browser build may do that, and the walkthrough for the full shape.

What can cross the wire: Wire

Everything that travels — rpc parameters, return types, mirrored payloads — must be serializable, which vilan calls Wire. The scalars are Wire (bool, the integers including i53, floats, str). List and Option of Wire types are Wire. And your own types opt in with a derive:

[derive(Wire, PartialEq, Debug)]
struct Note { id: i32, text: str }

That triple is the standard shape for payload types: Wire to travel, PartialEq because mirrors and UI reconciliation compare values, and Debug for error paths.

derive(Wire) checks every field recursively. A closure or a Signal hiding inside a payload type is a compile error at the derive, which is exactly where you want to find out.

The codec is chosen at connect time: json_codec() for a readable wire, binary_codec() for a compact one. Client and server must use the same one.

What rpc calls do

  • On the client they return Result<T, RpcError> and are implicitly awaited, like any async call.
  • RpcError tells you what went wrong: Transport (couldn’t reach the server), Decode, or Remote (the handler failed). Errors are values. Look at them and decide.
  • At connect time, both sides compare a hash of the service’s shape. If a stale client meets a redeployed server, the connect fails cleanly instead of calls corrupting halfway. This is the contract check.
  • On the server, each handler runs inside a turn, so all the signal writes one rpc makes are broadcast as a single consistent update.
  • Handler bodies can await — call another service, sleep_for, wait on I/O. The reply is sent when the body finishes, and the turn holds across the awaits: writes before and after a suspension still coalesce into that same single update.

Mirrors

The [expose] mirror is the piece that replaces most “fetch on mount, refetch on focus, invalidate on mutation” client code. The server writes its signal whenever and however it likes. Every connected client’s copy updates. That’s it.

Three patterns follow from it:

  • Derive views locally. Expose one tasks list and let each page map it down (filter by workspace, sort by date). Don’t add an rpc per view.
  • Mutate via rpc, observe via mirror. Your create/delete handlers write the server signal. The confirmation the user sees is their own change arriving back through the mirror.
  • Edit through drafts. Bind text inputs to drafts whose commit is the rpc, and adopt mirror updates into them. Typing stays instant, remote edits fold in, and your own echoes are no-ops.

Connection state and reconnection

Connections drop. The transport handles it: it reconnects with backoff, re-verifies the contract, and re-attaches every mirror, so state resyncs on its own. Your code sees two things.

First, a signal you can bind a banner to:

let state = client.transport.connection_state();
view("p").text("reconnecting…")
	.show(state.map(|current| current == ConnectionState::Reconnecting))

Second, honest call failures. A call in flight when the connection drops rejects with “connection lost”. A call made while down fails immediately with “not connected”. Nothing is silently retried, because an rpc might not be safe to repeat. Retrying is the app’s decision — a draft’s next push, or the user pressing the button again.

Going deeper. The backoff dials at 250 ms doubling to a 4 s cap, ten attempts before giving up (Closed). Mirrors rebind by re-running the contract check and re-attaching each subscription; you never re-subscribe manually. The full state machine is in the rpc reference.

Authentication

The straightforward shape, proven in the kolt pilot: a login rpc returns a token, later rpcs take the token as their first parameter, and the server validates it per call.

[rpc]
fun login(self, username: str, password: str): AuthOutcome { … }

[rpc]
fun create_task(self, token: str, workspace_id: i32, name: str): i32 { … }

When token-per-call gets noisy, the recorded refinement is connection-scoped identity via std::context. It isn’t built into the generated dispatch yet.

Where the service lives

The service lives next to the resources its methods use — a database handle, the filesystem, other services. In a single-package app (one [package] with an [entry.client] and an [entry.server] — see Platforms), that’s just a module both entries can see:

// src/store.vl — bodies use server std directly
[service(TodoClient)]
struct TodoStore { … }

// src/client.vl
import pkg::store::TodoClient;

In a multi-package workspace the same idea reads: the service sits in the server package, and the client package depends on it, importing just the generated client (import server::store::TodoClient;).

Either way, the browser build takes only the stub and the contract hash from that module; the method bodies and the dispatcher are server-colored and out of its reach. A shared common library is still a fine home for the payload types both sides speak — it’s just no longer the only legal home for anything.

The server side

serve_service(
	port,
	service.dispatcher().into_protocol(json_codec()),
	|request| …,       // http fallback: serve assets + the app shell
	|| …,              // on_ready
)

dispatcher() is generated by [service]. The fallback answers every plain http request — serve client.js and client.css, and return the app shell for anything else so deep links work (see Routing). For custom per-connection state, drop down to serve_connected — see the rpc reference.

Traps

  • Mysterious contract-mismatch failures while developing usually mean an old server process is still holding the port. Check with ss -tlnp | grep <port> and kill it by pid.
  • The wire is value-semantic. A mirrored list is a fresh copy per update. Mutate through rpcs, never by writing the client’s mirror signal.
  • An rpc handler’s reply is its return value, so the handler runs to completion before the client hears back. Long work belongs in spawned tasks that write signals when done.

Persistence and the server

This chapter covers the server half of a full-stack app: SQLite via std::db, http serving via std::http, files via std::fs, and the process itself via std::process. These modules live in the process layer, so they’re available in node/deno/bun builds. The rpc layer that sits on top is Services & RPC.

SQLite: std::db

vilan ships with an embedded SQLite binding (node’s built-in SQLite underneath). There is no ORM and no query builder. You write SQL, with ? placeholders for values:

import std::print;
import std::db::{ Database, Statement, Row };
import std::option::Option::{ self, Some, None };

fun main() {
	let db = Database::open("app.db");
	db.exec("CREATE TABLE IF NOT EXISTS task (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		name TEXT NOT NULL,
		created_at INTEGER NOT NULL
	)");

	let id = db.prepare("INSERT INTO task (name, created_at) VALUES (?, ?)")
		.run(["write docs", 1720656000000i53]);
	print(id);

	match db.prepare("SELECT * FROM task WHERE id = ?").first([id]) {
		Some(let row) => print(row.text("name")),
		None => print("missing"),
	}

	for row in db.prepare("SELECT * FROM task").all([]) {
		let row_id = row.integer("id");
		let name = row.text("name");
		print(i"{row_id}: {name}");
	}
}

The whole surface fits in a few lines:

  • Database::open(path) opens or creates the file. ":memory:" gives you a throwaway in-memory database, handy in tests.
  • db.exec(sql) runs DDL and one-off statements.
  • db.prepare(sql) gives a Statement. Then .run(params) executes and returns the last insert id, .first(params) fetches an Option<Row>, and .all(params) fetches a List<Row>.
  • Rows read by column name: text, integer (i32), big_integer (i53 — use it for epoch-millis timestamps, which outgrow i32), real (f64), and is_null.

Two habits to keep:

  • Values always go through ? placeholders. Never interpolate them into the SQL string.
  • Don’t name a column with an SQL keyword. desc is the one that bites in practice — spell it description.

The API is synchronous, which fits rpc handlers (the dispatch path is synchronous too), and there is no connection pool to manage.

Serving http: std::http

For an rpc app, serve_service (from the services guide) owns the port, and you only supply the http fallback for plain requests. The plain server underneath is usable on its own too:

import std::print;
import std::http::{ Server, Request, Response };

fun main() {
	Server::builder()
		.port(8080)
		.on_request(|request| {
			match request.path() {
				"/health" => Response::builder().body("ok").build(),
				_ => Response::builder()
					.code(404)
					.set_header("Content-Type", "text/plain")
					.body("not found")
					.build(),
			}
		})
		.on_start(|server| print(i"listening at {server.url()}"))
		.build()
		.start();
}

Request gives you path(), method(), and body(). Responses come from a builder: .code(i32) (200 by default), .set_header(name, value), .body(str), .build().

Here is the standard full-stack fallback. It serves the client bundle and answers every other path with the HTML shell, so deep links load (see Routing):

|request| match request.path() {
	"/client.js" => Response::builder().set_header("Content-Type", "text/javascript").body(client_js).build(),
	"/client.css" => Response::builder().set_header("Content-Type", "text/css").body(client_css).build(),
	_ => Response::builder().set_header("Content-Type", "text/html").body(app_html).build(),
}

Files: std::fs

fun exists(path: str): bool               // sync — boot code can branch on it
fun read_file_to_str(path: str): str      // async (implicitly awaited), UTF-8
fun write_file(path: str, contents: str)  // async

The typical server reads the client bundle and shell into memory once at boot, then serves from the strings.

The process: std::process

fun args(): List<str>          // CLI arguments (vilan run app.vl -- …)
fun env(key: str): Option<str> // an environment variable
fun exit(code: i32)            // end the process
fun scan(): str                // a line from stdin

One behavior to plan around: the process exits when main finishes. A server stays alive because start() holds the event loop open. A long-lived client process (a probe holding a socket, say) has to keep main open itself — await something that ends with the app, or sleep_for a long duration.

Putting it together

The boot sequence of a kolt-shaped server, in order:

  1. Database::open, then exec the schema (CREATE TABLE IF NOT EXISTS …).
  2. Load the mirrored state from SQLite into the service’s signals.
  3. Wire the service’s handlers to statements. Write SQL first, then update the signal — the mirror broadcasts the signal.
  4. fs::read_file_to_str the client bundle and shell.
  5. serve_service(port, protocol, fallback, on_ready).

The ordering inside step 3 matters. Persist first, then update the signal. That way a crash between the two can never broadcast state that was never stored.

Server-side rendering

A single-page app ships an empty <div id="app"> and paints nothing until its JavaScript loads, runs, and fetches. That costs first paint and it costs SEO — a crawler sees a blank page. Server-side rendering fixes both by doing the first render on the server: the page arrives already painted, and the client takes over from there.

vilan’s model is render, then replace. There is no hydration — the client does not adopt the server’s DOM. It renders the same view fresh and swaps it in.

  1. Render. The server calls your own view-building code against the process layer’s std::ui, which builds an HTML string instead of live DOM. render(view) serializes it.
  2. Serve. The handler splices that markup into an HTML shell and serves the page. The user (and the crawler) sees the full content — first paint and SEO are done, before a line of JavaScript runs.
  3. Replace. On boot the client builds the same view as live DOM and mount clears the container first, replacing the server markup with the live tree. Since the same component produced both, the swap is imperceptible when the data matches; when it moved between render and boot, the replace simply shows the truth.

One component, both legs

The whole trick is a shared fun app(): View. It imports std::ui, which resolves per platform: the browser layer (live DOM) in the client build, the process layer (an HTML string tree) in the server build — the same source, no annotation. Put it in a common library both packages depend on.

import std::ui::{ view, View, render };
import std::reactive::Signal;
import std::print;

// The one component both legs build.
fun app(): View {
	let tasks: Signal<List<str>> = Signal::new(["Render on the server", "Replace on boot"]);
	view("main")
		.child(view("h1").text("Tasks"))
		.child(view("ul").bind_each(tasks, |task| task, |task| view("li").text(task)))
}

// On the server, `render` turns the view into markup.
fun main() {
	print(render(app()));
	// <main><h1>Tasks</h1><ul><li>Render on the server</li><li>Replace on boot</li></ul></main>
}

On the server: render and splice

The server reads the client bundle and an HTML shell from disk, then serves. The shell has a marker where the app goes; the handler replaces it with the render. The splice is plain user code — one str::replace, no framework:

import std::fs;
import std::http::{ Server, Request, Response };
import std::ui::{ view, View, render };

fun app(): View {
	view("main").child(view("h1").text("Tasks"))
}

fun main() {
	let client_js = fs::read_file_to_str("dist/client.js");
	let shell = fs::read_file_to_str("server/src/app.html");
	Server::builder()
		.port(8791)
		.on_request(|request| {
			match request.path() {
				"/client.js" => Response::builder().set_header("Content-Type", "text/javascript").body(client_js).build(),
				// Create, serialize, discard: a fresh render per request, spliced
				// into the shell. No effects, no subscriptions survive the response.
				_ => Response::builder().set_header("Content-Type", "text/html").body(shell.replace("<!--ssr-->", render(app()))).build(),
			}
		})
		.build()
		.start();
}

The shell puts the marker inside the mount point:

<div id="app"><!--ssr--></div>
<script type="module" src="/client.js"></script>

On the client: mount replaces

The client entry is the shared app() and one mount_root. There is no server mount — mounting is a client entry, not a renderable view, which is exactly why the natural factoring is a shared fun app(): View with a per-leg main.

import std::ui::{ view, View, mount_root };
import std::reactive::Signal;

fun app(): View {
	let tasks: Signal<List<str>> = Signal::new(["Render on the server", "Replace on boot"]);
	view("main")
		.child(view("h1").text("Tasks"))
		.child(view("ul").bind_each(tasks, |task| task, |task| view("li").text(task)))
}

fun main() {
	// mount_root clears the container (the server markup) and mounts the live UI.
	let _root = mount_root("app", || app());
}

mount/mount_root call replaceChildren() before appending, so the live tree replaces the server-rendered nodes rather than stacking on top of them. For an ordinary client-only app the container was empty, so the clear is a no-op; on an SSR page it discards the server DOM and mounts fresh. No adoption, no node addressing, no reconciliation.

Build pure, bind reactive

The server render creates, serializes, and discards — no effects attach, no subscriptions survive the request. So the bindings read once: bind_text, bind_attr, bind_each, when, and swap embed the signal’s value at render time. That is the value served, and it is the value the client re-derives.

The rule this implies: build pure, bind reactive. A component that computes its structure from signals and binds them renders correctly on both legs. A component that leans on an effect side-channel at build time renders stale on the server (effects don’t run there). Text and attribute values are escaped, so a hostile string is inert markup, not injected HTML.

The double-fetch, honestly

v1 embeds no initial state. A data-backed app still fetches its data over rpc on boot exactly as a client-only app does — so the data is fetched once for the server render and again on the client. A change between the two shows as a content update when the client replaces (the honest behavior, not a mismatch error).

Serializing the initial store into the page and adopting it client-side — so the first client fetch is skipped — is a planned next slice (proposal/ssr.md §6c), held back because it introduces a cross-process state contract v1 does not need. The end state beyond that is resumability (A7b): the server serializes the reactive graph and handlers so the client executes nothing at boot. Render and replace is the fallback that remains under both.

Try it

examples/ssr is the whole loop in three small packages — common (the shared app()), client (browser), and server (node):

vilan run examples/ssr
# open http://localhost:8791/ — view source shows the rendered markup

View the page source and you will see the list already in the HTML, before any script runs. Load it in a browser and the client boots and replaces it in place.

A full-stack walkthrough

Every guide so far taught one layer. This chapter builds a whole app, so you can see the layers meet: Notes — sign in, a note list that syncs live between browser windows, and an editor that saves as you type.

The finished app lives in the repo at vilan/examples/walkthrough/, about 500 lines in one package. Every snippet below is quoted from those files, and the test suite builds the app on every run, so this chapter can’t quietly rot. To run it:

cd vilan/examples/walkthrough
vilan run .             # builds both entries, starts the server
                        # → http://localhost:4600

Open two browser windows side by side. Sign in, add a note in one window, and watch it appear in the other. Open a note and type — the other window follows keystroke by keystroke.

The shape

[package]
name = "notes"

[entry.client]
target = "browser"

[entry.server]
walkthrough/
  vilan.toml
  src/
    client.vl     the browser entry
    server.vl     the node entry
    store.vl      the service, next to its database
    notes.vl      the types that cross the wire
    routes.vl     the route enum
    views.vl      the UI
    app.html      the shell the server serves

One package, two entries (Platforms introduced this layout). There is no client/server directory split and no shared common package — every file is visible to both entries, and the compiler sorts out what may run where by what each entry reaches. store.vl uses SQLite freely because only the server entry calls into it; if client code ever reached that far, the build would fail with the call chain.

The data flows in one loop, and the whole app hangs off it:

you type → rpc → server writes SQL → server writes its signal
        → the mirror updates every client → the UI re-renders one binding

Your own edit comes back to you the same way everyone else’s does. There is no “local state vs server state” bookkeeping — the mirror is the state, and drafts smooth over the last inch (the input you’re typing in).

The wire types

src/notes.vl declares the payloads both sides speak. This is most of the client/server contract — there is no schema file, no endpoint list, no client SDK to regenerate:

[derive(Wire, PartialEq, Debug)]
struct Note {
	id: i32,
	title: str,
	body: str,
}

The service: next to its resources

src/store.vl is the heart of the app. Its database is module-level — opened once at startup, process-lifetime, closed only when the process ends:

// Process lifetime: opened once, never dropped (a serve-forever server's
// `Database` is exactly this). Every method reaches it by loan.
let db: Database = open_database();

[service(NotesClient)]
struct NotesStore {
	[expose] notes: Signal<List<Note>>,
}

Why module-level, and not a field on NotesStore? A Database is a resource: it has a single owner, it moves rather than copies, and it closes itself when its owner’s scope ends. A struct that owns a resource is itself a resource — and [service] generates a dispatcher that captures the store into one closure per [rpc] method, which a resource can’t be (a closure capturing a resource is the double-owner bug the class exists to prevent). So the long-lived database lives at module scope, and the store holds only the reactive state it exposes. Each [rpc] body reaches db directly, by loan:

[rpc]
fun retitle_note(self, token: str, note_id: i32, title: str): i32 {
	match session_user(db, token) {
		Some(let _user) => {
			db.prepare("UPDATE note SET title = ? WHERE id = ?").run([title, note_id]);
			self.notes.set_with(|list| list.map(|note| {
				if note.id == note_id {
					Note { id = note.id, title = title, body = note.body }
				} else {
					note
				}
			}));
			note_id
		},
		None => 0 - 1,
	}
}

Three things worth pausing on:

  • A module-level resource is loan-only. db.prepare(...) and session_user(db, ...) borrow it; the compiler rejects taking ownership away — moving it (let mine = db) or drop(db) — because process-lifetime state has no scope to be handed off to. It simply lives for the whole run.
  • No injected hooks. The service used to be forced into a shared package that couldn’t name Database, so its methods called closures the server installed at boot. Platform coloring removed the need: the service lives with its database, the browser build takes only the generated stub and contract hash from this module, and the bodies stay server-side because only the server entry reaches them (Services & RPC).
  • The order matters. Persist first, then update the signal. The signal write is what broadcasts to every client, so a crash between the two can never announce state that was never stored (Persistence covers this).

Title and body commit separately (retitle_note and rewrite_note): the editor uses one draft per field, and per-field rpcs mean one field’s edit never re-sends the other’s text.

Auth is register-or-login in one rpc: an unknown username creates the account (pbkdf2-hashed password), a known one checks it, and either path opens a session row whose token identifies later calls (Services & RPC). open_database() creates the tables at startup; boot() loads the mirror once from the already-open database.

The server entry: read, boot, serve

src/server.vl is now the boring file, which is the point:

async fun main() {
	let client_js = fs::read_file_to_str("dist/client.js");
	let client_css = fs::read_file_to_str("dist/client.css");
	let app_html = fs::read_file_to_str("src/app.html");

	let store = boot();

	serve_service(4600, store.dispatcher().into_protocol(json_codec()), |request| {
		match request.path() {
			"/client.js" => Response::builder().set_header("Content-Type", "text/javascript").body(client_js).build(),
			"/client.css" => Response::builder().set_header("Content-Type", "text/css").body(client_css).build(),
			_ => Response::builder().set_header("Content-Type", "text/html").body(app_html).build(),
		}
	}, || print("notes server listening on http://localhost:4600"));
}

The catch-all serves the shell for every unknown path. That’s what makes deep links like /note/7 load (Routing). The client bundle it ships is dist/client.jsvilan build compiles browser entries first, so the file is always fresh by the time the server entry builds.

The client entry: four signals and a mount

src/client.vl is the whole wiring diagram:

async fun main() {
	let notes: Signal<List<Note>> = Signal::new([]);
	let token = Signal::new(storage::get("notes-token"));
	let route = current_path().map(parse);

	match NotesClient::connect("/", json_codec()) {
		Ok(let client) => {
			let _sync = client.notes.sub(|list| notes.set(list));
			let _root = mount_root("app", || screen(client, notes, token, route));
		},
		Err(let error) => print(i"connect failed: {error.debug()}"),
	}
}

Read it as: mirror in, token from localStorage (a reload stays signed in), the typed route derived from the URL, connect, mount. NotesClient comes from import pkg::store::NotesClient; — the same module whose bodies run SQL on the server, of which this build sees only the stub. Everything after this line is just views reading those signals.

Routes

src/routes.vl is the enum-router pattern from Routing, at its smallest:

[derive(PartialEq)]
enum Route {
	Home,
	Note(i32),
	NotFound,
}

plus parse and href as the inverse pair, and pages that swap on route.

The views

src/views.vl has three layers, each one guide’s idea:

The gate. The sign-in panel shows while the token is empty; the routed app shows once it isn’t. Signing in stores the token; signing out removes it and navigates home.

The list page. An add form bound to a local signal, and the list itself — one keyed bind_each over the mirror:

.child(view("ul").bind_each(notes, |note| note.id, |note| note_row(client, note, token)))

That single line is the live sync. When any client adds or deletes a note, the mirror updates and the keyed rows reconcile (Building UI).

The editor. The note page finds its note in the mirror, waits for it under when(present) (so a deep link shows “loading…” until the first sync), and then the editor binds one draft per field:

let title = draft(seed_title, |value: str| commit_outcome(client.retitle_note(token.get(), note_id, value), note_id));
let body = draft(seed_body, |value: str| commit_outcome(client.rewrite_note(token.get(), note_id, value), note_id));

// Remote edits (another session's typing — or our own echo) fold in.
entry.effect(|current: Option<Note>| {
	match current {
		Some(let note) => {
			title.adopt(note.title);
			body.adopt(note.body);
		},
		None => {},
	}
});

view("div")
	.child(view("input").styled(field).attr("placeholder", "Title…").bind_draft(title))
	.child(view("span").styled(muted).bind_text(title.state.map(state_text)))
	…

This is the local-first loop from Reactive state closed end to end: typing updates the input instantly, each keystroke commits through its rpc, the server broadcasts, and the adopt in the effect folds remote changes in. Your own echo changes nothing. Another session’s edit updates your field — unless you’re mid-edit, in which case your text wins until it commits. There is no Save button because there is nothing left for one to do.

Things to try

  • Two windows. Type a title in one and watch the other follow. Then type in both fields at once, one per window.
  • Kill the server (Ctrl-C) with the app open. The “reconnecting…” banner appears (one show on the transport’s state signal). Restart the server: the banner clears and the mirror resyncs by itself.
  • Restart the server and reload: the notes are still there. SQLite did that, not the mirror.
  • Deep-link to a note (/note/1) in a fresh window: “loading…” flashes until the first sync, then the editor seeds.
  • Cross the platform line. Add load_notes(self.db) — or any pkg::store call — somewhere the client entry reaches, and rebuild: the error names the whole chain from main down to the SQL.

Where each idea came from

In this appTaught in
the package, its two entriesHello vilan, Platforms
Note, derives, the enumsData & traits
signals, effects, draftsReactive state
views, bind_each, when, showBuilding UI
the const stylesStyling
the route enum, swap, linkRouting
[service], mirrors, reconnectServices & RPC
SQLite, the fallback, boot orderPersistence

From here, the honest next step is to change something: add a created_at: Instant to Note (the compiler will walk you through every place it matters), or add a second entity. The shape you’d follow is exactly the one above.

The dev loop

You built an app in the walkthrough. This chapter is about iterating on it — the edit-save-see loop. vilan run --watch on a full-stack workspace closes that loop with hot module replacement (HMR): save a source file and the running browser app updates in place, reactive state intact, without a full page reload.

vilan run --watch .

On a workspace with a browser leg this prints one extra line at startup:

hmr: dev channel on 127.0.0.1:35917

That is the dev channel — a tiny local endpoint the browser connects back to. From then on every save rebuilds all legs and the channel tells the browser exactly what changed. Nothing new to learn and no separate dev command: run --watch already means “the dev loop”.

What each edit does

Change detection is by output bytes, not by guessing from the source: each save rebuilds every leg, and the artifacts are compared. That makes the verdict exact.

You edited…What happens
Client codeThe browser bundle changes → a swap: the new bundle is evaluated in place, module state carried across (below). No reload.
A stylesheet onlyJust the CSS sidecar changed → the stylesheet is hot-swapped, no reload, no swap — the page doesn’t even flicker.
Server codeThe server bundle changed → the Node process restarts. The browser stays connected; its live rpc mirror reconnects on its own (the same backoff that survives a server crash) and resyncs from the server’s current values.
Shared code (a common library both legs use)Both bundles change → the server restarts and the browser swaps. The fresh client dials the new contract, so a changed rpc shape never leaves a stale client talking to a new server.
A file with a mistakeThe compile error shows in the terminal and as an in-page overlay — the real file, line, and message — while the running app keeps its last good build. Fix it and the next good save clears the overlay and swaps normally.

A server-only edit pushes nothing to the browser — the client is unaffected, so it isn’t disturbed. That the Node leg restarts rather than hot-swapping is deliberate: the process is cheap and a fresh start is always correct, so there is no server-side HMR to reason about.

The error overlay carries the real diagnostics — the file, the line:col, the message, and any note — the same text your terminal shows, rendered over the page so the eyes already on the browser don’t miss it. The terminal stays authoritative; the overlay is the copy, and the next successful save clears it.

What carries across a swap, and what resets

A swap re-evaluates the whole client bundle. Two things survive it:

  • Module-level state. Every top-level binding is carried across by its key (package::module::name) and a fingerprint of its type. A plain-data binding carries its value; a module-level Signal or Shared carries its payload into a fresh cell. So mut count and a module-level list signal keep their live contents while you edit the view that renders them.
  • Everything the server holds. The server doesn’t swap — it restarts with its state in SQLite (or wherever it lives), and the client’s mirror resyncs. In a full-stack app that is most of your durable state, which is why the swap can afford to be simple about the rest.

Top-level bindings like these keep their live values while you edit the view that renders them:

import std::dev;
import std::reactive::Signal;

// Carried across every swap by key + type. Edit main's body, save, and these
// hold their values — only the view re-runs.
mut opened = 0;
let recent: Signal<List<str>> = Signal::new([]);

fun main() {
	opened = opened + 1;
	recent.set(["home"]);
}

What resets is state minted inside functions during mount — an ephemeral signal created in a component, the focused element, scroll position, half-typed text not yet pushed. Fine-grained reactivity gives these no stable identity to reattach to, so v1 lets them go. A plain browser refresh is the always-available complete reset.

The initializer-edit rule. Editing a binding’s initializer without changing its type keeps the live value — the new initializer does not run:

mut counter = 0;      // edit this to `mut counter = 100`, save…
// …and `counter` stays at whatever it had climbed to. During iteration the
// value you're watching *is* the work — this is the behavior every mainstream
// hot-reloader converged on.

Change the binding’s type, though, and the old value is the wrong shape: that binding fresh-initializes (a “fingerprint miss”), which is the correct answer, not a failure. To carry a value your edit reshapes anyway, or to carry something minted inside a function, reach for the manual channel — std::dev’s stash/take.

Escape hatches

  • --no-hmr — turn HMR off and get the plain restart-the-whole-app watch loop (exactly the pre-HMR behavior). Reach for it if a swap ever surprises you and you want the blunt instrument back.
  • --hmr-port <port> — the dev channel defaults to 35917; change it if that port is taken. --hmr-port 0 asks the OS for any free port and the startup line reports the one it got.
  • A browser refresh is always a full, clean reset — seed state lives only in the page’s heap, so reloading throws all of it away.

Picking which server to run

run (and run --watch) executes one Node leg. A workspace with a single node package needs no help — that one runs. A workspace with two or more node packages (say a server and a diagnostics probe) has to be told which, with --entry <name>:

vilan run --watch --entry server .

Without it, run stops and lists the candidates:

error: this workspace has more than one `node` package to run — pick one with --entry <name>: probe, server

The non-selected Node legs still compile as part of the workspace — their bundles land in dist/ and a shared edit still recompiles them — they just aren’t launched. Under --watch the browser legs hot-swap exactly as usual; the chosen server restarts on its own edits, and a change to a leg that isn’t running does nothing visible (its dist/ bundle refreshes, but nothing restarts). Which leg is the default is a per-workspace choice we may add to the manifest later; for now it’s the flag.

CSS hot-swap works by bumping the cache-buster on your stylesheet <link>, so it needs the stylesheet to be a <link> to dist/<leg>.css:

<link rel="stylesheet" href="/client.css">

An app that inlines its CSS into the page instead gets a full swap on a style change rather than the flicker-free stylesheet reload — still correct (the byte-diff classifies inlined CSS as a bundle change), just not as surgical. The <link> form is the one to prefer for the tightest loop.

Cleaning up strays

The swap disposes the UI root and closes the live rpc socket for you. Anything else a bundle started outside the reactive system — a raw interval, a bare task — keeps running after a swap unless you register a cleanup. That, plus the stash/take carryover channel and the hmr_active guard, is the whole of std::dev.

Collections — reference

The container types: List (built in), std::map::Map, std::set::Set, std::range::Range, and the std::iterator protocol underneath for.

List<T>

Built in, with literal syntax: [1, 2, 3]. An empty literal needs a type annotation (let xs: List<str> = [];).

impl List<type T> {
	fun new(): List<T>
	fun push(&mut self, item: T)
	fun pop(&mut self): Option<T>
	fun len(self): i32
	fun is_empty(self): bool
	fun map<U>(self, fn: |T| U): List<U>
	fun filter(self, predicate: |T| bool): List<T>
	fun fold<B>(self, init: B, fn: |B, T| B): B
	fun for_each(self, fn: |T| void)
}
impl List<type T: Add + Default> { fun sum(self): T }
impl List<type T: Mul + Default> { fun product(self): T }

Indexing is list[i]; iterate with for item in list (copies) or for e in &mut list (in-place views — see the memory model).

import std::print;

fun main() {
	let words = ["alpha", "beta", "gamma"];
	let lengths = words.map(|word| word.len());
	print(lengths.fold(0, |total, n| total + n));
	print(lengths.sum());
}

Map<K, V>

impl Map<type K: Hashable, type V> {
	fun new(): Map<K, V>
	fun insert(&mut self, key: K, value: V)
	fun get(self, key: K): Option<V>
	fun contains_key(self, key: K): bool
	fun remove(&mut self, key: K)
	fun len(self): i32
	fun is_empty(self): bool
	fun keys(self): List<K>
	fun values(self): List<V>
}

Keys compare by value. Scalars work directly; a struct, enum, tuple, or List key works as long as it is Hashable — derive it:

import std::print;
import std::map::Map;
import std::hash::Hashable;
import std::option::Option::{ self, Some, None };

[derive(Hashable)]
struct Point {
	x: i32,
	y: i32,
}

fun main() {
	mut seen: Map<Point, str> = Map::new();
	seen.insert(Point { x = 1, y = 2 }, "origin-ish");
	// A fresh, distinct Point with equal fields hits.
	match seen.get(Point { x = 1, y = 2 }) {
		Some(let label) => print(label), // origin-ish
		None => print("miss"),
	}
}

keys() returns the real Ks (in insertion order), and the key is snapshot on insert, so mutating the original afterward can’t desync the map.

Set<T>

impl Set<type T: Hashable> {
	fun new(): Set<T>
	fun insert(&mut self, value: T)
	fun contains(self, value: T): bool
	fun remove(&mut self, value: T)
	fun len(self): i32
	fun is_empty(self): bool
	fun values(self): List<T>
}

Value-keyed like Map (element T must be Hashable); for x in set iterates the elements in insertion order.

Hashable

A key’s value is turned into a Hash — a canonical key — by key.hash(). [derive(Hashable)] implements it for a struct/enum whose fields are all Hashable (scalars, str, bool, List/Option of Hashable, or another derived type); a closure, Set, Map, or Shared field is rejected. You can also hand-write impl Hashable to key by a subset of fields, and build your own container by bounding on K: Hashable and keying a Map<Hash, …> yourself.

One corner: a float inside an aggregate key canonicalizes through JSON, where NaN becomes null and -0/+0 collapse to 0, so those collide. Bare numeric keys don’t have this (they key by JS value directly).

Range

End-exclusive integer ranges, made for for:

Range::new(start: i32, end: i32): Range   // start..end, end excluded
range.next(&mut self): Option<i32>
import std::print;
import std::range::Range;

fun main() {
	mut total = 0;
	for i in Range::new(1, 5) {   // 1, 2, 3, 4
		total += i;
	}
	print(total);
}

Iterator

The protocol for consumes, and the seam for custom sequences:

trait Iterator<T> { fun next(self): Option<T>; }
trait Iterable<T> { fun iter(self): Iterator<T>; }
Iterator::from_fn(fn: || Option<T>): IteratorFromFn<T>   // an iterator from a closure

Anything implementing Iterator/Iterable works in a for loop — Range is exactly this.

Option & Result — reference

Option<T> is how vilan says “maybe a value” (there is no null), and Result<T, E> is how it says “this can fail” (there are no exceptions). Both are plain enums with a large helper-method surface, listed here. For how they replace null checks and try/catch in practice — including the ! and ?. operators — read Control flow first.

import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };

(The { self, … } form imports the type and its variants, so Some(x) works unqualified.)

Option<T>

enum Option<T> { Some(T), None }

impl Option<type T> {
	// predicates
	fun is_some(self): bool
	fun is_some_and(self, fn: |T| bool): bool
	fun is_none(self): bool
	fun is_none_or(self, fn: |T| bool): bool

	// extraction
	fun unwrap(self): T                      // panics on None
	fun unwrap_or(self, fallback: T): T
	fun unwrap_or_else(self, fn: || T): T

	// in-place partial move — read/replace the slot through `&mut self`,
	// always leaving a valid Option behind
	fun take(&mut self): Option<T>               // Some(v) -> None here, Some(v) out
	fun replace(&mut self, value: T): Option<T>  // value in, old contents out

	// transformation
	fun map<U>(self, fn: |T| U): Option<U>
	fun map_or<U>(self, fn: |T| U, fallback: U): U
	fun map_or_else<U>(self, fn: |T| U, fallback: || U): U
	fun map_or_default<U: Default>(self, fn: |T| U): U
	fun inspect(self, fn: |T| void): Self    // peek, pass through
	fun filter(self, predicate: |T| bool): Option<T>

	// combination
	fun and<U>(self, b: Option<U>): Option<U>
	fun and_then<U>(self, fn: |T| Option<U>): Option<U>
	fun or(self, b: Option<T>): Option<T>
	fun or_else(self, fn: || Option<T>): Option<T>
	fun xor(self, b: Option<T>): Option<T>
	fun zip<U>(self, peer: Option<U>): Option<(T, U)>

	// bridging
	fun ok_or<E>(self, err: E): Result<T, E>
	fun ok_or_else<E>(self, err: || E): Result<T, E>
}
impl Option<type T: Default> { fun unwrap_or_default(self): T }
impl Option<(type T, type U)> { fun unzip(self): (Option<T>, Option<U>) }

str.parse_i32(): Option<i32> (declared here) is the string→number path.

take and replace mutate the Option in place through &mut self: take swaps None in and hands the old contents back, replace swaps a new value in and hands the old back. Both leave a valid Option behind, which is what makes them the sanctioned way to move a value out of a place — for a resource this is the only legal partial move (self.slot.take()), and match opt.take() { Some(let c) => drop(c), None => {} } is the conditional-teardown idiom.

Result<T, E>

enum Result<T, E> { Ok(T), Err(E) }

impl Result<type T, type E> {
	// predicates
	fun is_ok(self): bool
	fun is_ok_and(self, fn: |T| bool): bool
	fun is_err(self): bool
	fun is_err_and(self, fn: |E| bool): bool

	// extraction
	fun unwrap(self): T                      // panics on Err
	fun unwrap_err(self): E                  // panics on Ok
	fun unwrap_or(self, fallback: T): T
	fun unwrap_or_else(self, fn: |E| T): T
	fun expect(self, message: str): T        // panic with your message
	fun expect_err(self, message: str): E

	// transformation
	fun map<U>(self, fn: |T| U): Result<U, E>
	fun map_err<F>(self, fn: |E| F): Result<T, F>
	fun map_or<U>(self, fn: |T| U, fallback: U): U
	fun map_or_else<U>(self, fn: |T| U, fallback: |E| U): U
	fun inspect(self, fn: |T| void): Self
	fun inspect_err(self, fn: |E| void): Self

	// combination
	fun and<U>(self, b: Result<U, E>): Result<U, E>
	fun and_then<U>(self, fn: |T| Result<U, E>): Result<U, E>
	fun or<F>(self, b: Result<T, F>): Result<T, F>
	fun or_else<F>(self, fn: |E| Result<T, F>): Result<T, F>

	// bridging
	fun ok(self): Option<T>
	fun err(self): Option<E>
}
impl Result<type T: Default, type E> { fun unwrap_or_default(self): T }
impl Result<Option<type T>, type E> { fun transpose(self): Option<Result<T, E>> }

Idioms

import std::print;
import std::option::Option::{ self, Some, None };
import std::result::Result::{ self, Ok, Err };

fun parse_port(text: str): Result<i32, str> {
	text.parse_i32()
		.ok_or(i"not a number: {text}")
		.and_then(|port| {
			if port > 0 && port < 65536 {
				Ok(port)
			} else {
				Err(i"out of range: {port}")
			}
		})
}

fun main() {
	print(parse_port("8080").unwrap_or(0));
	match parse_port("http") {
		Ok(let port) => print(port),
		Err(let reason) => print(reason),
	}
}
  • Prefer ! (propagate) and unwrap_or* over unwrapunwrap is for invariants, and it panics.
  • Application errors belong in Result’s E; only truly unreachable states panic.
  • match with Some(let x) / Ok(let x) patterns is always available when the method chain gets clever — clarity beats cleverness.

Strings — reference

The string type str (built in, immutable), plus the text-facing traits Display, Debug, and Into.

str

Concatenate with +. Interpolate with i"…{expr}…" (see Values and types).

impl str {
	fun len(self): i32
	fun is_empty(self): bool
	fun trim(self): str
	fun to_uppercase(self): str
	fun to_lowercase_ascii(self): str
	fun contains(self, needle: str): bool
	fun starts_with(self, prefix: str): bool
	fun ends_with(self, suffix: str): bool
	fun replace(self, from: str, to: str): str       // all occurrences
	fun repeat(self, count: i32): str
	fun split(self, separator: str): List<str>
	fun substring(self, start: i32, end: i32): str   // end-exclusive
	fun code_at(self, index: i32): u32               // UTF-16 code unit
	fun parse_i32(self): Option<i32>                 // declared in std::option
}
import std::print;

fun main() {
	let path = "/w/3/task/7";
	let parts = path.split("/").filter(|part| !part.is_empty());
	print(parts.len());
	print(parts[0].to_uppercase());
	print("task".repeat(2));
}

str also implements PartialEq/Ord (lexicographic ==, <) and Default ("").

Display — user-facing text

trait Display {
	fun to_string(self): str;
}
fun format<T: Display>(value: T): str

Implement Display for values that have a natural user-facing rendering; format(value) (from std::display) is the generic entry point. Interpolation accepts anything already str-shaped — call format/to_string explicitly for custom types.

Debug — developer-facing text

trait Debug {
	fun debug(self): str;
}

[derive(Debug)] generates a structural rendering (Point { x: 1, y: 2 } style) for structs and enums — the standard tool for logging and error paths (error.debug() on an RpcError).

Into — conversions

trait Into<T> {
	fun into(self): T;
}

The generic conversion seam: implement Into<Target> on a source type, bound helpers as T: Into<Target>. (Numeric width conversions don’t use this — they’re the as_* methods on the numbers; see numbers.)

Numbers — reference

The sized numeric family (std::number), generic min/max (std::math), and random values (std::random). Literal syntax and conversion semantics: Values and types.

The family

TypeWidthLiteral
i8 i16 i32 i53signedbare = i32; others suffixed (100i53)
u8 u16 u32 u53unsignedsuffixed (0xFFu8)
f64float2.5 or 10f
f32float2.5f32
BigIntarbitrary7n

i53/u53 are the wide integers, named for the precision they actually deliver: they are f64-backed on the JS backend, and every value in ±2^53 (JavaScript’s safe-integer window) is exact. There is no i64 — a type that silently loses precision past 2^53 would be lying about its width; for genuinely bigger integers use BigInt.

Literals are range-checked at compile time (an out-of-range i53 literal is a compile error, not a rounded value). Integer division truncates toward zero. No implicit width coercion — convert with as_*. Arithmetic that overflows a type’s range is undefined behavior (spec §7.2) — on JS it manifests as f64 artifacts; a checked add_safe family is recorded future work.

Methods

Integers (per type; shown for i32):

impl i32 {
	fun abs(self): i32
	fun pow(self, exponent: i32): i32
	fun min(self, other: i32): i32
	fun max(self, other: i32): i32
	fun rem(self, m: i32): i32     // the % operator's method
	fun diff(self, other: i32): i32
}

Floats add the usual math surface:

impl f64 {
	fun abs(self): f64
	fun sqrt(self): f64
	fun pow(self, exponent: f64): f64
	fun floor(self): f64
	fun ceil(self): f64
	fun round(self): f64
	fun min(self, other: f64): f64
	fun max(self, other: f64): f64
	fun sin(self): f64      // cos, tan, asin, acos, atan …
}

Every numeric type implements Default (zero), the operator traits, and comparison.

Conversions: as_*

Every numeric type converts to every other with Rust-as semantics — truncate toward zero (floats), fold two’s-complement into the target width (integers):

import std::print;

fun main() {
	print((3.9).as_i32());    // 3
	print((-1).as_u8());      // 255 — folded
	print((300).as_u8());     // 44
	let wide = 9007199254740992i53;
	print(wide.as_i32());
	print((255u8).as_f64() / 2.0);
}

Conversions on literals fold at compile time.

std::math

fun min<T: Ord>(a: T, b: T): T
fun max<T: Ord>(a: T, b: T): T
fun minmax<T: Ord>(a: T, b: T): (T, T)   // (smaller, larger)

std::random

fun range<T: Random>(low: T, high: T): T   // uniform in [low, high)
// implemented for i32, u32, f64
import std::print;
import std::random;

fun main() {
	let roll = random::range(1, 7);   // 1..=6
	print(roll >= 1 && roll <= 6);
}

Not cryptographic — for tokens and ids use std::crypto (random_uuid, random_bytes; see misc).

Core traits — reference

The traits behind the operators and the derive set: std::compare, std::default, std::operators.

std::compare

trait PartialEq<B = Self> {
	fun eq(self, b: B): bool;      // ==
	fun ne(self, b: B): bool;      // != (default: !eq)
}
trait Eq with PartialEq {}

enum Ordering { Less, Equal, Greater }

trait PartialOrd<B = Self> with PartialEq<B> {
	fun partial_compare(self, b: B): Option<Ordering>;
	fun lt(self, b: B): bool;      // <   (defaults over partial_compare)
	fun le(self, b: B): bool;      // <=
	fun gt(self, b: B): bool;      // >
	fun ge(self, b: B): bool;      // >=
}

trait Ord with Eq + PartialOrd {
	fun compare(self, b: Self): Ordering;
	fun min(self, b: Self): Self;
	fun max(self, b: Self): Self;
	fun clamp(self, min: Self, max: Self): Self;
}
  • ==/!= dispatch through PartialEq; </<=/>/>= through PartialOrd. Numbers, str, and bool implement them in std.
  • For your own types, [derive(PartialEq)] gives structural equality — the usual path. Implement PartialOrd/Ord by hand when ordering is meaningful (Instant does this in std).
  • The B = Self parameter allows cross-type comparison impls; you’ll rarely need it.

std::default

trait Default {
	fun default(): Self;
}

Zero for numbers, "" for str, false for bool. [derive(Default)] composes fields’ defaults. Used as a bound by helpers like unwrap_or_default and List.sum.

std::operators — the operator traits

Each operator dispatches through a trait; implement the trait, get the operator:

TraitOperatorTraitOperator
Add<B = Self>+Shl<<
Sub-Shr>>
Mul*BitAnd&
Div/BitOr|
Rem%BitXor^

The B = Self parameter types the right-hand side — mixed-operand impls are how std’s Instant + Duration works:

import std::print;
import std::operators::Add;

struct Celsius {
	degrees: f64,
}

impl Celsius with Add {
	fun add(self, b: Celsius): Celsius {
		Celsius { degrees = self.degrees + b.degrees }
	}
}

fun main() {
	let morning = Celsius { degrees = 20.5 };
	let rise = Celsius { degrees = 1.5 };
	print((morning + rise).degrees);
}

Compound assignment (+=, /=, …) rides the same impls. Note: the operator’s result type is Self (the left operand’s type).

std::operators — Try and Lift

The machinery behind ! and ?. (control flow):

enum Verdict<T, B> { Good(T), Bad(B) }

trait Try<T, B> {
	fun verdict(self): Verdict<T, B>;   // split into good/bad
	fun from_bad(bad: B): Self;         // rebuild from the bad half (for propagation)
}
trait Lift {}                           // opt-in marker for ?.

Option and Result implement both in std. A custom two-outcome type that implements Try gets !; adding the Lift marker gets ?..

Cells — reference

The two sharing tools: std::shared::Shared (one shared mutable cell) and std::arena::Arena (stable identities for graphs). When to reach for which: the memory model.

Shared<T>

A heap cell two places can hold at once — the escape hatch from value-semantics copying.

impl Shared<type T> {
	fun new(value: T): Shared<T>
	fun read(self): T                      // a COPY of the contents
	fun write(self): &mut T                // a writable view of the contents
}
import std::print;
import std::shared::Shared;

fun main() {
	let log: Shared<List<str>> = Shared::new([]);
	let record = |entry: str| {
		log.write().push(entry);
	};
	record("first");
	record("second");
	print(log.read().len());
}
  • read() copies: mutating the result is lost (shared.read().push(x) — the classic trap). Mutate through write().
  • write() returns a view — use it within the same statement (cell.write() = v, cell.write().push(item)); it obeys the usual view rules (no storing, no holding across await).
  • Copying the Shared value itself copies the handle — both handles see one cell. That’s the point.

Arena<T> + Handle<T>

A generational arena: insert values, get back small copyable Handle<T> keys. Handles are plain values — storable in struct fields and lists (which views are not), so nodes can reference each other:

struct Handle<T> { … }   // slot index + generation; copy freely

impl Arena<type T> {
	fun new(): Arena<T>
	fun insert(&mut self, value: T): Handle<T>
	fun get(&self, handle: Handle<T>): Option<&T> borrows self  // a view; None once removed
	fun set(&mut self, handle: Handle<T>, value: T): bool
	fun remove(&mut self, handle: Handle<T>): Option<T>
	fun contains(self, handle: Handle<T>): bool
	fun len(self): i32
	fun is_empty(self): bool
}
import std::print;
import std::arena::{ Arena, Handle };
import std::option::Option::{ self, Some, None };

struct Node {
	label: str,
	edges: List<Handle<Node>>,
}

fun main() {
	mut nodes: Arena<Node> = Arena::new();
	let a = nodes.insert(Node { label = "a", edges = [] });
	let b = nodes.insert(Node { label = "b", edges = [a] });
	// Close the cycle: a → b. `get` hands back a view, so copy it (`*node`),
	// edit the copy, and write it back with `set`.
	match nodes.get(a) {
		Some(let node) => {
			mut updated = *node;
			updated.edges.push(b);
			nodes.set(a, updated);
		},
		None => {},
	}
	print(nodes.len());
}
  • Generational means deletion-safe: removing a value and reusing its slot bumps a generation counter, so a stale handle gets None instead of aliasing the new occupant.
  • get returns a view (Option<&T>), second-class like any other: read through it, but it may not outlive an arena mutation or be stored. To change a value, copy it out (*view), edit, and set it back — or design nodes so edges/fields update independently.
  • Traversal is re-get per step — the arena stays mutable while you walk.

std::time — reference

Instants, durations, and timers. Both types are Wire — they ride rpc payloads (created_at: Instant in a mirrored record is the standard timestamp shape).

import std::time::{ now, Instant, Duration, sleep, sleep_for };

Instant

A moment in time — epoch milliseconds in an i53 under the hood.

fun now(): Instant                       // the current wall-clock moment

impl Instant {
	fun since(self, earlier: Instant): Duration   // self - earlier
	fun to_iso(self): str                         // ISO-8601, via the host clock
}
impl Instant with Add<Duration> { … }    // instant + duration → Instant
impl Instant with Sub<Duration> { … }    // instant - duration → Instant
impl Instant with PartialOrd { … }       // <, >, == between instants

Duration

impl Duration {
	// constructors
	fun millis(count: i53): Duration
	fun seconds(count: i53): Duration
	fun minutes(count: i53): Duration
	fun hours(count: i53): Duration
	fun days(count: i53): Duration
	// truncating accessors
	fun as_seconds(self): i53
	fun as_minutes(self): i53
	fun as_hours(self): i53
	fun as_days(self): i53
	// human text: "42 seconds", "3 hours" …
	fun describe(self): str
}
impl Duration with Add { … }             // duration + duration
impl Duration with Sub { … }
impl Duration with PartialOrd { … }

The "{age} ago" UI idiom:

import std::print;
import std::time::{ now, Instant, Duration };

fun main() {
	let started = now();
	let deadline = started + Duration::hours(2i53);
	print(deadline.since(started).describe());
	print(started < deadline);
}

Ordering two instants (or durations) is the </<=/>/>= operators dispatching through their PartialOrd impls — the same partial_compare you’d call by hand.

Timers

fun sleep(ms: i32)                  // suspend (async; callers implicitly await)
fun sleep_for(duration: Duration)
fun set_timeout(callback: || void, ms: i32)   // host setTimeout, fire-and-forget

Notes

  • Duration constructors take i53 — remember the suffix on computed literals (Duration::millis(500i53 * factor)); see gotchas.
  • now() is a host call, so it can’t be const-folded, and programs using it aren’t output-deterministic — keep it out of golden-file tests.
  • Wire format: an Instant serializes as its i53 millis — exact for any realistic date (i53 rides the wire as a float’s 53 bits, safe past year 200,000).

Encoding — reference

JSON (std::json), the codec-agnostic wire layer (std::wire), the binary codec (std::binary), raw bytes (std::bytes), and base64 (std::base64).

The short version: derive Json for JSON in/out at app boundaries, derive Wire for rpc payloads, and let the codecs do the rest. Everything below “Derives” here is plumbing you only meet when building custom transports or parsers.

JSON

trait Json { fun to_json(self): str; }        // encode
trait FromJson {                              // decode
	fun from_json(text: str): FromJson;
	fun from_json_value(value: JsonValue): FromJson;
}

[derive(Json)] implements both from a struct/enum’s shape; scalars, List, and Option nest.

Encoding (to_json) is total, but decoding is fallible: the input is untrusted, so a missing field, a wrong-shaped value, or text that isn’t JSON is a decode error rather than silent garbage or a crash. Both from_json(text) and from_json_value(value) return Result<Self, str> — handle it with !, match, or is Ok(..):

import std::print;
import std::json::{ Json, FromJson };
import std::result::Result::{ self, Ok, Err };

[derive(Json)]
struct Point {
	x: i32,
	y: i32,
}

fun main() {
	let point = Point { x = 1, y = 2 };
	let text = point.to_json();
	print(text); // {"x":1,"y":2}

	match Point::from_json(text) {
		Ok(let back) => print(back.x), // 1
		Err(let reason) => print(reason),
	}

	// A missing field is a decode error naming the field.
	match Point::from_json("{\"x\":1}") {
		Ok(_) => print("decoded"),
		Err(let reason) => print(reason), // missing field y
	}
}

Untyped inspection, when the shape isn’t known up front:

external struct JsonValue;
fun parse_json_value(text: str): JsonValue    // panics on bad JSON
str.try_parse_json(): Option<JsonValue>       // the safe form
value.field(name: str): JsonValue
value.tag(): str                              // "object" | "array" | "string" | …
value.elements(): List<JsonValue>
value.is_null(): bool

json_codec(): Codec is the JSON wire codec for rpc (see below).

The wire layer (std::wire)

The codec-agnostic serialization protocol under derive(Wire) and rpc:

  • trait Serialize / trait Deserialize — visitor-style value description (begin_struct/field/str_value/i53_value/…). The wire scalars: str, bool, i32, u32, i53, f64 (+ lists, options, structs, enum variants).
  • Frame — one encoded message.
  • Codec — a matched writer/reader pair: json_codec() (std::json, readable) or binary_codec() (std::binary, compact). Client and server must agree.

[derive(Wire)] requires every field to be Wire, recursively, checked at the derive site. You implement Serialize/Deserialize by hand only for types with a custom encoding.

Bytes

An immutable-length byte array (Uint8Array underneath) — the currency of the binary codec, crypto, and websockets:

impl Bytes {
	fun alloc(size: i32): Bytes
	fun len(self): i32
	fun get(self, index: i32): i32
	fun set(self, index: i32, value: i32)
	fun slice(self, from: i32, to: i32): Bytes
	fun fill(self, value: i32, from: i32, to: i32): Bytes
	fun copy_into(self, source: Bytes, offset: i32)
	fun concat(a: Bytes, b: Bytes): Bytes     // static
	fun to_hex(self): str
}

// UTF-8 text ↔ bytes
fun encode_utf(text: str): Bytes
fun decode_utf(bytes: Bytes): str

Lower still: ByteBuffer/DataView (host ArrayBuffer access, read_f64/write_f64) — the binary codec’s float channel.

Binary codec (std::binary)

fun binary_codec(): Codec
fun encode_binary<T: Wire>(value: T): Bytes
fun decode_binary<T: Wire>(bytes: Bytes): T
struct BinaryWriter { … }   // write_byte / write_i32 / write_str / finish(): Bytes

Same model as JSON, compact layout. i53 values ride as f64 bit patterns — exact to 2^53.

Base64 (std::base64)

URL-safe alphabet, no padding — the JWT flavor:

fun encode_url(bytes: Bytes): str
fun decode_url(text: str): Option<Bytes>

Networking — reference

Client HTTP (std::fetch) and websocket framing (std::ws). The rpc transports that ride these: the rpc reference.

std::fetch

A small builder over the host fetch. send, text, and friends are async, so callers implicitly await them:

fun get(url: str): Request
fun post(url: str, body: str): Request
fun post_bytes(url: str, body: Bytes): Response   // one-shot binary POST

impl Request {
	fun header(own self, name: str, value: str): Request   // chainable
	fun send(self): Response                               // async
}

impl Response {
	fun status(self): i32
	fun text(self): str        // async
	fun bytes(self): Bytes     // async (whole body)
}
import std::print;
import std::fetch;

fun main() {
	let response = fetch::get("https://example.com/api/health")
		.header("Accept", "application/json")
		.send();
	print(response.status());
	print(response.text());
}

Streaming a body chunk by chunk: response.body_stream().reader() + read_chunk() (finished()/payload() per chunk) — the SSE fallback transport uses this.

Note fetch is base-layer: it works in the browser and on node. For talking to your own vilan service, prefer the generated rpc client over hand-rolled fetch calls.

std::ws

Websocket framing — building and parsing raw frames. This is server-side plumbing (std::rpc_server uses it to speak websocket on a plain TCP socket); browser clients get sockets from the host via the rpc transport instead.

// Build frames
fun text_frame(text: str): Bytes
fun binary_frame(payload: Bytes): Bytes
fun pong_frame(payload: Bytes): Bytes
fun close_frame(): Bytes

// Parse a byte stream into events
enum WsEvent { Text(str), Binary(Bytes), Ping(Bytes), Close, … }
impl WsParser {
	fun new(): WsParser
	fun feed(self, chunk: Bytes): List<WsEvent>   // stateful; call per TCP chunk
}

WsParser.feed handles fragmentation and interleaved control frames; feed it whatever the socket delivers and act on the returned events.

std::reactive — reference

Signals, effects, ownership, turns, and the higher-level cells. Concepts and usage patterns: the reactive guide.

Import what you use:

import std::reactive::{
	Signal, Source, Subscription, Disposable, combine,
	Owner, owner_scope, get_owner, run_with_owner, comp,
	Turn, FlushPolicy, turn_scope, turn, batch, flush,
	optimistic, draft, Draft, DraftState,
	reconcile, ReconcilePlan, RowStep,
};

At a glance

ItemKindOne line
Signal<T>structmutable cell with subscribers
Source<T>traitanything readable + subscribable (get/sub/effect)
Subscriptionstructan explicit subscription; Disposable
combinefntuple-signal over 2+ signals
Ownerstructdisposal bag; the lifetime unit
run_with_owner, comp, get_owner, owner_scopefns/contextestablish/read the ambient owner
turn, batch, flush, FlushPolicy, turn_scopefns/contextwrite batching
optimisticfnpaint → commit → confirm-or-rollback
draft, Draft<T>, DraftStatefn/struct/enumlocal-first editing cell
reconcile, ReconcilePlan, RowStepfn/structskeyed list diffing engine

Signal

impl Signal<type T> {
	fun new(value: T): Signal<T>
	fun set(self, value: T)                 // write + notify
	fun set_with(self, transform: sync |T| T)    // read-modify-write
	fun map<U>(self, transform: sync |T| U): Signal<U>
}
impl Signal<type T> with Source<T> {
	fun get(self): T
	fun sub(self, observer: |T| void): Subscription
	// from the trait default:
	fun effect(self, observer: |T| void)    // fires now + on change; owner-registered
}
impl Signal<Signal<type U>> {
	fun flatten(self): Signal<U>            // follow the current inner signal
}
  • set notifies through the ambient turn when one exists (writes coalesce); outside any turn it notifies immediately.
  • map’s result is a live derived signal; its internal subscription is unowned (lives as long as the source).
  • effect requires an ambient owner — calling it outside every owner is a compile error (context coverage). It fires once immediately.
  • sub does not fire immediately, and its Subscription is yours to dispose (or hand to owner.take).

combine

fun combine<T: (2..)>(sources: (U in T: Signal<U>)): Signal<T>

A signal of the tuple of the sources’ current values, firing when any source changes. Variadic over tuples of signals of mixed element types:

import std::print;
import std::reactive::{ Signal, combine };

fun main() {
	let flag = Signal::new(true);
	let count = Signal::new(2);
	let both: Signal<(bool, i32)> = combine((flag, count));
	let (_on, current) = both.get();
	print(current);
}

(Bind the tuple before taking elements — chained access on a call result, both.get().1, doesn’t type yet; see the gotchas appendix.)

Subscription, Disposable

trait Disposable { fun dispose(self); }
struct Subscription { … }        // impl Disposable

Disposing a subscription guarantees no later deliveries; a delivery already queued in the currently-draining turn may still land once.

Owner

impl Owner {
	fun new(): Owner
	fun take<T: Disposable>(self, item: T): T   // adopt a disposable; returns it
	fun defer(self, cleanup: || void)           // run cleanup at dispose
}
impl Owner with Disposable {
	fun dispose(self)   // dispose everything collected + run defers
}

let owner_scope: Context<Owner>
fun get_owner(): Owner                                        // read the ambient owner
fun run_with_owner<T>(owner: Owner, body: (sync || T) context owner_scope): T
fun comp<T>(body: (sync || T) context owner_scope): (T, Owner)     // fresh owner + result

body parameters marked context owner_scope receive the ambient owner implicitly — your component functions thread ownership without mentioning it. Establish owners at disposal boundaries (places where a subtree can die), not per object; in UI code the framework’s boundaries (mount_root, bind_each rows, when/swap bodies) already do this.

Turns

enum FlushPolicy { AtEnd, AtSuspension }
let turn_scope: Context<Turn>

fun turn<T>(policy: FlushPolicy, body: (|| T) context turn_scope): T
fun batch<T>(body: (sync || T) context turn_scope): T   // join or create
fun flush()                                             // drain the ambient turn now

Inside a turn, signal writes are recorded and each subscriber runs once with final values when the turn settles. The body is asyncness-polymorphic (spec §7.4): a synchronous body settles at the end of its synchronous extent, and an awaiting body holds every notification until it fully completes — a transaction. Framework boundaries establish turns for you: UI event handlers and mount_root (AtSuspension), RPC service handlers (AtEnd). Writes landing after a settle (from spawned work) drain in per-segment microtasks.

optimistic

fun optimistic<T, E>(signal: Signal<T>, value: T, commit: async || Result<T, E>): Result<T, E>

Paint value into signal now, await commit, then reconcile: the confirmed value on Ok, the previous value rolled back on Err. Returns the outcome for error UX. For continuous editing, use draft instead — rollback is wrong mid-typing.

Draft — local-first cells

enum DraftState {
	Synced,       // local matches the last pushed/adopted value
	Dirty,        // local edits not yet confirmed (in-flight included)
	Failed(str),  // last push errored; local KEPT, not rolled back
}

struct Draft<T> {
	local: Signal<T>,           // bind inputs to this; read like any signal
	state: Signal<DraftState>,  // bind a status label to this
	…                           // internals: synced value, generation counter
}

fun draft<T: PartialEq>(initial: T, commit: async |T| Option<str>): Draft<T>

impl Draft<type T: PartialEq> {
	fun push(self, value: T)    // set local + SPAWN the commit (returns immediately)
	fun adopt(self, remote: T)  // fold in a remote value
}
  • commit returns None on success, Some(reason) on failure. The parameter is async-typed so an RPC-calling closure flows in directly; a plain synchronous closure works too.
  • push is per-keystroke-safe: local-first (never waits on the wire), and a generation counter ensures only the newest push settles state — a slow older commit landing late is discarded.
  • adopt rules: value equal to the last synced value (an echo of your own push) → no-op; clean local (no unpushed edits) → adopt into local; dirty local → local wins, the remote value is remembered so the eventual push knowingly overwrites (last-write-wins).
  • On failure, state carries the reason and local keeps the user’s text; the next push retries naturally.

UI wiring: View.bind_draft(draft) — see the browser reference.

reconcile — keyed list diffing

enum RowStep {
	Keep(i32),     // reuse old row at index (moved into the new order)
	Refresh(i32),  // same key, changed value: rebuild, dispose old index
	Fresh,         // a new row
}
struct ReconcilePlan {
	steps: List<RowStep>,  // one per NEW item, in the new order
	removed: List<i32>,    // old indices gone entirely
}
fun reconcile<T: PartialEq, K: PartialEq>(
	old_keys: List<K>, old_items: List<T>, items: List<T>, key_of: sync |T| K,
): ReconcilePlan

The pure engine under ui.bind_each; duplicate keys claim the first surviving row once. Reach for it directly only when building a custom list-rendering primitive.

std::style — reference

Typed, compile-time atomic styles. Concepts and the emission model: the styling guide.

import std::style::{
	style, space, Style, Length, Color,
	Display, Position, FlexDirection, AlignItems, JustifyContent,
	TextAlign, Cursor, Overflow,
};

Constructors and values

fun style(): Style                 // empty style; chain from here (inside a const)
fun space(step: i32): Length       // spacing scale: space(1) = 0.25rem

impl Length {
	fun px(value: f64): Length
	fun rem(value: f64): Length
	fun pct(value: f64): Length
	fun auto(): Length
	fun var(name: str): Length     // a CSS custom-property reference ("--w")
}

impl Color {
	fun white(): Color
	fun black(): Color
	fun transparent(): Color
	fun hex(value: str): Color     // "#663399"
	fun gray(step: i32): Color     // ramps: 50…900
	fun blue(step: i32): Color
	fun red(step: i32): Color
	fun green(step: i32): Color
}

Keyword enums: Display (Flex, Block, …), Position, FlexDirection, AlignItems, JustifyContent, TextAlign, Cursor, Overflow.

Style methods

Every method returns a new Style with one more property slot; each slot is one atomic rule, deduplicated build-wide.

Layout:

MethodValue
displayDisplay
positionPosition
flex_directionFlexDirection
align_itemsAlignItems
justify_contentJustifyContent
gap, padding, padding_x, padding_y, margin, margin_x, margin_yLength
width, height, max_width, min_heightLength
overflowOverflow

Appearance:

MethodValue
radiusLength
border(width: Length, color: Color)
background, colorColor
font_sizeLength
font_weighti32
line_heightf64
text_alignTextAlign
cursorCursor
opacityf64
transitionstr

Escape hatches:

fun raw(self, property: str, value: str): Style
fun with_length(self, property: str, value: Length): Style
fun with_color(self, property: str, value: Color): Style

Conditions

Each takes an inner Style and conditions all of its slots:

fun hover(self, inner: Style): Style
fun focus(self, inner: Style): Style
fun active(self, inner: Style): Style
fun disabled(self, inner: Style): Style
fun first(self, inner: Style): Style      // :first-child
fun last(self, inner: Style): Style       // :last-child
fun dark(self, inner: Style): Style       // prefers-color-scheme: dark
fun pseudo(self, name: str, inner: Style): Style

fun sm(self, inner: Style): Style          // breakpoints (min-width)
fun md(self, inner: Style): Style
fun lg(self, inner: Style): Style
fun xl(self, inner: Style): Style
fun media(self, min_width: str, inner: Style): Style

A breakpoint cannot wrap an already-media-conditioned style (panics at compile-time evaluation).

Construction emits rules and therefore lives in const; these do not emit and work anywhere:

style_a + style_b          // merge: per-property, right side wins (impl Add)
style.class_list(): str    // the space-joined class attribute (what `styled` uses)

std::rpc — reference

Transports, the generated service surface, errors, and connection state. Concepts and usage: the services guide. Most apps touch only the generated client, RpcError, and ConnectionState — everything else here is the machinery those sit on.

The generated surface ([service])

For [service(FooClient)] struct Foo with [rpc] methods and [expose] signal fields, the macro generates:

// client side
FooClient::connect(url: str, codec: Codec): Result<FooClient<SocketTransport>, RpcError>
client.some_rpc(args…): Result<T, RpcError>     // per [rpc] method; implicitly awaited
client.some_signal: Signal<T>                   // per [expose] field; a live mirror
client.transport: SocketTransport               // connection state lives here

// server side
foo.dispatcher(): Dispatcher                    // the method table
dispatcher.into_protocol(codec: Codec): RpcProtocol   // what serve_service takes

connect accepts a relative url ("/") in the browser; it dials the same host over WebSocket, waits for the server’s announcement, and verifies the contract hash — a drifted server fails the connect with RpcError::Contract.

Errors

[derive(Wire, Debug)]
enum RpcError {
	Transport(str),   // couldn't reach / lost the server ("not connected", "connection lost")
	Decode(str),      // reply didn't parse
	Remote(str),      // the handler failed
	Contract(str),    // connect-time shape mismatch (old client vs new server)
	Unauthorized,
}

Infrastructure failures only — an application “not found” belongs in the rpc’s own return type (Option<Task>), not here.

Connection state

enum ConnectionState { Connected, Reconnecting, Closed }

impl SocketTransport {
	fun connection_state(self): Signal<ConnectionState>
}

The reconnect lifecycle (automatic): on drop → Reconnecting, in-flight calls reject with Transport("connection lost"), new calls fail fast with Transport("not connected"); dial with backoff (250 ms doubling, 4 s cap, 10 attempts); on success → contract re-check, mirrors re-attach and resync, Connected. Backoff exhausted → Closed. Nothing is ever silently retried — retry is the app’s decision.

Transports

trait Transport {
	fun call(self, request: Frame): Task<Result<Frame, str>>;
}
TransportWireUse
SocketTransportWebSocket (reconnecting)what connect gives you — the production client transport
HttpTransportone POST per callstateless calls, no mirrors
LocalTransportin-processtests: client and service in one process

Below SocketTransport sits SocketDuplex (the reconnect-surviving socket: pending-call registry, inbound dispatch, on_reconnect hooks) and the DuplexTransport machinery (duplex_pair, bridge, connect_split for the SSE/split fallback). App code doesn’t construct these; the generated connect does.

fun connect_socket(url: str): Result<SocketDuplex, str>   // dial + announcement (backoff)
impl SocketDuplex {
	fun transport(self): SocketTransport
}

Server plumbing (std::rpc_server, process layer)

fun serve_service(
	port: i32,
	protocol: RpcProtocol,
	fallback: |Request| Response,   // plain-http requests: assets + app shell
	on_ready: || void,
)

serve_service = WebSocket upgrade + per-connection session registration (mirror attach/detach) + rpc dispatch, with fallback answering ordinary http. Each handler runs in a turn (AtEnd). For custom per-connection state (connection-scoped auth, an app-written attach), use serve_connected(port, protocol, on_connection, fallback, on_ready) — the same server with the session hook exposed.

Envelope & codec layer

Frame is the codec-agnostic unit (std::wire); encode_request / open_request / encode_reply read and write the rpc envelope ({"method": …, "args": […]} on the json codec). Codec comes from json_codec() (std::json) or binary_codec() (std::binary); both ends must use the same one. You only meet this layer when implementing a custom transport or protocol bridge.

Browser modules — reference

The browser layer of std: std::dom, std::ui, std::router, std::storage. Available only for browser builds. Concepts: Building UI, Routing.

std::dom

Opaque handles over real DOM objects.

external struct Element;
fun get_element_by_id(id: str): Element
fun create_element(tag: str): Element
fun query_selector(selector: str): Element
fun query_selector_all(selector: str): List<Element>

impl Element {
	fun set_text(self, text: str)                      // textContent =
	fun set_class(self, name: str)                     // className =
	fun set_attribute(self, name: str, value: str)
	fun set_style_property(self, name: str, value: str) // style.setProperty (CSS custom props)
	fun append(self, child: Element)
	fun remove(self)                                   // detach from the document
	fun clear(self)                                    // remove every child
	fun set_hidden(self, hidden: bool)
	fun value(self): str                               // an input's current text
	fun set_value(self, value: str)
	fun on(self, event: str, handler: || void)
	fun on_event(self, event: str, handler: |Event| void)
}

external struct Event;
impl Event {
	fun prevent_default(self)
	fun button(self): i32        // 0 = main button
	fun meta_key(self): bool
	fun ctrl_key(self): bool
	fun shift_key(self): bool
	fun alt_key(self): bool
	fun key(self): str           // "Enter", "Escape", "a", …
}

Raw element.on handlers do not establish a turn — that’s View.on’s job. Prefer the View layer; drop to dom for what it doesn’t cover.

std::ui

struct View { element: Element }
fun view(tag: str): View
fun mount(id: str, view: View)                                   // attach only
fun mount_root(id: str, body: (|| View) context owner_scope): Owner

mount_root = fresh owner + turn boundary + attach; it returns the root owner (most apps let it live forever). mount is the attach half alone — use only when you already hold a boundary.

View methods

MethodSignature (self elided)Notes
text(content: str): Viewstatic text
class(name: str): Viewstatic class
styled(style: Style): Viewclasses from a compiled style
attr(name: str, value: str): Viewstatic attribute
style_var(name: str, source: Signal<str>): Viewreactive CSS custom property
on(event: str, handler: (|| void) context turn_scope): Viewhandler runs in a fresh turn
on_event(event: str, handler: (|Event| void) context turn_scope): Viewsame, with the DOM event
child(child: View): Viewappend one
children(items: List<View>): Viewappend several
bind_text(source: Signal<str>): Viewreactive text
bind_class(source: Signal<str>): Viewreactive class
bind_attr(name: str, source: Signal<str>): Viewreactive attribute
bind_value(signal: Signal<str>): Viewtwo-way input bind
bind_draft(draft: Draft<str>): Viewlocal-first input bind (drafts)
bind_each(source: Signal<List<T>>, key: |T| K, render: (|T| View) context owner_scope): ViewT: PartialEq, K: PartialEqkeyed rows; each row is a disposal boundary
when(condition: Signal<bool>, body: (|| View) context owner_scope): Viewstate-DROPPING conditional
swap(source: Signal<T>, render: (|T| View) context owner_scope): ViewT: PartialEqdispose + rebuild per changed value
show(condition: Signal<bool>): Viewstate-PRESERVING visibility toggle

Semantics, choosing between show/when/swap, and examples: the UI guide.

std::router

fun current_path(): Signal<str>       // location.pathname, live (navigate + back/forward)
fun navigate(path: str)               // pushState + update current_path
fun segments(path: str): List<str>    // "/w/3/task/7" → ["w", "3", "task", "7"]

trait Routable { fun to_path(self): str }
fun link<R: Routable>(label: str, route: R): View   // a real <a>; intercepts plain left-clicks

current_path() is a singleton signal — every caller gets the same one, and the popstate listener is wired on first use. link renders a real anchor (middle-click, ctrl-click, and copy-link keep native behavior) and intercepts only a plain left click, calling prevent_default + navigate. Route modelling (parse/href over enums): the routing guide.

std::storage

localStorage / sessionStorage, string-keyed strings. A missing key reads as "".

fun get(key: str): str
fun set(key: str, value: str)
fun remove(key: str)
fun session_get(key: str): str
fun session_set(key: str, value: str)
fun session_remove(key: str)
import std::storage;

fun main() {
	storage::set("token", "abc");
	let token = storage::get("token");
	if token != "" {
		storage::remove("token");
	}
}

Dev / HMR — reference

std::dev is the app-facing surface of hot module replacement — the live-update loop vilan run --watch runs for a full-stack app (see The dev loop for the whole picture). Browser-only, and every hook here is a no-op outside a hot reload: importing it costs nothing in a production build, so you can leave the calls in place.

fun hmr_active(): bool                       // is a hot-reload session live?
fun on_teardown(cleanup: || void)            // run before the next swap
fun stash<type T>(key: str, value: T)        // carry plain data across a swap
fun take<type T>(key: str): Option<T>        // recover it (None outside HMR / on a miss)

hmr_active

True only while a run --watch browser round has installed its dev runtime; false in a normal build. Everything else in the module already guards on it, so you rarely call it directly — reach for it when you want a whole block to exist only during development.

import std::print;
import std::dev;

fun main() {
	if dev::hmr_active() {
		print("editing live — the dev channel is connected");
	}
}

on_teardown

Register a cleanup to run before the next hot swap re-evaluates the bundle. The swap’s teardown disposes the UI root and closes the live rpc socket for you; on_teardown is the sanctioned patch for anything the swap can’t see on its own — a raw set_interval, a bare spawned task, a handle to something outside the reactive system. Without it, that stray keeps running after the swap: harmless (it writes into disposed cells) but wasteful.

import std::dev;
import std::shared::Shared;

let connection: Shared<bool> = Shared::new(true);

fun main() {
	// Something the reactive runtime doesn't own — close it before a swap so
	// the re-evaluated bundle starts from a clean slate.
	dev::on_teardown(|| connection.write() = false);
}

A plain browser refresh is always the complete reset — seed state lives only in the page’s heap — so on_teardown is about tidiness within a session, not correctness across one.

stash / take

The manual carryover channel — Vite’s hot.data, made type-safe. stash a value under a key before a swap; take it back in the re-evaluated bundle. Module-level bindings carry across a swap automatically (see the dev loop); stash/take is for the rest — a value minted inside a function that you want to survive one edit.

import std::print;
import std::dev;
import std::option::Option::{ self, Some, None };

fun main() {
	// On the first boot `take` is None; after a hot swap it returns what the
	// previous bundle stashed. `take` is non-destructive — the value stays put
	// for the swap after this one too (Vite's persistent `hot.data`).
	let seen: Option<i32> = dev::take("visits");
	let visits = match seen {
		Some(let n) => n + 1,
		None => 1,
	};
	print(i"loaded {visits} time(s) this session");
	dev::stash("visits", visits);
}

The transfer bound. T must be transferable-as-value: plain data the new bundle can adopt by reference without inheriting old code — scalars, str, List, Option, Result, tuples, and structs/enums built from them. A closure, a View, a resource, or a reactive cell (Signal/Shared) carries code or identity the new bundle can’t adopt, so stashing one is a compile error at the call site — the type system enforces what Vite leaves to convention:

let live: Signal<i32> = Signal::new(0);
dev::stash("live", live);
// error: `Signal<i32>` cannot cross a hot swap — a closure, view, resource,
// or reactive cell (`Signal`/`Shared`) carries code or identity the new
// bundle cannot adopt; stash only plain data

To carry the value inside a Signal, stash its payload — stash("n", signal.get()) — and re-seed a fresh cell from take on the other side. That is exactly what the compiler does for you for a module-level Signal binding; stash/take just gives you the same move by hand for the cases it can’t reach.

take returns None on a first boot, a plain browser refresh, or a key nothing has stashed. One honest caveat: unlike the automatic module-binding carryover — which fingerprints each binding’s type and fresh-inits on a mismatch — the manual stash is a plain keyed slot. If an edit changes the type you take at a key, the old value comes back in its old shape; the transfer bound guarantees it is still plain data, but reading it as the new type is on you (Vite’s hot.data has the same contract, unchecked). When in doubt, pick a new key — or refresh, which clears the stash entirely.

Process modules — reference

The process layer (node/deno/bun builds): std::db, std::http, std::fs, std::process, std::rpc_server. Task-oriented usage: Persistence and the server.

std::db — SQLite

resource external struct Database;       // a resource: moves, closes on drop

impl Database {
	fun open(path: str): Database        // ":memory:" for an in-memory db
	fun exec(self, sql: str)             // DDL / one-off statements
	fun prepare(self, sql: str): Statement
}
impl Statement {
	fun run(self, parameters: List<any>): i32          // → last insert id
	fun all(self, parameters: List<any>): List<Row>
	fun first(self, parameters: List<any>): Option<Row>
}
impl Row {
	fun text(self, name: str): str
	fun integer(self, name: str): i32
	fun big_integer(self, name: str): i53   // i53-wide INTEGER (epoch millis)
	fun real(self, name: str): f64
	fun is_null(self, name: str): bool
}

Parameters are ? placeholders. Synchronous by design (fits the rpc dispatch path). desc and other SQL keywords fail as column names.

Database is a resource: it has a single owner and moves rather than copies, and it closes its node:sqlite handle when its owner’s scope ends — a let db = Database::open(..) local closes on the function’s return, with no close() method to remember. drop(db) closes it early (the move spends the binding). A module-level Database is the serve-forever idiom: it has process lifetime, never drops, and is reachable only by loan (method calls, &-passing) — moving or droping a module-level database is a compile error. Being a resource, a Database cannot go into a List (use Option or a struct field), cross the wire ([derive(Wire)] rejects it), or be a field of a [service] struct (the generated dispatcher would capture the store — keep the database at module scope instead, next to the service).

std::http — the server

impl Server { fun builder(): ServerBuilder }
impl ServerBuilder {
	fun port(own self, port: i32): ServerBuilder
	fun on_request(own self, handler: async |Request| Response): ServerBuilder
	fun on_upgrade(own self, handler: |NodeRequest, NodeSocket, Bytes| void): ServerBuilder
	fun on_start(own self, callback: |Server| void): ServerBuilder
	fun on_stop(own self, callback: |Server| void): ServerBuilder
	fun build(self): Server
}
impl Server {
	fun start(self)        // begin listening; holds the event loop
	fun url(self): str
}

impl Request {
	fun path(self): str
	fun method(self): str
	fun body(self): str      // the body as text
	fun bytes(self): Bytes   // the same body raw (binary POSTs)
}
impl Response {
	fun builder(): ResponseBuilder
}
impl ResponseBuilder {
	fun code(own self, code: i32): ResponseBuilder          // default 200
	fun set_header(own self, name: str, value: str): ResponseBuilder   // repeatable
	fun body(own self, body: str): ResponseBuilder
	fun body_bytes(own self, body: Bytes): ResponseBuilder  // binary body
	fun streaming(own self, on_open: |ResponseStream| void): ResponseBuilder
	fun build(self): Response
}
impl ResponseStream {
	fun send(self, chunk: str)          // write without ending
	fun close(self)                     // end the response
	fun on_close(self, handler: || void)   // the client went away
}

A streaming response holds the connection open: once the status and headers are written, on_open receives the live ResponseStream and writes chunks over time (SSE’s shape — a suspending on_open runs as spawned work). on_upgrade mounts a WebSocket-style handshake handler over the raw bindings (NodeRequest/NodeSocket). For an rpc-serving app you won’t touch any of this directly — serve_service wraps it (below), and serve_connected itself now rides this surface.

std::rpc_server

fun serve_service(
	port: i32,
	protocol: RpcProtocol,             // service.dispatcher().into_protocol(codec)
	fallback: |Request| Response,      // plain-http requests
	on_ready: || void,
)

fun serve_connected(port, protocol, on_connection, fallback, on_ready)
	// the same server with the per-connection hook exposed (custom attach/auth)

Websocket upgrade + session registry (mirror attach/detach) + rpc dispatch; each handler runs in a turn (AtEnd). Details and the client side: Services & RPC and the rpc reference.

std::fs

fun exists(path: str): bool                // sync
fun read_file_to_str(path: str): str       // async, UTF-8
fun write_file(path: str, contents: str)   // async

std::process

fun args(): List<str>            // CLI arguments
fun env(key: str): Option<str>   // environment variable
fun exit(code: i32)
fun scan(): str                  // read a line from stdin

A completed main ends the process — long-lived programs must hold it open (a listening server does; a socket-holding client needs an explicit wait).

Misc — reference

The small modules that don’t need a page of their own: std::io, std::promise, std::context, std::crypto, std::jwt, std::asset.

std::io

fun print(message: any)                     // console.log
fun panic(message: str)                     // abort with a message
fun assert(condition: bool, message: str)   // panic when false

panic is for unreachable states (expected failures are Result). A panic arm in a match diverges — the other arms decide the match’s type. assert is the vilan test failure mechanism.

std::task

external struct Task<T>;
impl Task<type T> {
	fun settle_all(tasks: List<Task<T>>): List<T>   // async; implicitly awaited
	fun race(tasks: List<Task<T>>): T               // async; first settled wins
}

fun nursery<T>(body: (|Nursery| T) context ambient_nursery): T

external struct Nursery;
impl Nursery {
	fun cancel(self)                 // abort the extent's signal
	fun is_cancelled(self): bool     // the compute-loop check
	fun signal(self): CancelSignal   // the raw host AbortSignal
}

resource struct OwnedNursery { nursery: Nursery }
impl OwnedNursery {
	fun new(): OwnedNursery                                       // a detached owner
	fun enter<T>(&self, body: (|| T) context ambient_nursery): T  // spawns inside → owned
	fun cancel(&self)                                             // early, idempotent
}
// `impl OwnedNursery with Drop` cancels the owned nursery at scope end.

fun ambient_signal(): Option<CancelSignal>   // the enclosing nursery's, if any

Tasks only arise from spawning (async expr); see the async tour. The handle is opaque and copying it refers to the same task. Every task absorbs its own failure: a later await receives it, and a task nobody awaits reports the error (with its spawn origin) instead of crashing the program. Keep the task instead of the results by spawning the settle_all itself: let pending = async Task::settle_all(tasks);.

nursery(body) joins every task spawned in the body’s dynamic extent — the body’s value passes through, the first-observed failure re-raises with its spawn origin, and everything else is absorbed. cancel() aborts the nursery’s signal; sleep and fetch carry the ambient signal automatically, so in-flight IO in the extent rejects promptly, and those rejections absorb as cancellation echoes. Task::race + cancel() is the race idiom: first settled wins, the losers’ IO stops. Spec: §7.7. ambient_signal() bridges host APIs std doesn’t wrap.

OwnedNursery is the owner for background work no function-scoped nursery can hold — a task whose lifetime is an object’s, not a call’s. It is a resource: it has a single owner, moves, and is destroyed deterministically. new() makes a detached owner; enter(body) runs body with the owner’s nursery ambient, so every task spawned inside registers with it — but, unlike nursery, enter does NOT join: it returns as soon as the body settles, leaving the tasks running. Dropping the owner (at scope end, or drop(owner)) cancels them, so in-flight bridged IO aborts. Because the owner’s nursery is never joined it runs detached: a child’s REAL failure reports to the console with its spawn origin — as a free-floating task would — instead of being absorbed for a join, and children do not cancel their siblings (ownership is lifetime, not fate-sharing). Cancellation echoes, from the owner’s cancel or drop, stay silent.

std::promise

external struct Promise<T>;
impl Promise<type T> {
	fun all(promises: List<Promise<T>>): List<T>   // async; implicitly awaited
}

The raw host promise, for direct host interop: an [extern(new, "Promise")] constructor or a promise-returning host API is typed Promise<T>, and await unwraps it exactly like a task. Code that only spawns never sees this type.

std::context

Ambient values with dynamic extent — the machinery under owner_scope and turn_scope:

impl Context<type T> {
	fun new(): Context<T>
	fun run<U>(self, value: T, body: || U): U   // establish for the body's extent
	fun get(self): T                            // read (compile error if possibly absent)
	fun get_safe(self): Option<T>               // read, absence as None
}
  • get is statically covered: the compiler proves every call path runs inside a run — an uncovered read is a compile error, not a runtime None.
  • Closures capture their contexts at creation; parameters declare context needs with the context clause (functions & closures).
  • Async-safe by construction: a continuation sees the value captured at creation, across awaits and interleaved extents.

Define module-level contexts for app-wide ambients (a session identity on the server is the canonical use).

std::crypto

fun random_bytes(length: i32): Bytes        // cryptographically secure
fun random_uuid(): str
fun equals_constant_time(a: Bytes, b: Bytes): bool   // timing-safe compare

WebCrypto-backed (async where the host is). For password hashing on the server, bind the host’s sync primitives as externs (the kolt pilot uses node’s pbkdf2Sync) — candidates for std promotion.

std::jwt

HS512 JSON Web Tokens; claims are any Wire type:

async fun sign_hs512<C: Wire>(secret: Bytes, claims: C): str
async fun verify_hs512<C: Wire>(secret: Bytes, token: str): Option<C>
fun decode_claims<C: Wire>(segment: str): Option<C>   // decode WITHOUT verifying

verify_hs512 checks the signature (constant-time) before yielding claims; decode_claims is for non-security introspection only.

std::asset

fun emit(kind: str, line: str)   // compile-time only: append to a build asset

Callable only from const evaluation — it’s how std::style writes the CSS file (emit("css", rule)). A browser build with emissions produces <entry>.css beside <entry>.js.

Spec §1 — Introduction & conformance

This is the vilan language specification: the normative definition of what programs mean and which programs are rejected. The tour teaches; this defines. Where the two disagree, this document wins.

1.1 Scope and normativity

  • Unmarked prose in docs/spec/ is normative.
  • Implementation notes (italic paragraphs beginning “Implementation note:”) are not normative: they record where the current compiler is known to diverge from this specification (linking the tracked gap), or where behavior is deliberately implementation-defined.
  • The specification states intent. A compiler behavior that contradicts normative text is a compiler bug, even before it is fixed; such gaps are tracked in vilan/proposal/backlog.md and pinned as #[ignore]d tests.

Deliberately implementation-defined (a conforming implementation may vary): diagnostic wording and spans; the shape of emitted JavaScript (subject to §7’s observable guarantees); compilation performance; the order in which independent diagnostics are reported.

The standard library’s API is specified by the std reference and is not part of this document, except for the lang items the language itself depends on (appendix §A.4): Option, the Try/Lift traits, the operator traits, PartialEq/PartialOrd, Context, Task, Promise, List, and the primitive types’ declarations.

1.2 Program processing phases

A vilan program is processed in phases; each phase’s errors preclude the next. A conforming program passes all of them.

  1. Lexing (§2) — source text to tokens.
  2. Parsing (§3) — tokens to the syntax tree.
  3. Macro expansion & loading (§10) — module loading, attribute/ derive/block expansion, splicing. Generated code re-enters phases 1–2.
  4. Name resolution (§4) — names to declarations.
  5. Type checking (§5) — types, generic binding, bound checking.
  6. Memory checking (§6) — view confinement, the aliasing rules.
  7. Context checking (§8) — ambient-value coverage.
  8. Async inference (§7) — asyncness, seam checking.
  9. Emission (§7.1 for the observable guarantees).

An error’s phase is observable only in which other errors accompany it; a conforming implementation may report errors from several phases at once.

1.3 Notation

Grammar productions use this EBNF dialect:

production = alternative | alternative ;
sequence   = item item ;
[ x ]        optional
{ x }        zero or more
( x )        grouping
"literal"    a keyword or fixed token
IDENT        a token class (small caps, defined in §2)

Code blocks tagged vilan are conforming programs (compile-checked by the test suite). Blocks tagged vilan,fragment are fragments or deliberate counter-examples; a counter-example always states its error class in the surrounding prose.

1.4 Source files, packages, programs

A module is one source file (.vl, UTF-8). A package is a directory with a vilan.toml manifest declaring [package] (an application) or [library]; a [project] manifest groups packages into a workspace (normative manifest schema: §11). A program is an application package compiled for a platform (§11): its entry module, the modules it transitively imports, its dependencies’ modules, and the standard library subset they reach.

Spec §2 — Lexical structure

Source text is UTF-8. Lexing converts it to a token stream; trivia (whitespace and comments) separates tokens and is otherwise discarded. A file consisting only of trivia lexes to an empty token stream (and parses as an empty module).

Line terminators. A line terminator is \n, and the two-character sequence \r\n is one line terminator. Text built from source — the value of any string literal — is built from the normalized text, so a literal spanning lines carries \n per source line break and never \r\n, whatever the file’s on-disk encoding. A lone \r (no following \n) is not a line terminator: between tokens it is ordinary whitespace, and inside a string literal it is an ordinary character, preserved. A leading U+FEFF byte-order mark is an encoding marker rather than source text: it is ignored, and positions are counted from the byte after it. A U+FEFF anywhere else is content. Together these make a program’s meaning independent of how an editor saved it — the same source is the same program on every platform. (See proposal/windows-support.md §2; the canonical on-disk form is LF with no BOM, and vilan fmt writes it.)

2.1 Comments

A comment begins with // and runs to the end of the line. There are no block comments.

2.2 Identifiers and keywords

IDENT = ascii_letter | "_" , { ascii_letter | digit | "_" } ;

Identifiers are ASCII. The following words are reserved — they lex as keyword tokens and are never IDENT:

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

(true/false lex as boolean literals; null as the null literal.)

Contextual keywords lex as IDENT and take meaning only by position: context (the clause after a closure type, §3.9), void (the unit value/type), self and Self (receiver and receiver type), derive, service, extern, must_use, rpc, trait_only, doc, expose (attribute names in [...] position), and jump targets (break, continue) after jump. All remain usable as ordinary identifiers elsewhere.

2.3 Literals

Numbers

NUMBER = decimal | hex ;
decimal = digits , [ "." , digits ] , [ SUFFIX ] ;
hex     = "0x" , hexdigit , { hexdigit } , [ SUFFIX ] ;
SUFFIX  = IDENT   (* immediately adjacent, no space *)

The suffix names the literal’s type: i8 i16 i32 u8 u16 u32 (that two’s-complement width), i53/u53 (the wide integers — see below), f (f64), f32, f64, n (BigInt). An unknown suffix is a compile error (the retired i64/u64 suffixes get a rename hint). An unsuffixed integer literal is i32; an unsuffixed fractional literal is f64. Every integer literal is range-checked against its type at compile time.

i53 spans the symmetric range ±2^53 and u53 spans [0, 2^53] — the window in which every integer is exactly representable in an IEEE-754 double (the backing representation). The names deliberately follow JavaScript’s safe-integer convention (53 bits of integer precision) rather than the two’s-complement iN convention; there is no i64 — integers beyond the window take BigInt.

In a hex literal the digit run is maximal, so a suffix must begin with a non-hex letter: 0xFFu8 is valid; 0xFFf is a single hex number 0xFFF, not 0xFF with suffix f.

Strings

STRING           = '"' , { string_char } , '"' ;
string_char      = "\" any_char | any_char_except_quote_backslash ;
MULTILINE_STRING = '"""' , raw_text , '"""' ;

In a plain string a backslash escapes the next character; escape sequences are preserved in the token and interpreted at code generation with JavaScript string-escape semantics (\n, \", \\, …). A multiline string is raw (a backslash is a backslash) and runs to the first """; the whitespace prefix of the line containing the closing delimiter is stripped from every line of the content.

Both forms may span lines, and in both a source line break contributes a single \n to the value per the line-terminator rule above. An escaped \r is unaffected: it is written into the literal, not read off the end of a line.

Interpolated strings

i"…" is an interpolated string: {expr} holes embed expressions; \{ and \} are literal braces. The construct is defined by desugaring — an interpolated string is exactly equivalent to a parenthesized concatenation:

i"Hello, {name}!"   ≡   ("" + "Hello, " + (name) + "!")

Each hole’s contents are lexed as ordinary tokens (except that {/} delimit the hole; string literals inside a hole may still contain braces) and parsed as a single parenthesized expression. The result of the whole form is str; each part must therefore be valid as a + operand with str (§5’s operator dispatch).

An interpolated string may also span lines — the multi-line source(i"…") form is how a macro writes the code it returns — and its literal fragments follow the same line-terminator rule: one \n per source line break.

Implementation note: because a hole is re-lexed as ordinary tokens, a string literal inside a hole cannot use \" escapes — nested quoting inside holes is currently a parse error. Bind the value to a local first.

Other literals

true, false (type bool); null (the host-boundary null, §5.2); void (the unit value — a contextual identifier, not a keyword).

2.4 Operators and punctuation

Two token classes:

  • Operator tokens — a maximal run of the characters - : ! * / + = | & ^ ? %, plus the arrow => (lexed as one token). Maximal munch means ==, !=, +=, ::, ?.’s ?, &&, || each lex as single operator tokens; conversely a+-b lexes as a, +-, b and is a parse error.
  • Control tokens — the single characters ( ) [ ] { } < > ; , ..

< and > are control tokens (they delimit generics), not operator characters. Consequently <=/>= lex as </> followed by =, and the shift operators <</>> are two adjacent control tokens; the parser accepts them as shifts only when span-adjacent (no whitespace): a << b is a shift, a < < b is a parse error (§3.7).

2.5 Trivia and token separation

Whitespace (any Unicode whitespace) and comments may appear between any two tokens and are required only where two tokens would otherwise lex as one (fun main needs the space; a + b does not). Lexing is greedy and context-free: no token depends on parse state.

Spec §3 — Grammar

The full syntactic grammar, in the notation of §1.3. Token classes (IDENT, NUMBER, STRING, …) are defined in §2. The start symbol is module.

3.1 Modules and statements

module    = { statement } ;

statement = derived-item
          | service-item
          | macro-attributed-item
          | macro-fun
          | macro-block [ ";" ]
          | macro-invocation [ ";" ]
          | "export" statement
          | expression ";"
          | if-expr        (* not before "}" — see below *)
          | for-expr       (* not before "}" *)
          | match-expr     (* not before "}" *)
          | function
          | struct
          | enum
          | impl
          | trait
          | "mod" IDENT "{" { statement } "}"
          | import ";"
          | use ";"
          | block          (* not before "}" *)
          ;

A block-like form (if/for/match/{…}) in statement position must not be the last thing in its enclosing block: in that position it is instead the block’s trailing expression and supplies the block’s value (§3.5).

3.2 Imports and exports

import  = "import" path-branch ;
use     = "use"    path-branch ;
path-branch = NAME [ "::" ( path-branch | path-set ) ] ;
path-set    = "{" path-branch { "," path-branch } [ "," ] "}" ;
NAME        = IDENT | "true" | "false" ;   (* variant re-exports *)

import brings names from another module into scope; use brings names from a type’s namespace (e.g. variants) into scope. In a set, self names the item itself (Option::{ self, Some, None } imports the type and its variants). Semantics: §4. export statement re-exports an import or exposes a declaration to importers of the module.

3.3 Items

Functions

function = [ extern-attr ] [ "[" "must_use" "]" ] [ "[" "rpc" "]" ]
           [ "[" "trait_only" "]" ] [ "[" "doc" "(" "hidden" ")" "]" ]
           [ "async" ] [ "external" ]
           "fun" IDENT [ generic-params ]
           "(" [ parameter { "," parameter } [ "," ] ] ")"
           [ ":" type ] [ "borrows" IDENT ]
           ( block | ";" ) ;

parameter  = [ convention ] binder [ ":" type ] ;
convention = "own" | "&" [ "mut" ] ;
binder     = IDENT | "(" binder "," binder { "," binder } [ "," ] ")" ;

extern-attr = "[" "extern" "(" extern-args ")" "]" ;
extern-args = STRING [ "," STRING ]           (* module/global binding *)
            | ("method"|"get"|"set") [ "," STRING ] ;

A ; body is a signature-only declaration — legal for external functions and required trait methods. A parameter’s convention may come from the prefix (own x, &mut self) or from a view type (x: &mut T); the prefix wins if both are present (§6.3). The borrows clause names the parameter the returned view projects (§6.5).

Structs and enums

struct = [ "resource" ] [ "external" ] "struct" (IDENT | "null") [ generic-params ]
         ( "{" [ field { "," field } [ "," ] ] "}" | ";" ) ;
field  = [ "[" "expose" "]" ] IDENT [ ":" type ] ;

enum    = [ "resource" ] "enum" IDENT [ generic-params ]
          "{" [ variant { "," variant } [ "," ] ] "}" ;
variant = NAME [ "(" [ type { "," type } [ "," ] ] ")" ]
          [ "=" [ "-" ] NUMBER ] ;

A ;-bodied struct is legal only for external structs (host types). An explicit variant discriminant (= 0, = -1) fixes the variant’s integer tag.

The leading resource modifier marks a type declaration as a resource — the owned-resource class, whose semantics are specified in the resources chapter (forthcoming; the modifier currently reserves the surface). It precedes external, so the full modifier order is resource external struct, and it is accepted only on struct and enum declarations — resource before any other item is a parse error.

Impls and traits

impl  = "impl" type [ "with" type { "+" type } ] "{" { statement } "}" ;
trait = "trait" IDENT [ generic-params ] [ "with" type { "+" type } ]
        "{" { function } "}" ;

An impl’s subject is a type pattern: type X [: bounds] binders anywhere inside it (impl List<type T>, impl Option<(type T, type U)>, bare impl type T) declare the impl’s generic parameters (§5.6). with lists the implemented trait(s) — an impl without with provides inherent members. A trait’s with lists supertraits.

Generic parameters and arguments

generic-params = "<" generic-param { "," generic-param } [ "," ] ">" ;
generic-param  = [ "type" ] IDENT [ ":" ( bound-list | tuple-bound ) ]
                 [ "=" type ] ;
bound-list  = type { "+" type } ;
tuple-bound = "(" [ NUMBER ] ".." [ NUMBER ] [ ":" type ] ")" ;
generic-args = "<" type { "," type } [ "," ] ">" ;

A tuple bound constrains a variadic tuple parameter’s arity and, optionally, each element (T: (2..), T: (..: Display)); see §5.9.

Attributes and macro items

derived-item   = "[" "derive" "(" IDENT { "," IDENT } [ "," ] ")" "]"
                 ( struct | enum ) ;
service-item   = "[" "service" [ "(" IDENT ")" ] "]" struct ;
macro-attributed-item = "[" IDENT [ "(" [ expr-span { "," expr-span } ] ")" ] "]"
                        ( struct | enum | function ) ;
macro-fun        = "macro" function ;
macro-invocation = "macro" IDENT "(" [ expr-span { "," expr-span } ] ")" ;
macro-block      = "macro" block ;

A macro attribute’s arguments are captured as source spans — the macro receives their text, not their values (§10). The built-in attribute names (derive, service, extern, must_use, rpc, trait_only, doc, expose) are not available as user macro-attribute names.

3.4 Bindings and assignment

let        = ("let" | "mut") binder [ ":" type ] [ "=" expression ] ;
assignment = [ "*" ] place ( "=" | "+=" | "-=" | "*=" | "/=" | "%=" )
             expression ;
ret        = "ret" [ expression ] ;
jump       = "jump" IDENT ;          (* break | continue *)

let binds immutably, mut mutably; a tuple binder destructures (irrefutably — names and nested tuples only). Both the type and the initializer are syntactically optional. A place is a chain expression (§3.6) denoting a location: a local, a field chain, an index, or a place reached through a call (a.write().count); the optional leading * assigns through a view. jump break / jump continue control the innermost enclosing loop.

3.5 Blocks and control expressions

block      = "{" { statement } [ expression ] "}" ;
if-expr    = "if" secondary-expr block [ "else" ( block | if-expr ) ] ;
for-expr   = "for" IDENT "in" secondary-expr block   (* iteration *)
           | "for" secondary-expr block              (* while *)
           | "for" block ;                           (* infinite *)
match-expr = "match" secondary-expr "{" { match-leg [ "," ] } "}" ;
match-leg  = pattern { "," pattern } [ "if" expression ] "=>" expression ;

A block’s value is its trailing expression, or void if none. Conditions and match/for subjects are secondary expressions (§3.8) — struct initializers are excluded there, keeping if Foo { unambiguous. A match leg’s comma-separated patterns form an or-pattern; the optional if guard applies to the whole leg; the trailing comma after a leg is optional.

3.6 Chain expressions (postfix)

The tightest expression tier, chain:

chain   = path { call-suffix | postfix } ;
path    = ( IDENT generic-args ␣"::"  (* generic static head *)
          | atom )
          { "::" IDENT } ;
call-suffix = [ generic-args ] "(" [ expression { "," expression } [ "," ] ] ")" ;
member  = NUMBER                          (* tuple index: .0 *)
        | IDENT [ call-suffix ] ;         (* field / ONE fused method call *)
postfix = "." member
        | "[" expression "]"             (* index *)
        | "!"                            (* try-assert, §5.10 *)
        | "(" [ expression { "," expression } [ "," ] ] ")"
                                          (* direct call on the chain result *)
        | "?." member ;                  (* lift link, §5.10 *)

atom    = literal | IDENT | IDENT generic-args
        | "(" expression ")" | tuple | list
        | tuple-comprehension | macro-invocation | macro-block ;
tuple   = "(" expression "," expression { "," expression } [ "," ] ")" ;
list    = "[" [ expression { "," expression } [ "," ] ] "]" ;
tuple-comprehension = "(" IDENT "in" secondary-expr "=>" expression ")" ;

Name<Args> is read as a generic path head only when :: immediately follows (List<str>::new()); otherwise < is a comparison. A member fuses at most ONE call; a further (args) is a direct call on the chain’s result, calling a closure-typed value (self.hook.read()(a, b)). A ?. link’s continuation extends through the following plain postfixes up to the next ?. or !a?.b.c()! lifts b.c() into the container, then try-asserts the result (§5.10).

3.7 Operator precedence

From tightest to loosest; every binary level is left-associative:

LevelOperatorsNotes
1:: paths, calls, . [] ! ?.§3.6
2prefix ! - await async & &mut *unary; async also takes a block
3* / %
4+ -
5<< >>the two control tokens must be span-adjacent
6&bitwise and
7^bitwise xor
8|bitwise or
9== != < <= > >=one level; a < b < c parses as (a < b) < c (ill-typed, §5.7)
10is patternat most one per operand (no chaining)
11&&
12||

Bitwise operators bind tighter than comparisons (a & b == c is (a & b) == c).

3.8 The expression tiers

expression     = "const" expression        (* weak prefix: captures to the end *)
               | secondary-expr ;
secondary-expr = closure | block | if-expr | for-expr | match-expr
               | jump | let | ret | assignment
               | operator-expr ;           (* §3.7 levels 1–12 *)
condition-expr = secondary-expr ;          (* struct-init excluded from operands *)

struct-init   = IDENT [ generic-args ]
                "{" [ init-field { "," init-field } [ "," ] ] "}" ;
init-field    = IDENT [ "=" expression ] ;   (* shorthand: name alone *)
closure       = ( "||" | "|" [ closure-param { "," closure-param } [ "," ] ] "|" )
                [ ":" type ] expression ;
closure-param = binder [ ":" type ] ;

Two consequences of the tier split are normative:

  • A struct initializer is an operand of the operator/postfix chain (Point { … } == q compares; Point { x = 1, y = 2 }.length() folds the member chain) — except in condition positions: an if/for condition, a for … in iterable, and a match subject parse condition-expr, whose operands exclude struct initializers, so the { after if Foo is the block. Parenthesize a literal to use it in a condition (if p == (Point { x = 1 }) { … }).
  • const captures weakly: everything to the end of the expression (up to the enclosing bracket or comma) folds; parenthesize to narrow (§9).

A closure’s body is one expression (commonly a block). || in operand position always begins a zero-parameter closure; logical-or is only recognized between two operands.

3.9 Types

type = "&" [ "mut" ] type                       (* view type *)
     | "type" IDENT [ ":" bound-list ]          (* impl-subject binder *)
     | [ "async" | "sync" ] closure-type [ context-clause ]
     | IDENT generic-args                        (* nominal, generic *)
     | IDENT                                     (* nominal *)
     | "(" IDENT "in" type ":" type ")"          (* mapped tuple, §5.9 *)
     | "(" [ type { "," type } [ "," ] ] ")"     (* tuple type *)
     ;
closure-type   = ( "||" | "|" [ [IDENT ":"] type { "," [IDENT ":"] type } "|" )
                 [ type ] ;
context-clause = "context" ( IDENT | "(" IDENT { "," IDENT } [ "," ] ")" ) ;

context here is the contextual keyword (§2.2); the clause is only valid on closure types, checked semantically (§8.5). sync is likewise contextual (§7.4: the synchronous contract; parameters only). A closure type’s parameters may carry documentation names (|value: T| U); only the types are significant.

3.10 Patterns (match)

pattern = ("let" | "mut") binder                (* binding *)
        | "(" pattern "," pattern { "," pattern } [ "," ] ")"
        | STRING | MULTILINE_STRING | NUMBER    (* equality literal *)
        | "_"                                   (* wildcard *)
        | NAME { "::" IDENT }
          [ "(" [ pattern { "," pattern } [ "," ] ] ")" ] ;  (* variant *)

Bindings inside patterns are written explicitly (Some(let x)), so a bare name is always a variant reference, never a fresh binding — the classic mistyped-variant trap is a resolution error instead of a silent catch-all. bool and null literals match as variants of their enums. The let/parameter binder grammar (names and tuples, §3.3) is the irrefutable subset; refutable forms (literals, variants) are match-only.

Spec §4 — Names, modules, and packages

4.1 Modules

A module is one source file. Its top-level statements form its body; its declarations (fun, struct, enum, trait, impl, module-level let, mod blocks) are its items. There is no separate module declaration — a file routes.vl in a package’s source root is the module routes of that package.

4.2 The three namespaces

A path’s first segment selects a namespace:

  • std::name — the standard library module name, resolved against the std package’s layers for the current platform (§11).
  • pkg::name — the module name of the importing file’s own package.
  • depname::name — the module name of the dependency declared as depname in the package manifest.

Within std itself, sibling modules are referenced as pkg::… (std is its own package). The namespaces are disjoint: resolution is scoped by the root segment, so a package is free to name a module ui or json even though std has one — pkg::ui is always the package’s own module, std::ui always std’s, and neither shadows the other. (Conversely, pkg:: never reaches a std module.) A module name that resolves both as name.vl and name/lib.vl is an ambiguity error.

A module name must match the on-disk directory entry byte for byte: a case-insensitive filesystem that answers import foo with Foo.vl is a diagnostic naming both spellings, not a resolution — so that a program compiles identically on case-sensitive and case-insensitive filesystems (proposal/windows-support.md §5). Every component of the resolved path carries the rule, so foo/lib.vl is reached by import foo only when the directory is spelled foo.

4.3 Imports

import path (§3.2) loads the target module (once per program — loading is idempotent and cycle-tolerant) and binds the imported items in the importing module’s scope:

  • import std::print; — binds the item print.
  • import std::reactive::{ Signal, combine }; — binds each set member.
  • import std::option::Option::{ self, Some, None }; — a path into a TYPE: self binds the type itself; variant names bind the variants for unqualified use.

use path binds names from an already-visible type’s namespace without loading (variants, statics). export statement re-exports: importers of this module see the exported names as if declared here.

Platform gating is not checked at the import: a module outside the current platform’s layers (e.g. std::ui in a node build) still loads, so its items type-check. The error is reported where platform-colored code becomes reachable from the build’s entry (§11).

4.4 Scopes and shadowing

Scopes nest: module → function/impl → block → closure. Name lookup walks outward from the use site to the innermost binding. A let/mut binding shadows any outer binding of the same name from its point of declaration onward, including imports and items:

import std::print;

fun main() {
	let print_count = 2;
	mut label = "a";
	{
		let label = "inner";     // shadows the outer binding in this block
		print(label);
	}
	print(label);
	print(print_count);
}

Items within one module share the module scope and are visible throughout the module regardless of declaration order (a function may call one declared later). Local let bindings are visible only after their declaration.

4.5 Type position vs value position

A name is resolved differently by position:

  • In type position (annotations, generic arguments, impl subjects), lookup prefers bindings that denote types; a value binding with the same name does not shadow a type there.
  • In value position, lookup takes the nearest binding of any kind.

Consequently a local variable named Signal does not break let s: Signal<i32> annotations in the same scope — but relying on this is poor style.

4.6 Statics and members

Type::member (§3.6) resolves member in Type’s namespace: enum variants, and the static functions of the type’s impls (those without self). value.member resolves against the value’s type: fields first, then methods of inherent impls, then trait members visible via the type’s impls (§5.7). Generic statics take their arguments at the path head: List<str>::new().

4.7 The prelude

A small set of names is in scope without imports: the primitive types (i32, str, bool, …), List, void, and the boolean/null literals’ types. Everything else — including Option, Result, print — must be imported. (The exact prelude is the lang-item table, appendix §A.4.)

Spec §5 — The type system

5.1 Types

The type forms (grammar §3.9) denote:

  • Nominal types — structs and enums, possibly generic (Task, Option<i32>, Map<str, List<i32>>). Two nominal types are equal iff they name the same declaration and their arguments are equal — there is no structural typing of nominals.
  • Primitivesbool, str, i8 i16 i32 i53 u8 u16 u32 u53, f32 f64, BigInt. Declared in std as external structs; nominally distinct (no implicit numeric conversions, §5.8).
  • Tuples(T, U, …); structural: equal iff element-wise equal. () and one-element tuples do not exist as distinct types ((T) is T; the unit is void).
  • Closure types|T, U| R, || R, || void; structural in their parameter and return types. An async closure type (§7.4) and a context-claused type (§8.5) are distinct from their plain counterparts.
  • View types&T, &mut T (§6). Views are second-class: these types appear in parameter and return positions and in short-lived locals only.
  • void — the unit: one value, also written void.
  • any — the dynamic top type, produced at host boundaries; it unifies with every type (absorbing).
  • Never — the type of diverging expressions (panic(..), ret .., jump break/continue). Never unifies by yielding: a diverging match leg or if branch doesn’t constrain the construct’s type, and a Never value satisfies any expected type. Internal — not written in source.
  • Generics — a bound type parameter in scope (T) is a type; it is abstract within its binder’s body.

5.2 null

null is not a member of ordinary types. It exists for host interoperability (an extern that may return JS null); std APIs flatten it at the boundary (Option, or a documented sentinel like storage::get’s ""). A conforming program cannot assign null to a non-host type.

5.3 Declarations

A struct introduces a nominal product type; field types are mandatory in non-external structs. An enum introduces a nominal sum type; each variant is a constructor (with payload types) and a static member of the enum. An external struct declares a host type: no fields, its surface defined entirely by externs in impls.

5.4 Impls

impl Subject { … } adds inherent members to Subject; impl Subject with Trait { … } provides Trait for Subject. The subject is a type pattern whose type X: Bounds binders declare the impl’s generics:

impl List<type T: PartialEq> { … }      // for every List<T> where T: PartialEq
impl Signal<Signal<type U>> { … }       // only for nested signals
impl type T: Display { … }              // blanket: every T that is Display

An impl applies to a concrete type when the pattern matches it and every binder’s bounds hold. Members may be functions (with or without self) — a function without self is a static, reached as Subject::name(…).

Implementation note (soundness gap, tracked): a conditional impl’s bounds are not yet re-checked when the impl is selected through a GENERIC bound — List<B> can satisfy a Marker bound via impl List<type T: Marker> even when B lacks Marker. Derive-based checks (Wire, Json) therefore verify field trees syntactically rather than through trait bounds.

5.5 Traits

A trait declares required methods (signature-only) and defaults (with bodies). trait X with Y makes Y a supertrait: implementing X requires Y. A trait’s generic parameters may carry defaults (trait PartialEq<B = Self>); Self in a trait body denotes the implementing type. Traits are used as bounds; a trait is not a type — let x: Display is a compile error (no trait objects).

Trait members resolve on a value when exactly one visible impl provides the name; a trait default is inherited by impls that don’t override it.

5.6 Generic binding and inference

Type checking is expectation-directed: every expression is checked against an expected type (possibly unknown), and expectations flow inward (a let annotation to its initializer, a parameter type to its argument, a field’s declared type to its initializer value).

For a call f(a₁ … aₙ) where f has generic parameters:

  1. Each parameter type is unified with its argument’s type; unification of a generic parameter with a concrete (or caller-generic) type binds it. Bindings are per-call.
  2. A generic mentioned only in the return type is bound by unifying the declared return type against the call’s expected type (let c: Cell<i32> = Cell::fresh() binds T := i32). Only the callee’s own binders participate — a caller-side generic introduced by substitution is never re-bound against the expectation.
  3. After binding, every bound’s satisfaction is checked; an unsatisfied bound is an error naming the parameter and bound.
  4. A call whose generics cannot all be grounded (no argument or expectation determines them) is an error at the call.

Method calls additionally bind the receiver’s impl binders from the receiver’s type before the parameters are considered. Closure arguments participate: a closure’s parameter types take the callee’s expectations, and its return type may ground the callee’s generics; resolution defers until the closure’s body has typed.

Generic code is monomorphized: each distinct binding vector of a generic function/impl produces its own specialization; dispatch is static. A program that would require an unbounded set of specializations (polymorphic recursion) is not required to compile.

5.7 Operator and method dispatch

The operators dispatch through lang-item traits (appendix §A.4): + - * / % and the bit/shift operators through Add/Sub/…; == != through PartialEq; < <= > >= through PartialOrd. The left operand’s type selects the impl; the trait’s B parameter types the right operand (default Self); the result type is the impl’s (for the arithmetic traits, Self). Compound assignment x op= e is exactly x = x op e with x’s place evaluated once.

is (§3.7 level 10) tests a value against a match pattern and yields bool; bindings inside an is pattern are scoped to nothing (use match to bind).

5.8 Conversions and coercions

There are no implicit conversions between numeric types; use the as_* methods (value-converting, Rust-as semantics). There is one implicit coercion: a reference to a plain named function coerces to a matching closure type —

let transform: |str| i32 = measure;    // fun measure(text: str): i32
words.map(measure);

Eligibility: a non-generic, non-method, non-async, non-external fun whose signature equals the target closure type. Everything else (generics, methods, async functions, externs) requires an explicit wrapping closure.

any unifies with every type in both directions (it is produced by panic and host boundaries; it absorbs rather than converts).

5.9 Variadic tuples

A generic parameter with a tuple bound ranges over tuples: T: (2..) (arity ≥ 2), T: (..: Display) (every element Display). A mapped type (U in T: F<U>) denotes the tuple obtained by mapping each element U of T to F<U>combine’s signature is the canonical use:

fun combine<T: (2..)>(sources: (U in T: Signal<U>)): Signal<T>

A tuple comprehension (x in xs => e) is the value-level mapping form.

Tuple bounds are enforced at every binding site, alongside trait bounds: the bound value must be a tuple, its arity must fall inside the declared range (endpoints inclusive), and every element must satisfy the element bound. A generic parameter forwarded into a tuple-bounded position satisfies it only through its own declared tuple bound — a contained arity range whose element bound names the same trait or a subtrait. keyof and spread parameters are recorded future work.

Positional access t.0, t.1 (chaining as t.0.1) types as that element and, through a mut binding, assigns it. Tuples store flat: a tuple-typed element occupies its elements’ slots, so accessing one yields its region as a value (destructuring reads the same layout).

5.10 ! and ?.

Both dispatch through lang-item traits and desugar per expression:

  • e!try-assert. With v = e of a type implementing Try<T, B>: if v.verdict() is Good(t), the value is t; if Bad(b), the enclosing function returns R::from_bad(b) where R is its declared return type (which must implement Try<_, B> compatibly). Option and Result implement Try in std.
  • e?.m…lift. With v = e of a container type implementing Lift + Try: if v is good with value t, the continuation (.m and the following plain postfixes, §3.6) applies to t; the result re-wraps in the container — unless the continuation itself yields the container type, in which case it is returned as-is (flattening). If v is bad, the container passes through unchanged.
import std::print;
import std::option::Option::{ self, Some, None };

fun main() {
	let word: Option<str> = Some("dune");
	print((word?.len()).unwrap_or(0));       // 4  — lift + rewrap
	let missing: Option<str> = None;
	print((missing?.len()).unwrap_or(0));    // 0  — bad passes through
}

5.11 Type errors of note

Normative rejection cases (each is a compile error):

  • Using a trait as a type (let x: Display = …).
  • An unsatisfied bound at a call (generic parameter 'T' is missing the bound …).
  • A match whose VALUE legs’ types don’t unify. Diverging legs (ret, panic, jump) are Never and don’t participate (§5.1).
  • An i53/i32 operand mix (no implicit widening — suffix the literal).

Implementation note (tracked gaps): a closure bound to a local and called directly does not infer its parameter types from the call; effect’s unannotated closure parameter can type against the impl’s abstract T (B23); chained element access on a call result can lose the element type. Each has a pinned test; the workaround is an annotation or a binding.

Spec §6 — The memory model

Values are copied (§6.1); sharing is asked for explicitly. Every explicit sharing mechanism — views, projections, Shared, Arena/Handle, and the resource class (§6.8) — is a claim on an owner, and all of them obey the one law stated in §6.0. The numbered sections that follow are that law’s projections: the four rules (design: proposal/memory-management-rev-1.md), the escape hatches, and resources (design: proposal/claims-and-epochs.md, proposal/destruction.md). Rules 1–3 and rule 4’s static half are normative and enforced; rule 4’s dynamic remainder is future work, marked below.

6.0 The law — owners, epochs, and claims

One relation underlies every sharing mechanism in this chapter; the sections after it are its projections.

An owner is any value with independent existence: a binding, a container, an arena slot, a counted cell, a resource. Every owner has an epoch — an abstract counter that advances on a fixed set of events, determined by the owner’s shape:

owner shapeepoch events
scalar cell (a boxed local)rebinding, death
aggregate (a struct root)+ reassigning an aggregate field out from under an interior view
container (List, arena slots)+ geometry: resize, insert, remove, reallocation
counted cell (Shared; the future counted tier, §6.8)+ the strong count reaching zero
resource (§6.8)+ the move (the source binding’s epoch ends), + drop (the final event)

A claim is any alias to a place inside an owner: a &/&mut view (§6.3), an Arena Handle, a Weak, a loan of a resource. In the abstract a claim is the triple (owner identity, path, epoch at capture).

The law: a claim is valid while its owner’s epoch is unchanged. Nothing else is forbidden — aliasing is permitted, and writing through an alias is permitted (§6.4 declines exclusivity); only using a claim whose owner’s epoch has advanced is illegal. A suspension point (await) is the degenerate event: while a function is suspended, other turns run, so every owner’s epoch must be assumed to advance — await bumps the world (§6.6).

A claim’s validity is established at exactly one of two times, giving two enforcement regimes:

  • Static discharge — views (§6.3). The compiler proves that no event occurs between a view’s capture and its last use. The proof needs a surveyable interval, which is why views are second-class: lexical liveness (declaration to block end) is the interval the compiler can audit. Because the proof is total, the access is infallible and free — a view compiles to a bare (base, key) and checks nothing at runtime.
  • Dynamic carry — handles. When a claim outlives every surveyable interval — stored in a field, kept across await, held between turns — it carries its epoch as data (a Handle’s generation) and every access re-establishes validity by comparison, answering Option<&T>. The check runs at use time, so access is failable, and the failability is in the type. A handle is a view that survived by carrying its proof obligation with it.

The same relation, read at compile time or at use time:

concernstatic (proof)dynamic (check)
interior access&/&mut views; borrows provenance (§6.5)Arena.get(handle): Option<&T>
invalidationrule 4 (§6.4): reassignment, mutating callgeneration mismatch → None
suspensionno view across await (§6.6)handles cross freely; the next access re-validates
deathresources: use-after-move; drops placed statically (§6.8)a stale handle / WeakNone
exclusivitydeclined — aliased views and content writes are legaltraps only on invalidating overlap (§6.7)

Reading down the columns: the left is what the compiler proves when liveness is lexical; the right is the identical relation carried as data when the claim must outlive the compiler’s sight. Sections 6.1–6.8 place each mechanism in a cell — rules 1–2 (§6.1–§6.2) govern owners that carry no claims; rule 3 (§6.3) is the static column’s interval requirement; rule 4 (§6.4) is the static invalidation cell; borrows (§6.5) makes a claim’s origin explicit; the escape hatches (§6.7) are the dynamic column; and resources (§6.8) are the static death cell, where use-after-move is the compile-time twin of a stale handle’s None.

6.1 Rule 1 — values are copied; copies are semantic

Every binding, assignment, argument pass, field initialization, and return copies the value. After mut b = a, mutating b never affects a — for primitives, structs, enums, tuples, lists, and every other value type alike:

import std::print;

struct Point { x: i32, y: i32 }

fun main() {
	mut a = Point { x = 4, y = 6 };
	mut b = a;
	b.x = 10;
	print(a.x);   // 4 — b is a semantic copy
}

6.2 Rule 2 — elision is an optimization, never observable

An implementation may skip a copy (reuse the storage) when no conforming program can tell — e.g. when the source is never used again. Elision must not change any program’s output; a live view of the source counts as a use. (This rule licenses the JS backend to alias under the hood; it grants programs nothing.)

6.3 Rule 3 — references are second-class views

&place / &mut place create a view: an alias of a place, readonly or writable. Views are values of view type (&T, &mut T) but are deliberately second-class — a view may not outlive the thing it views:

  • A view may be a parameter (the caller’s place is lent for the call), a short-lived local, or a return projecting a parameter (§6.5). One vocabulary, position-dependent defaults: &mut self on a method, bump(&mut c) at a call site, x: &mut T in a signature all carry the same convention.
  • A view may not be stored in a struct field, a collection element, or a Signal/Shared payload; may not be returned except through a borrows projection; may not be captured by a closure that outlives the place; may not cross an await (§6.6).

Mutating through a view writes the viewed place; reading its value requires an explicit *. A view in value position — passed where a value is expected, used as an operator’s operand, or bound to a value type — is a compile error, never a silent coercion to the pointee (so the (base, key) representation of a scalar view can’t leak); write *v to copy the value out. Iteration by view (for e in &mut list) binds each element as a view — assignment and field writes go through; *e reads the element. The parameter conventions:

ConventionWrittenMeaning
barex: Tby value (a copy — rule 1)
ownown x: Tby value, explicitly (documentation of intent)
ref&x / x: &Treadonly view
ref mut&mut x / x: &mut Twritable view

6.4 Rule 4 — no invalidating mutation under a live view

While a view of a place is live, mutations that would invalidate the view (replacing the aggregate that contains the place, removing the element it points into, resizing past it) are forbidden. The compiler enforces the statically-decidable half in full: reassigning the viewed root (or an enclosing place), and any call that may advance the root’s geometry — the callee’s inferred bumps verdict per parameter, so a content-stable &mut callee (one that only writes fields or elements through the parameter) passes freely. Views anchor at their origin roots wherever they arise: a direct &place, a view-returning call (the callee’s borrows positions mapped through the arguments), or a wrapped-view match capture — a projection returned through a call is policed exactly like the &place it came from.

Implementation note: the dynamic remainder (aliasing reached through calls, container-internal invalidation) is tracked future work. When it lands it enforces the same event set as the static rule above (§6.0’s design invariant): a trap fires on a reassignment, a geometry change, or a death under a live claim — never on a mere overlap of content writes, which the static rule deliberately permits (aliased views, and writing through them, are legal). Shared.read()/write() are the cell-level form of this dynamic check (§6.7).

6.5 Projections: borrows

A function may return a view into one of its parameters — the one sanctioned escape from rule 3’s return ban. The projected parameter is named by a borrows clause, which is inferred when the body makes it evident (a method returning a view of self needs no clause):

fun write(self): &mut T borrows self;   // Shared::write — explicit
fun get(&mut self, i: i32): &mut T      // inferred: borrows self

At the call site the returned view obeys the same second-class rules, with the borrow anchored to the projected argument: the argument’s place is treated as viewed while the result is live (rule 4 applies to it). Returning a view of a local is always an error (it would dangle).

The clause is a set: a function whose branches return a view of different parameters projects them all (borrows a, b), and the call result anchors at every corresponding argument. Hover shows the inferred clause on any projection, alongside its sibling effect bumps — the per-&mut-parameter geometry verdict §6.4’s call rule fires on. A &mut parameter the body only writes fields or elements through is content-stable (absent from the clause; the owner’s epoch holds); one the body may resize, insert into, remove from, or whole-reassign through bumps. Both effects are inferred, never written (the explicit borrows clause remains legal); an unknowable callee — a bodiless trait signature, an untabled host function — is treated as bumping and, when view-typed, as projecting every argument: the conservative direction.

Option<&T> is permitted as a return type for “a view, maybe” (map lookups); the Some payload obeys the same anchoring. An Option<&mut T> may also be built inline as a transient and matched in the same expression — match Some(&mut a) { Some(let v) => … }, including the conditional form match if c { Some(&mut x) } else { None } { … } and forwarding a bare view parameter (match Some(p) { … } for p: &mut T). Because the transient never outlives the match that consumes it, its payload may view a local (unlike a returned projection). Binding the same constructor to a let stores the view and is rejected.

6.6 Views and suspension

A view may not be live across an await. Between suspension and resumption other code runs and may invalidate any place; rather than extend rule 4 across turns, the language forbids the shape. Re-derive the view after the suspension:

let row = &mut rows[index];
send(row.id);              // suspends
row.text = "sent";         // ✗ error: view live across await

send(rows[index].id);      // ✓ re-derive
rows[index].text = "sent";

This applies to every suspension point: calls to async functions (implicitly awaited, §7), explicit await, and calls through async closure values.

6.7 Library escape hatches (informative)

Shared<T> (one shared cell; read() copies, write() yields a statement-scoped view of the contents) and Arena<T>/Handle<T> (stable, generation-checked identities — handles are plain values, storable where views are not) are std types built on these rules, not extensions of them. They are §6.0’s dynamic regime: a Handle’s generation is its owner’s epoch carried as data, and Arena.get answers Option<&T>. When the counted tier (§6.8) gives Shared a runtime check, that check enforces the same event set as rule 4 — a reassignment, geometry change, or death of the cell under another live view traps, while overlapping writes through the cell do not (the reconciled trap law of §6.0; a write view is not exclusive). See cells.

6.8 Resources and destruction

(Design: proposal/destruction.md.) Rule 1 copies values, and a droppable value cannot survive copying: a copied file handle double-closes, a copied refcount miscounts. Destruction is therefore not bolted onto the data world — the world is partitioned. Data is everything above: copied on binding, elided at last use, reclaimed by the host. A resource is a small, explicitly-rooted class with affine discipline — one owner at a time, no copies — whose owner’s scope end runs its destructor. This section is the static death cell of §6.0: a resource’s move ends the source binding’s epoch, and drop is its final event.

The class is two tiers. Tier 1, specified here and enforced on JS, is unique resources: one owner, move-only. Tier 2 (Shared as a counted resource, Weak, counted closure environments) is specified against the future native arc and is out of scope for this section beyond the forward references above.

The resource class

  • resource is a declaration modifier, written in external’s position: resource struct S, resource external struct D, resource enum E.
  • Containment infers. An aggregate — struct, enum, tuple, or fixed array [R; n] — with a resource field, payload, element, or member type is a resource, recursively (the Wire/Hashable all-fields machinery with the polarity flipped: any resource member marks the whole). Declaring resource on such a type is allowed and checked; omitting it never hides resource-ness.
  • The modifier is required at leaves. An external struct is opaque, so a host-object resource (Database) must declare itself one.
  • Per-instantiation for generics. Option<Database> is a resource instantiation; Option<i32> stays data. Resource-ness of a generic type is decided at each instantiation, like the platform and asyncness bits.
  • Drop may be implemented only for a resource type (see below); an impl on a data type is an error steering to add resource.

The affine rules

Move transfers ownership and leaves the source binding dead; loan is the existing second-class view (self / & / &mut conventions, §6.3), which changes no ownership and is policed by rule 4.

  • R1 — binding moves. let b = a; transfers ownership; any later use of a is a compile error naming the move site. No copies ever fire for a resource.
  • R2 — overwrite drops. Assigning onto a binding that still owns a resource drops the old value first, then moves the new one in.
  • R3 — parameters. self / &x / &mut x are loans, unchanged; own x is a move — and for a resource only a move: an own argument that is not the binding’s last use is an error (where a data own would silently copy).
  • R4 — returns move out, including through if / match tails (a diverging leg is exempt).
  • R5 — fields. A struct literal moves resources in. A resource field is read only by loan (self.db.exec(..), &mut self.db); moving it out of a live aggregate is rejected — v1 has no partial moves. The sanctioned partial move is Option (below).
  • R6 — match consumes. Matching a resource by value consumes the subject; pattern captures move the payloads into the arm. Matching a loan (match &self.state) inspects without consuming.
  • R7 — no conditional moves. A binding must be moved on every path through a scope or on none; moving it on one path only is an error. This keeps end-of-scope ownership static — there are no runtime drop flags in v1.
  • R8 — no moves in repeatable interiors. Moving a binding declared outside a loop from inside its body is an error (the move would repeat).
  • R9 — closures and spawns cannot capture resources. Capturing one would give the closure a second owner. Injected context-clause bodies receive resource parameters as loans — parameters are per-call, not captures — so nursery(|n| ..)-shaped APIs are unaffected. A closure referencing a module-level resource is likewise exempt: a module global is loan-only and lives for the process (see Module-level resources never drop, below), so the closure can never own it and no second owner is created — the reference is a per-call loan, exactly like a parameter. Captures of a local or a parameter stay rejected.
  • R10 — no resource elements in the native containers. List / Map / Set and every external generic (Shared, Task, Promise, Context) reject resource type arguments in v1 — their internals are host code the move checker cannot see. Option is the sanctioned container (it is a vilan enum, checkable under R11).
  • R11 — generics must be move-clean per instantiation. Instantiating a type parameter with a resource type re-checks the instantiated body under the affine rules (T := the resource): every T-typed value is used at most once as a move, with no captures and no copies. Option::unwrap(self): T passes; a body that reads its parameter twice fails at the instantiation site, not inside std. For an own T parameter the rule tightens to exactly once (a generic body is emitted once and so cannot run an instantiation-conditional destructor; zero moves would leak).
  • R12 — no coercion to any. A resource passed where any is expected is an error (print(db) included): any is a data sink, and the discipline must not launder away. Debug-print the fields instead.

Destruction — the Drop trait

trait Drop {
	fun drop(&mut self);
}

The body cleans up through the &mut self loan; the compiler destroys the fields afterward, in reverse field order. &mut self is the exact and only accepted shape — a by-value self could move the value out and keep it alive (resurrection), a &self receiver cannot run the mutating teardown, an extra parameter cannot be supplied by an inserted call, and a value-returning body is rejected. Two further restrictions are enforced:

  • drop is synchronous. An async or awaiting drop body is rejected — teardown runs synchronously in v1. (Cancel owned tasks through an OwnedNursery, whose own drop cancels them.)
  • drop is context-free. A drop body that requires an ambient context (for example, one that writes a Signal, which threads the turn as a hidden context argument) is rejected: a destructor’s call sites are scope exits, which thread no context.

A resource without a Drop impl is legal — containment alone still enforces moves and destroys the resource’s fields. Drop is distinct from the cooperative Disposable protocol, which is the data-world teardown hook (subscriptions, owners) and is capture-based (exactly why it is not a resource mechanism).

Drop timing and order

At the owner’s scope end, still-owned resource locals drop in reverse declaration order. A value’s own drop body runs before its fields, and the fields drop in reverse field order; an enum’s payload drops with the value. Every exit runs drops — fall-through, ret, jump break, jump continue (out of the scopes they leave), and panic unwinding — because a resource-owning scope lowers to a try/finally and every exit flows through the finally. Concrete own resource parameters drop at scope end like locals (a generic own T is required to move out instead, per R11).

  • Module-level resources never drop. A top-level let resource has process lifetime (the serve-forever server’s Database). It is consequently loan-only: moving it into a local, an own argument, or drop(x) would hand a process-lifetime resource to a droppable owner and is rejected; method calls and & / &mut passing are accepted.
  • Panic during unwind. A drop that panics while a panic is already unwinding replaces the in-flight error (JS finally semantics; a native backend would abort).
  • Across await. Owning a resource across a suspension is legal — frames own their locals, and §6.6’s no-view-across-await restriction is about loans, not ownership. Under cancellation a bridged operation rejects, the frame unwinds, and drops run.
  • Exit after finally. When a value-returning main owns a resource, its process exit is sequenced after the teardown finally runs, so scope-end drops are never skipped by process termination.

Option.take and Option.replace

Moving out of a place must leave a valid value behind. One intrinsic pair on Option<T> provides the sanctioned partial move:

impl Option<type T> {
	fun take(&mut self): Option<T>;              // Some(v) -> None here, Some(v) out
	fun replace(&mut self, value: T): Option<T>; // new value in, old contents out
}

self.slot.take() is how a resource field leaves a live aggregate (R5), and match opt.take() { Some(let c) => drop(c), None => {} } is the conditional-teardown idiom R7 pushes toward. Both are ordinary std surface, useful for data too.

Early teardown — drop

fun drop<T>(own value: T) {}

Moving a value into drop destroys it at that (immediate) scope end instead of waiting for the owner’s scope to close. The call is rewritten at each site by the concrete argument type: for a resource it lowers to that type’s destructor; for plain data it is a no-op that consumes the argument for its effects. There is no public close() to keep in sync with a destructor — drop(x) is the early form. Inside a generic body a drop(x) on a value of a still-abstract type T has no concrete destructor (a generic body is emitted once, erased), so R11 rejects it under a resource instantiation (“the erased body cannot destroy a T — move it out to the caller”); a data instantiation keeps the legitimate no-op consume.

OwnedNursery

OwnedNursery (std::task) is the resource-owner for object-lifetime background work that no function-scoped nursery can hold. It wraps a Nursery (which stays data — the ambient handle; Context<R> is R10-rejected, so ownership lives only in the wrapper). enter(body) runs body with the owner’s nursery established as ambient — every task spawned in the body’s dynamic extent registers with the owner — but, unlike nursery, does not join: it returns the body’s value as soon as the body settles, leaving the spawned tasks running under the owner. Its Drop cancels the owned nursery, so dropping the owner (at scope end or via drop(owner)) aborts in-flight bridged IO.

Its nursery runs in detached mode: because nothing ever joins the owned children, a child failure that is not a cancellation echo takes the free-task reporting path (console, with the spawn origin) rather than being stored for a join, and a child does not cancel its siblings — ownership is lifetime, not fate-sharing. Cancellation echoes stay silent.

Services and resources

A [service] struct that owns a resource field is itself a resource by containment, and its generated dispatcher builds per-[rpc] handler closures that capture self — which R9 forbids. R9’s module-level exemption does not apply here: self is a local store value, not a module global, so the capture is a genuine second owner. This collision is by design: the sanctioned shape is the module-level idiom — hoist the resource (a Database) to a module-level let, and let the store hold only its reactive state (plain data) and reach the resource by loan. The module-level resource is process-lifetime and never drops, and reachability keeps its initializer out of the client bundle.

Honesty limits

The following are recorded limits the model does not promise, not bugs:

  • A never-settling await leaks its frame’s drops. An unbridged await that never resolves (nothing cancels it) leaves its frame — and the resources the frame owns — undropped. This is structured concurrency’s concern, not the memory model’s.
  • R11/R12 diagnostic residues (completeness, not soundness). The per-instantiation move scan descends into direct lexical closures only, so a double move of T inside a nested closure is not separately flagged (its capture is still caught); dispatched (trait-typed-receiver) callees are not re-discovered by R11’s scan or R12’s any-coercion check, matching the standing convention that convention checks skip dispatched callees — R11’s per-instantiation re-check is the net under that residue. A cross-file instantiation may anchor its primary span imprecisely (the body note carries the correct source). A module-initializer global → global move is not scanned — benign, since module globals never drop.

Spec §7 — Execution & async

7.1 Program start and termination

A program’s entry module executes its top-level statements in order. A function named main at the entry module’s top level is the entrypoint: it is invoked automatically after the top-level statements. (An explicit top-level main(); call is redundant — the program still runs main once.)

Module-level bindings initialize before main, in dependency order. A binding’s initializer runs after the initializer of every binding it evaluates at load time: the bindings its own initializer reads, and everything read inside whatever it calls while initializing. Creating a closure evaluates nothing — a closure body runs only when something calls it, so the bindings it mentions order nothing (two module-level closures may name each other freely). Textual position carries no meaning: a binding may read one declared below it, in the same file or another, exactly as a function may call one declared later (§4.4). A cycle in that relation is a compile error, since no order satisfies it — including the one-binding cycle of an initializer that reads itself. A const initializer evaluates during compilation (§9), so it evaluates nothing at load time and waits for nothing; a binding that reads it still initializes after it.

Where the relation leaves two bindings unordered, the one whose module loaded first initializes first. Modules load in a canonical order: the standard library’s first, then the dependency packages’ — each package after the packages it depends on, in an order the dependency graph fixes rather than the manifest’s listing — then the program’s own; within one package, modules load by name. Each package’s root module follows, in that same package order, and the entry file is last. Within one file, bindings initialize in declaration order. Nothing here depends on how a file spells its imports, so reordering import statements, or the names inside a { .. } list, cannot change what a program does.

Only a binding something reachable from main references initializes at all (§11.2’s reachability rule): an unreferenced binding’s initializer never runs, so load-time side effects are not a promise.

On process platforms (node/deno/bun), the process exits when no live work remains: after main completes, only live host handles — a running timer, an open socket, a listening server — keep it alive, per the host event loop. Pending tasks holding such a handle run to completion; pending work that holds none is abandoned at exit. A program must therefore not rely on a dropped spawn completing (join it — §7.7 — to guarantee that), and a long-lived program must keep main open (a listening server does so inherently; a socket-holding client must await something that ends with the app). On the browser platform, main’s completion leaves installed handlers and subscriptions live.

panic(message) aborts execution with the message; it types as any (§5.1). Failed asserts panic.

7.2 Evaluation order

Within an expression, evaluation is left-to-right: operands before operators apply, the callee before its arguments, arguments in source order, the receiver before method arguments. A compound assignment evaluates its target place once. Short-circuit: && and || evaluate the right operand only when needed. if/match evaluate exactly the taken branch; a match evaluates its subject once, then tests legs top to bottom (first matching leg wins; its guard is evaluated only when the pattern matches).

7.2a Integer overflow

Arithmetic whose mathematical result exceeds the operand type’s range is undefined behavior: a conforming program does not overflow, and an implementation may produce any value for one that does (the JS backend yields f64 artifacts — precision loss for the wide types, out-of-range magnitudes for the narrow ones — without trapping). Literals are range-checked at compile time (§2.3); runtime operations are not. An opt-in checked family (add_safe, …) is recorded future work; BigInt is the answer where the range itself is the problem.

7.3 The async model

vilan is await-by-default. Asyncness is a property of functions, inferred, and never written in return types:

  1. A function is async iff its body contains a suspension point: a call to an async function (including async externs), an explicit await, or a call through an async-typed closure value.
  2. A direct call to an async function is implicitly awaited: the caller receives the plain declared value, and the caller becomes async (asyncness propagates through the call graph).
  3. A call through a closure value is never inferred async by itself — there is no static callee. The closure type carries the marker instead (§7.4).

The explicit forms:

  • async exprspawn: evaluate expr’s suspending computation concurrently; the spawn expression itself does not suspend and yields Task<T> where T is expr’s type (the task handle is opaque, and copying it refers to the same task). async { … } spawns a block.
  • await expr — suspend until the Task<T> (or raw host Promise<T>) operand settles; yields T.

A spawned computation runs to its first suspension synchronously, then interleaves with its spawner per the host event loop. Dropping a task abandons nothing — the computation still runs; only its result is discarded. A task’s failure is absorbed at the spawn: it is delivered to whichever await observes the task, and a failed task that is never observed is reported to the host console with its spawn origin — it does not terminate the program.

import std::print;
import std::time::{ sleep_for, Duration };

fun step(label: str): str {
	sleep_for(Duration::millis(1));
	label
}

fun main() {
	let pending = async step("b");   // spawned
	print(step("a"));                // awaited inline — prints first
	print(await pending);
}

7.4 Async closure types, adoption, and adaptation

async |T| U marks a closure value whose calls are implicitly awaited (suspension points in the caller). The marker is legal on parameter types, let annotations, struct fields, and function return types; calls through a marked field read ((self.hook)()) and through a returned value (make()(), or via a binding) await like any other. An unannotated binding adopts asyncness from any value it holds — its initializer or any mut rebind — so a let f = || { …await… }; needs no marker at all.

A synchronous closure flowing into an async-typed position is fine (awaiting a non-promise yields it unchanged).

sync |T| U on a parameter declares the opposite contract: the callback completes inside the declaring function’s synchronous protocol, and an async closure argument is an error. (sync is a contextual keyword — it only means the contract directly before a closure type.) std::reactive’s recompute positions (Signal::map, set_with, turn/batch bodies, the UI render callbacks) are sync.

Adaptation. A plain, value-returning closure parameter is asyncness-polymorphic: each call instantiates the function with the actual asyncness of its closure arguments —

  • an async argument instantiates an async instance: calls through the parameter are awaited sequentially (each callback settles before the next begins; effects are ordered exactly as the sync body orders them), the instance is async, and the caller awaits it;
  • sync arguments select the untouched plain instance — no awaits, no coloring, identical emission.

An async adapted instance traverses a snapshot of any value it iterates (the receiver as of the call): its awaits admit interleaved work, which must not tear the traversal. Adaptation follows a closure through plain parameters transitively (helper(xs, f) forwarding f into map adapts both), and never crosses these boundaries:

  • a sync parameter (error, including transitively — the diagnostic names the call that made the closure async);
  • a host (external) function’s value-returning closure parameter — host code cannot await a vilan closure;
  • a trait/generic-dispatched call — there is no statically-known callee to instantiate (bind the receiver concretely, or declare the trait parameter async);
  • a module-level initializer — it cannot await (§7.6’s J.3 rule).

The divergence rule (the remaining stores). An async closure flowing where a plain closure type is declared on a struct field or a function’s declared return type is:

  • an error when that closure returns a value — the reader would receive a live promise typed as T; declare the field or return type async |…| T instead;
  • legal when it returns voidspawn semantics: the call fires the closure and nobody awaits it. This is what allows UI event handlers and other void callbacks to suspend freely, and it applies to parameters as well (a void parameter spawns rather than adapts).

7.5 Suspension and state

At every suspension point the enclosing function’s live state is captured and restored on resumption — with one carve-out: views may not be live across a suspension (§6.6). Ambient context values (§8) are captured at closure creation and are therefore stable across suspensions by construction.

Concurrency is cooperative and single-threaded per program: between a suspension and its resumption, other computations (event handlers, other spawned work) may run and mutate shared cells; within one synchronous extent (no suspension), execution is atomic. The reactive turn machinery (std::reactive) is a library discipline layered on these primitives, not part of the language.

7.6 Emission guarantees (observable behavior)

A conforming implementation targeting JavaScript guarantees: the entrypoint contract of §7.1, and the module-level initialization order it fixes; print writing one line per call to the host console; panics rejecting/aborting with the given message; left-to-right evaluation per §7.2; truncating integer division and two’s-complement as_* folds (overflow excepted — §7.2a); and i53 exactness over the wire across its whole range. Everything else about the emitted code — names, formatting, module layout beyond that order, the [build] knobs — is implementation-defined.

7.7 Nurseries

std::task::nursery(body) runs body with a nursery established for its dynamic extent — the body’s execution and everything it calls, under the ambient-value rules of §8 (in particular, closures capture the extent at creation). Within the extent, every spawn (§7.3’s async expr) registers its task with the nursery; spawns outside any nursery are unaffected. There is no implicit root nursery: a program’s free spawns keep §7.3’s unstructured behavior.

Join. The nursery call returns the body’s value, and returns only when the body and every registered task have settled — including tasks registered while draining (a running task’s own spawns register with the same nursery). The call is async (its callers await it), and the body parameter is a plain closure parameter, so an awaiting body selects an adapted instance per §7.4.

Failure. The nursery’s outcome is the first-observed failure:

  • a body throw wins (it always happens before the join);
  • otherwise the earliest-settled task failure wins, re-raised from the nursery call; a failure that is a vilan panic carries the spawn’s origin (the spawning function’s name) in its message.

On the first failure the nursery cancels (below). Every other registered task is absorbed: observed — so it neither becomes a host unhandled rejection nor triggers §7.3’s unobserved-failure report — with its result discarded.

Cancellation is cooperative. Each nursery owns a host abort signal, fired by the body handle’s cancel() or by the first failure; a nursery created inside another’s extent chains to its parent’s signal at creation. The standard library’s IO primitives pass the ambient nursery’s signal to the host operation, so cancellation rejects in-flight IO promptly; those rejections — and any rejection classified as a host abort — are cancellation echoes: absorbed at the join, never selected as the failure. Execution is never preempted: code runs until its next cancellable suspension (is_cancelled() is the check for compute loops). After an explicit cancel() the body continues and its value is still returned; a body suspended on cancellable IO when the signal fires observes the rejection, which then propagates as the nursery’s outcome under the body-throw rule.

A nursery call in a module initializer is rejected (§7.4’s initializer rule: an initializer cannot await).

Spec §8 — Contexts

A context is a dynamically-scoped value: established for the dynamic extent of a body, readable from anything that runs within that extent, invisible outside it. Contexts are vilan’s answer to ambient parameters — the current reactive owner, the current turn, the enclosing nursery — without global mutable state and without threading a parameter by hand through every signature.

Contexts are compiled away. The implementation threads each context’s value as a hidden parameter through exactly the functions and closures that transitively read it; there is no runtime storage, no async-local machinery, and a program that never creates a context pays nothing. Everything in this chapter is checked at compile time.

8.1 The model

std::context::Context<T> is the handle (appendix §A.4 — a lang item: the compiler keys on its operations). A context is created once, at module level, and referred to by that name:

let flavor: Context<i32> = Context::new();
  • flavor.run(value, body) — establish: value is the context’s value for the dynamic extent of body (the closure’s execution and everything it calls, transitively). run yields body’s value.
  • flavor.get() — the strict read: yields the established T.
  • flavor.get_safe() — the safe read: yields Option<T>Some(value) under an enclosing run, None otherwise.

Context::new, run, get, and get_safe are intrinsics: the threading pass rewrites their call sites away. They must be applied directly to the context’s name (a receiver that is not a named context is rejected). A context is not otherwise useful as a value: moving one through a parameter or a field severs the link between its runs and its reads — the reads can then never be covered. run’s body argument must be a closure literal (or an injected closure value, §8.5).

Context<T>’s value type is inferred from its first run, exactly as a List<T>’s element type is inferred from push.

import std::print;
import std::context::Context;

let flavor: Context<i32> = Context::new();

fun describe(): str {
	i"flavor {flavor.get()}"
}

fun main() {
	let inner = flavor.run(7, || {
		print(describe());   // flavor 7 — dynamic extent reaches callees
		flavor.run(9, || describe())
	});
	print(inner);            // flavor 9 — the nearest run wins
}

Establishment nests: a read observes the nearest enclosing run. Two runs of different contexts are independent.

8.2 Reads: strict and safe

The two read forms differ in what they demand of the program, not in what they return at a covered site:

  • A strict get() demands coverage (§8.3): the compiler proves every path that reaches it passes through an enclosing run. Code holding only strict reads carries the bare T.
  • A safe get_safe() never demands coverage: uncovered paths supply None. Code reachable without a run carries the value as Option<T>; at a covered→safe boundary the bare value wraps in Some automatically.

Strictness is a property of code regions, propagated caller-ward: a function that (transitively) reaches a strict read is strict; a function whose only demands are safe reads is safe. One function may be strict for one context and safe for another — each context threads independently.

8.3 Coverage

For every strict read, the compiler checks — over the whole call graph — that the reading code cannot be entered without the value. It is a compile error (“context is read here, but this code can be reached without an enclosing run”) when a strict read is reachable from:

  • the program’s top level or the entrypoint main (the uncovered roots — main is semantically the outermost, run-less extent);
  • a module-level initializer;
  • any caller chain that does not pass through a run of that context.

Trait/generic-dispatched calls are covered conservatively: a dispatch site is treated as reaching every candidate implementation (and the trait default), so a needy candidate demands coverage of the caller even if another candidate would have been selected. Dead code — a function with no callers at all — is exempt (it cannot run uncovered); a function called only from top level, or taken as a value, is not.

A function that reads a context cannot be used as a value (“ reads context , so it can’t be used as a value”): an indirect call would bypass the hidden parameter. Wrap it in a closure literal at the use site — the closure captures the channel correctly (§8.4).

Safe reads never fence. In uncovered positions they read None; this is what lets library code ask “is there an ambient X?” from anywhere (the standard library’s spawn registration does exactly this — §7.7).

8.4 Closures capture at creation

A closure created inside a covered region captures the context value at its creation site, and the capture is what its body reads — regardless of where the closure is later called:

import std::print;
import std::context::Context;

let flavor: Context<i32> = Context::new();

fun main() {
	let snap = flavor.run(3, || {
		|| flavor.get()   // captures 3 at creation
	});
	print(flavor.run(8, || snap()));   // 3 — not 8
}

This is the stability rule §7.5 relies on: ambient values do not shift across suspensions or across deferred invocation, because the closure’s channel was fixed when it was made. (Contrast dynamic-binding systems where the call site’s environment decides; in vilan only run’s extent and creation sites decide.)

8.5 Injected closures: the context clause

Capture-at-creation has one structural consequence: a closure literal passed to an establishing function is created before the extent exists, so it would capture the caller’s (often absent) value — exactly wrong for helpers like nursery(body) or run_with_owner(owner, body) whose entire purpose is to run the body under a fresh value.

A context clause on a closure type solves this by deferring the binding to the call site:

fun with_flavor<T>(body: (|| T) context flavor): T {
	flavor.run(7, body)
}

A parameter (or let binding) whose closure type carries context <name> declares an injected closure:

  • A closure literal supplied to that position does not capture at creation; it takes its own hidden parameter, bound anew at each call.
  • Each call through the injected value demands the context from the caller like a strict read, and supplies the caller’s value.
  • An unannotated local binding holding a closure literal, passed into a clause position, adopts the clause (as if the literal were written inline).

Because the binding is deferred, an injected value may only flow where the threading can follow it: it can be called, forwarded to a parameter with the same clause, or passed as run’s body. Any other use — storing it in a field, returning it, putting it in a collection — is a compile error (“an injected (context-typed) closure can only be called, forwarded …, or passed to run”).

A clause may name several contexts (context (a, b)); the clause must name context bindings, and it composes with the closure-type markers of §7.4 ((sync || T) context turn_scope is the reactive layer’s shape).

8.6 Interactions

  • Async (§7.5): captures are fixed at creation, so a context value is stable across every suspension of the extent by construction. An awaiting run body holds its value across its whole chain.
  • Spawns (§7.7): every spawn site is an implicit safe read of the standard library’s ambient nursery — inside a nursery’s extent the value is present and the task registers; outside, the read is absent and the spawn stays free-floating.
  • Platforms: the threading pass runs before emission for every target; contexts work identically on process and browser platforms (no host storage is involved).

8.7 The standard library’s ambient values (informative)

std builds its ambient machinery on this one mechanism: owner_scope (the reactive disposal owner — run_with_owner, comp), turn_scope (the current write-batching turn — turn, batch), and ambient_nursery (std::task — nursery registration and the cancellation signal). Their semantics are library contracts, not language rules; they are specified by their reference pages.

Spec §9 — Const evaluation

const expr evaluates expr during compilation and replaces it with its result: the emitted program carries the value as a literal, never the computation. Const evaluation and macro expansion (§10) run in the same fueled compile-time interpreter; they are the two phases that execute vilan code at build time.

9.1 The const expression

const is a prefix operator over an expression. It captures greedily: everything to the end of the surrounding expression folds (const 1 + 2 * 3 folds 7); parenthesize to narrow the extent — in (const square(4)) + square(2) the second call runs at runtime.

A const expression may appear anywhere an expression may, including module-level initializers — where it is also the way to run logic at load position without runtime cost: a const initializer ships as a plain value, participates in no platform coloring (§11.2), and cannot violate the initializer rules of §7.4 (nothing of it remains to run at load time).

9.2 The const environment

The evaluated expression may use exactly what the compiler can know:

  • literals, and the pure operations of the language (§7.2’s evaluation order applies unchanged);
  • functions whose bodies are themselves const-evaluable, transitively;
  • imports, and immutable module bindings whose own initializers are const-evaluable.

Host capabilities do not exist at compile time. A call that requires the host — the clock, the filesystem, network, randomness, any external function without a compile-time definition — is a compile error inside const (“now() is not const-evaluable”), not a deferred runtime call: the answer would not be a constant.

The one deliberate exception is std::asset::emit(kind, content), callable only during const evaluation: it declares a build asset (the styling system’s CSS, for example) that the build writes beside the output. Asset emission is deterministic — same inputs, same files.

9.3 Failure and resource limits

Const evaluation is total by construction of the budget: each run is bounded by the interpreter’s fuel (steps) and depth (nesting), shared with macro expansion and configured by the manifest’s [macro] section (§11.4). Exhausting either, or panicking during evaluation, is a compile error carrying the const expression’s span — a runaway const fails the build; it cannot hang it.

9.4 Results

The result of a const expression is spliced as a literal of its type: numbers, strings, booleans, lists, tuples, structs and enums of const-evaluable contents. The expression’s type is checked exactly as if it ran at runtime; const never changes typing, only when the computation happens.

Spec §10 — Macros

A macro is a compile-time function that receives program structure as data and returns source code to splice into the program. Expansion happens before name resolution and type checking (§1.2, phase 3): generated code re-enters lexing and parsing and is then checked exactly like handwritten code — a macro cannot smuggle ill-typed code past the compiler.

10.1 Declaring and invoking

macro fun derive_display(item: Item): Source

macro fun declares a macro. Invocation forms:

  • Attribute position: [name] (and [name(args)]) on an item hands the annotated item to the macro as data; the returned source replaces or augments the item per the macro’s construction API. [derive(A, B)] is the derive spelling: each named macro receives the type and its output is spliced alongside it.
  • macro { … } blocks: an anonymous macro expanded on the spot. In item position the returned source splices as items; in expression position the block folds to the value of the returned expression (for plain values, prefer const — §9).
  • Macros may call other macros as ordinary functions during expansion.

10.2 The macro environment

A macro’s body is ordinary vilan, but it compiles against macro_std — the compile-time standard library (source, the meta item types, collections, strings) — and only that: a macro’s imports are its own, and it cannot reference the surrounding program’s bindings. Macros see one item at a time; there is no whole-program reflection, no ordering guarantee between expansions, and no communication between macro runs. Like const evaluation, each expansion runs in the fueled interpreter (§9.3): fuel and depth exhaustion, and panics, are compile errors at the invocation’s span.

10.3 Inputs and outputs

The annotated item arrives as a macro_std::meta value (Item, with accessors such as as_struct() yielding names, fields, and types as data). The macro returns Source — text, usually built by interpolation (source(i"…")); literal braces in generated code are escaped \{ \}.

Returned source is parsed as items (or an expression, for expression-position blocks) as if written at the invocation site: names in generated code resolve in the annotated item’s module, and imports the generated code needs must be written into the generated source itself. Errors inside generated code are reported anchored at the attribute that generated it (the diagnostics standard’s macro rule), with the generated text available to tooling.

10.4 Limits

The manifest’s [macro] section (§11.4) bounds every compile-time run: fuel (interpreter steps per run) and depth (nested expansion — a macro whose output invokes macros). Exceeding either is a compile error, so a runaway macro fails the build rather than hanging it.

10.5 Standard attributes (informative)

[derive(Wire)], [derive(Hashable)], [service(…)], [rpc], and the other attributes the guides use are macros shipped in the standard library — the same mechanism as §10.1, not language special cases. Their semantics are library contracts specified by their reference pages.

Spec §11 — The platform model & manifests

A platform is a host a build targets: node (the default), deno, bun, or browser. The first three form the @process family. One package may build for several platforms at once (§11.4’s entries); the compiler proves, per entry, that no reachable code requires a capability its platform lacks.

11.1 Layers

The standard library is layered:

  • the base layer — platform-neutral, available everywhere;
  • the browser layer (std::dom, std::ui, std::router, std::storage) — browser builds only;
  • the process layer (std::fs, std::http, std::db, std::process, std::rpc_server) — @process builds only.

A library may declare the same shape for itself ([library.layer], §11.4): a neutral root plus per-platform overlay roots.

11.2 Coloring and the reachability check

Every function is colored with the platform requirement it implies — seeded by the layer its externs and std calls live in, flowing callee-to-caller through the call graph (the same inference shape as asyncness, §7.3), including through generic instantiations: a generic function’s requirement is judged per instantiation, so save<T> colors process-only only for the Ts whose code actually reaches a process capability.

The check is on reachable code, not imports: importing a module is free; each entry is checked along the call paths that start at its main (and its reachable initializers). A path that crosses onto a platform the entry does not build for is a compile error naming the chain from the entry to the crossing. Module-level initializers obey the same rule: a binding’s initializer is analyzed, colored, and bundled only if something reachable references the binding. const initializers evaluate at build time (§9) and never color anything.

11.3 Fences

[platform("browser")] (one platform, a family like "@process", or several) on a function declares the platforms it promises to run on. The promise is checked on every compile, whatever the build’s entries: if code the fenced function reaches requires a layer one of the fenced platforms lacks, the error lands at the fence with the offending chain — not at some distant entry in a dependent build. Fences add no runtime behavior; they are checked declarations.

11.4 Manifests (vilan.toml)

The manifest declares what a directory builds. Sections:

  • [package] — an application or plain package: name, description, root (source root; default src/), entry (the entry file, when there is exactly one), target (a platform; default node), and dependencies (name → { path = "…" } for a local directory, or { git = "…", tag | rev = "…" } for a [library] repository pinned to exactly one of a tag or a commit — registry dependencies are future work).
  • [entry.<name>] — one build entry per table: path (default <root>/<name>.vl) and target (default node). A package with entries builds each for its own platform; reachability (§11.2) is what lets one source tree serve several.
  • [library] — a dependency-only package: name, description, root, dependencies, and [library.layer.<name>] overlays (root, platform = ["…"]) for per-platform sources.
  • [project] — a workspace: packages = ["member", …] (paths); building the project builds every member against its own manifest. Its own dependencies are declared once for the members to share: a member writes dep = { project = true } to take that declaration (paths in it resolve against the project root). Inheritance is per dependency and opt-in — nothing is inherited implicitly — and project = true combines with no other key.
  • [build] — codegen options: preset ("debug" | "release") and the per-feature overrides indent, spaces, debug-names. Build options never change program semantics (§7.6), only the emitted text.
  • [macro] — the compile-time interpreter budget: fuel (steps per macro/const run) and depth (nested expansion), §9.3/§10.4.

11.5 Build products

Each entry emits dist/<name>.js for its platform (browser entries first, so a server that ships bundles finds them fresh), plus dist/<name>.css when const evaluation emitted style assets (§9.2). vilan run builds all entries and starts the one @process entry; vilan check checks every entry, always. The emitted text beyond §7.6’s guarantees is implementation-defined.

Spec §A — Appendix

A.1 Operator precedence

Tightest to loosest; binary levels are left-associative (§3.7):

LevelOperators
1:: paths · calls · .member · [index] · ! (try) · ?.
2prefix ! - await async & &mut *
3* / %
4+ -
5<< >> (span-adjacent)
6&
7^
8|
9== != < <= > >=
10is
11&&
12||

Above level 12 sit the expression forms (closures, blocks, if, match, for, let, ret, assignment), and above those the top-tier-only forms: const expr and struct initializers (§3.8).

A.2 Reserved words

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

Contextual (identifier everywhere else): context, void, self, Self, break/continue (after jump), and the attribute names derive service extern must_use rpc trait_only doc expose.

A.3 Literal suffixes

i8 i16 i32 i53 u8 u16 u32 u53 f f32 f64 n — §2.3 (unknown suffixes error; i64/u64 were renamed to i53/u53). Unsuffixed: integer → i32, fractional → f64.

A.4 Lang items

Std declarations the language itself depends on:

ItemModuleLanguage use
primitives (bool, str, numerics, BigInt)std::boolean/string/numberliteral types
List<T>std::listlist literals, for
Option<T>std::option?. results, view-returning lookups
Try, Verdict, Liftstd::operators!, ?. (§5.10)
Add Sub Mul Div Rem Shl Shr BitAnd BitOr BitXorstd::operatorsoperators (§5.7)
PartialEq, PartialOrdstd::compare==/ordering (§5.7)
Iterator/Iterablestd::iteratorfor … in
Task<T>std::taskasync/await (§7.3)
Promise<T>std::promisehost-interop promises (§7.3)
Context<T>std::contextcontexts (§8)
panic, assertstd::iodivergence, vilan test

Error index

You saw an error; this page says what it means and where to go. Messages are quoted the way the compiler prints them, with standing in for the parts that vary. Find yours with a page search.

(Organized companion: the gotchas checklist covers traps by topic rather than by message.)

Names and imports

“cannot find ‘…’ in this scope” · “cannot find type ‘…’” The name isn’t visible here. Usually a missing import — remember that everything, even print, is imported explicitly. If you did import it, check for a typo or a shadowing local. → Hello vilan, spec §4

std is a namespace, not a value — import the module first …” You wrote a qualified path like std::math::min(1, 2) inline. That spelling isn’t supported. Import the module, then qualify through its name: import std::math; and math::min(1, 2). → Hello vilan

requires the layer of std and cannot run on Code reachable from this build’s entry calls into a module the platform doesn’t have — std::fs from a browser build, std::dom from a node build. The error lists the call chain from main to the crossing. Importing the module is not the problem (imports are free); reaching it is. Move the call behind the right entry, or check the package’s target. → Platforms

requires … and cannot run on / reachable from , fenced [platform(…)] A function declared a platform fence and something it (transitively) reaches requires a layer one of the fenced platforms doesn’t serve. The chain shows the path from the fence. Fences check on every compile — narrowing the fence, or moving the colored call out from behind it, are the two fixes. → Platforms

“cannot find module ‘…’ to import” The path names a module file that doesn’t exist. pkg::routes means “routes.vl in this package’s source root” — check the file name and the package you’re in. → Hello vilan

“module ‘…’ resolved to ‘…’ on disk, but it is imported as ‘…’” Your filesystem ignores case (NTFS, and macOS by default) and answered the import with a differently-cased file. Module names match byte for byte (§4.2), so this would fail to build on a case-sensitive filesystem — rename the file or the import so the two agree. → Names, modules, and packages

Types and generics

“Expected …, but got … instead.” The general type mismatch. One special case surprises people: an i53 mixed with a bare integer literal — the literal is i32, and there are no implicit conversions. Suffix it (stamp + 1000i53). → Values and types

“generic parameter ‘…’ is missing the bound ‘: …’ required by this call” You called something that needs a capability (say PartialEq) with a generic parameter that doesn’t declare it. Add the bound to your signature: fun caller<U: PartialEq>(…). → Data and traits

“cannot call method ‘…’ on …” The value’s type doesn’t have that method. If the type is a generic parameter, you probably need a bound. If it says something like |i32| i32, you’re calling a method on a closure — often a sign a different value was passed than you think. → Data and traits

“‘…’ does not implement trait ‘…’: missing ‘…’” An impl … with Trait doesn’t provide every required method, or a bound demands a trait the type never implemented. → Data and traits

“… match the receiver convention” · “… match the parameter convention” · “… match the declared type” · “… match the declared return type” · “… match the declared parameter list” · “… match the trait’s type-parameter list” A method an impl … with Trait provides must match the trait’s declaration, not just its name: the receiver convention (self / &self / &mut self / own self), the parameter count and each parameter’s convention and type, the return type, and — for a generic method — the type-parameter count. Types are compared with Self read as the impl’s subject and the trait’s generic parameters read as the with-clause arguments (impl Meters with From<Feet> expects fun from(value: Feet): Meters). Asyncness is not required to match — an async impl of a synchronous trait method is allowed (dispatch is monomorphized, so callers await it regardless). → Data and traits

“match is not exhaustive: missing …” · “match is not exhaustive: add a catch-all _ leg” Some variants have no arm. Handle them or add _ => …. This error is the feature: it’s what fires everywhere when you add a variant. → Control flow

“struct ‘…’ has no field ‘…’” · “variant ‘…’ does not belong to the matched enum” A field or variant name is off. For the variant case inside match, remember patterns bind with let — a bare misspelled variant is an error here, never a silent catch-all. → Control flow

compares two values of the same type, but the operands are and Comparisons follow the trait model (== is PartialEq, < is PartialOrd): the right operand must be the left’s type, and there are no implicit conversions. An unsuffixed literal adapts to its peer (stamp < 3 is fine for an i53 stamp); two differently-typed variables need a suffix or an as_* conversion. Related: bool has no ordering” (compare with ==/!=) and && takes bool operands” (vilan has no truthiness). → Values and types

“type ‘…’ does not implement the operator; add impl … with … providing An operator was used on a type without the matching trait impl — + needs Add, == needs PartialEq, </<=/>/>= need PartialOrd (implement partial_compare once; the operators dispatch through it, and lt/le/gt/ge come free as defaults). → Data and traits

“the literal is out of range for (…)” The number doesn’t fit the type. For i53/u53 the range is ±2^53 — JavaScript’s exact-integer window. Bigger integers take BigInt (7n). → Values and types

“unknown numeric suffix The letters after the number aren’t a type. If it says i64 or u64: those were renamed to i53/u53. → Values and types

“type of … could not be resolved” Inference gave up somewhere upstream — this error is usually the echo of another one, so fix the first error in the list. When it appears alone, an annotation at the binding usually grounds it. → gotchas

Memory and mutation

“cannot mutate immutable ‘…’” The binding was declared with let. Declare it mut — or, if you’re inside a method, take &mut self. → The memory model

“a view cannot escape its scope: it may not be returned, stored in a field, placed in a collection, or carried in an enum payload. …” Views (&x, &mut x) are short-lived by design: lend, use, done. To keep a reference around, store a plain value, a Handle into an Arena, or a Shared cell. → The memory model

“cannot reassign ‘…’ while a view into it is live (rule 4 …)” Replacing the whole value would detach the view from live storage. Finish using the view first (its life ends with its block), or re-derive it after the replacement. Views anchor wherever they come from — &x, a view-returning call (list.at(0), arena.get(h)), or a Some(let v) capture of one — so the rule is the same for all three. → The memory model

“cannot mutate ‘…’ with ‘….(..)’ while a view into it is live (rule 4 …)” The call may advance the container’s geometry (grow, shrink, reallocate, swap an aggregate field) while a view points into it. Only geometry-advancing callees trigger this — a method that just writes fields or elements through &mut self passes freely (the compiler infers which is which). Do the mutation before taking the view, or after its block ends. → The memory model

“cannot hold a view across ‘await’: ‘…’ is still live here. …” Your function suspends while a view is live, and whatever it points into could change during the pause. Re-derive the view after the await (rows[i].field again) instead of keeping it. → The memory model, Async

“view binding ‘…’ cannot be mut: a view cannot be rebound. …” mut v = &mut x doesn’t mean what it would in Rust. Declare the view with let; assigning through it (v = …) already writes the target. → The memory model

Resources

A resource type has a single owner and moves rather than copies; a struct, enum, or tuple holding one is a resource too, inferred by containment (Option<Database> is a resource, Option<i32> is not). A resource moves on binding (let b = a), on own-passing, on return, and into a constructor; it is loaned — no ownership change — through self, &, and &mut. The Drop destructor trait and its restrictions are below. At each scope end the compiler runs the destructor on the still-owned resource locals, in reverse declaration order — through try/finally, so ret, jump, and a thrown panic all run it on the way out; a resource without a Drop impl still has its fields destroyed. A module-level resource lives for the process and never drops. A drop that panics while a panic is already unwinding replaces the in-flight error (JS finally semantics). The tutorial is Resources; the normative rules are spec §6.8.

“use of after it was moved — a resource has a single owner” The binding was moved (bound to another name, passed to an own parameter, returned, or matched by value) and then used again. The note points at the move. Loan it instead (&x / &mut x, or a method call), or, if you really need two owners, restructure with Option + take. → Resources

“cannot move a resource field out of a live aggregate — … no partial moves …” let x = s.db, or passing / returning s.db by value, would move a resource out of a struct that is still alive — v1 has no partial moves. Loan the field (&s.db, &mut s.db, s.db.method(…)), or make the field an Option<…> and take() it out. → Resources

is moved on one path through this branch but not another — …” An if/match moves the binding on some paths and not others, so its end-of-scope ownership isn’t static (v1 has no runtime drop flags). Move it on every path, or on none — or hold it in an Option and take() on the path that consumes it. A diverging leg (one that rets or jumps out) is exempt: it never reaches the merge. → Resources

is declared outside this loop and moved inside it — …” Moving a binding from a loop body would move it again on the next iteration. Move a value declared inside the loop, or loan the outer one (&x / &mut x). → Resources

is a module-level resource — it has process lifetime and cannot be moved …” A top-level let resource lives for the whole process and never drops (the serve-forever server’s Database). Consuming it — moving it into a local, passing it to an own parameter, or drop(x) — would hand a process-lifetime resource to a droppable owner and close the shared handle out from under the rest of the program. Reach it by loan only: method calls, &x, &mut x. To own a database that closes at a scope’s end, open it in a local instead. → Resources

“a closure cannot capture the resource — …” A closure or async/spawn body referenced a local or parameter resource from an enclosing scope; capturing it would give the closure a second owner. Pass a loan into the call, give ownership to the struct that owns the closure’s lifetime, or hoist the resource to module level — a module global is loan-only and process-lifetime, so a closure may reference it without becoming an owner. (A closure’s own parameter is per-call, not a capture — injected context-clause bodies are unaffected.) → Resources

is not move-clean when instantiated with a resource — …” A generic function or method was called with a resource type argument (Option<Database>, wrap(db)), and its body — checked with that type parameter treated as a resource — breaks the affine rules in one of three ways. It uses a value of the parameter’s type more than once (moves it on some paths but not all, or captures it in a closure) — a resource has a single owner; or an own parameter of resource type is never moved out — because the generic body is shared across every instantiation, it cannot run a destructor, so an own T must be moved out on every path (returned, or handed to another owner), or the function must take a concrete type; or it passes such a value to drop<T> — that erased body has no concrete destructor either, so the resource would leak (drop(x) on data is a fine no-op, which is why the data instantiation stays accepted — destroy at a concrete type, or move the value out to the caller). The error is spanned at the call (the instantiation), with a note into the generic’s body. A clean generic moves each such value exactly once (as Option::unwrap(self): T does), never copying, capturing, or forwarding it to the sink; drop(concrete) on a concrete resource is the destructor. Instantiating the same generic at a data type is unaffected. → Resources

“the resource cannot be used where any is expected — …” any is a data sink, and a resource must keep its single owner: passing one to print, binding it to let x: any, or returning it as any would launder the discipline away. Debug-print the resource’s fields instead. → Resources

cannot hold the resource — a native container’s internals are host code …” List, Map, Set, and the external generics (Shared, Task, Promise, Context) can’t hold a resource in v1 — the move checker can’t see inside host storage. Option is the sanctioned resource container; or keep the resource in a struct field. → Resources

“field of [derive(Wire)] / [derive(Hashable)] / [derive(PartialEq)] type is the resource — …” A resource is not plain data: it cannot be sent over the wire, hashed by value, or compared by copy. Drop it from the derived type, or carry a plain-data handle (an id, a key) in its place. → Resources

implements Drop but is not a resource — … declare it a resource …” Drop — the destruction hook — may be implemented only for a resource type. A destructor without move discipline is exactly the double-close bug: copy the value and each copy would run drop. Declare the type resource so it moves instead of being copied. (Plain-data, framework-driven teardown uses the cooperative Disposable protocol, not Drop.) → Resources

drop for is async — teardown must be synchronous …” A drop body may not be async, nor await (call an async function): a destructor runs synchronously in v1. Cancel owned tasks through an OwnedNursery — whose own drop cancels them — rather than awaiting them. Awaited teardown is a future design. → Resources

drop for requires an ambient context — teardown must be context-free …” A drop body reached something that needs an ambient context — most often a Signal write, which threads the current turn as a hidden argument. A destructor’s call sites are scope exits, which thread no context, so it cannot receive one. Keep teardown context-free: hand turn-joining or signal-writing work to an owner that runs inside a turn, not to the destructor. → Resources

Async

receives an async closure, but its type awaits nothing — declare it async || T (or return void for spawn semantics)” A closure that suspends was stored into a struct field typed as a plain, value-returning closure (at the literal or a later assignment). Either the field should be async |…| T, or — if fire-and-forget is fine — its return type should be void. (A plain parameter no longer produces this error: it adapts — the callee instantiates an async copy that awaits the callback.) → Async, Functions & closures

requires a synchronous closure (sync): its completion is part of the declaring function’s synchronous protocol …” The parameter is a sync contract position — Signal::map, set_with, turn/batch bodies, the UI render callbacks — where the callback must finish inside a synchronous protocol, so it cannot adapt. Move the async work outside the callback: an explicit turn(…) whose awaiting body holds one turn across its awaits, Draft/optimistic for local-first commits, or a spawned async { … } block. The transitive form (“this call passes an async closure that reaches ”) points at the call that made the closure async and notes where it was forwarded. → Async, Reactivity

is a host (external) function — it cannot await a vilan closure …” Host code can’t await your callback, so an external function’s value-returning closure parameters only accept synchronous closures (void-returning ones spawn, as everywhere). A parameter declared async |…| T is exempt — that is the host’s explicit contract to await the closure itself. → Async

“an async closure cannot adapt a trait/generic-dispatched call …” Adaptation instantiates a statically-known callee, and a trait/generic-dispatched call doesn’t have one — the concrete method varies per instantiation. Bind the receiver concretely before the call, or declare the trait method’s parameter async || T so every impl takes the typed channel. → Async

returns an async closure, but its declared return type awaits nothing — declare it async || T (or return void for spawn semantics)” The function’s declared return type is a plain, value-returning closure, but a ret (or the tail) hands back a closure that suspends. Mark the return type async || T so calls through the returned value await — make()() and let go = make(); go() both do — or return a void-returning closure for spawn semantics. → Async

“the initializer of calls , which is async — a module-level binding cannot await” A top-level let runs when the module loads, and module initialization is synchronous — there is no enclosing function to become async, so the value would be a live promise wearing the wrong type. Wrap the work in a function and call it from main. The variant “the initializer of runs a closure that awaits” is the same rule when the awaiting thing has no name — an adopted async closure applied directly, a run(value, body) whose body suspends, or a nursery at top level. (Creating an async closure at top level is fine; it awaits nothing until called.) → Async

form an initialization cycle: module-level bindings initialize in dependency order, and a cycle has no such order” Module-level bindings initialize in dependency order (spec §7.1): each one runs after everything its initializer evaluates at load — the bindings it reads, plus whatever is read inside anything it calls on the way. A cycle among those evaluations has no valid order, so it is refused at compile time; the message names the round trip (via A → B → A) and each participant’s declaration. The self-referential form — “’s initializer evaluates itself, which has not initialized yet” — is the one-binding case of the same rule. Creating a closure evaluates nothing, so two module-level closures may name each other freely; moving one of the cycle’s reads inside a closure is the usual fix. If the chain runs through a dispatched call, every implementation of that method participates — including one your program never instantiates; the message says so when it applies. → Execution

! requires the nearest enclosing function to declare an Option/Result-compatible return type …” ! propagates the failure by returning it, so the surrounding function must return an Option/Result that can carry it. Inside a closure or a UI handler, match instead. → Control flow

Contexts and UI

“context owner_scope is read here, but this code can be reached without an enclosing run The most common first UI error: you built reactive state (an effect, a binding) outside every ownership boundary. Wrap the entry point in mount_root — or run_with_owner in a test. → Building UI, Reactive state

reads context , so it can’t be used as a value” A function that reads an ambient context (like the current owner) can’t be passed around as a plain closure — the context channel would be severed. Wrap it in a closure literal at the use site instead. → Functions & closures

“an injected (context-typed) closure can only be called, forwarded …, or passed to run Injected closures (the ones with context clauses in their type) are deliberately restricted so the ambient value can always be threaded to them. Don’t store them; call or forward them. → Functions & closures

“unused result of a [must_use] call: bind it (e.g. owner.take(…)), or let _ = … to discard.” The call returns something that stops working if you drop it (a Subscription, typically). Keep it, hand it to an owner, or discard it on purpose with let _ = …. → Reactive state

Wire and rpc

“field of [derive(Wire)] type is , which is not Wire — …” Something unserializable (a closure, a Signal) is inside a payload type. Wire types carry data only: scalars, str, bool, List/Option of Wire, and other Wire types. → Services & RPC

RpcError::Contract at connect time Client and server were built from different versions of the service. Rebuild both. During development, a leaked old server still holding the port is the usual culprit — ss -tlnp | grep <port> and kill it. → Services & RPC, gotchas

RpcError::Transport("not connected") / ("connection lost") The connection is down (fail-fast) or dropped mid-call (in-flight rejection). Nothing is retried automatically, because your rpc might not be safe to repeat. Retry at the app level if that’s correct — a draft’s next push already does. → Services & RPC

Compile-time evaluation

asset::emit outside a const expression” Styles (and other build assets) are constructed at compile time. Build the Style in a const (let card = const style()…); select and merge already-built styles at runtime. → Styling, Macros & const

“a const result must be plain data; this evaluates to …” The const expression produced something that can’t be baked into the output (a closure, a host object). Fold values, not behavior. → Macros & const

Syntax

A struct literal in a condition parses as the block Struct literals are ordinary operator operands (Point { … } == q compares), but condition positions exclude them — after if Foo or a match subject, the { is the block/arms, by design. Written without parentheses, if p == Point { … } { … } leaves a bare Point as the condition’s operand, which reports Point is a type, not a value”. Parenthesize the literal: if p == (Point { x = 1 }) { … }. → spec §3.8

Name is a type, not a value” (also “a trait / a type parameter / a module, not a value”) A type, trait, type parameter, or module name was used where a value is expected (let q = Point;). A type names a kind, not a runtime value — construct it (Point { … }), name a variant (Color::Red), or call a static (Point::new(…)). → spec §4.2

Gotchas

A checklist of idioms that trip people up — each with the working shape. Grown as findings land (the backlog’s “idiom traps”, promoted here).

Arriving with an error message in hand? The error index is organized by message instead of by topic.

Language

  • Chained element access on a call result loses the element type. shared.read()[i] → bind, then index: let list = shared.read(); list[i].
  • match can’t be an operator operand. (match x { … }) + 1 → bind the match to a local first.
  • A bare integer literal adapts to its peer; two typed variables don’t. stamp + 1000 and stamp < 1000 are fine on an i53 (the literal takes the peer’s type), but mixing two differently-typed variables in a comparison is an error — there are no implicit conversions; convert with as_* or unify the declarations.

Reactive & UI

  • shared.read() is a copyshared.read().push(x) is lost; write through the cell: shared.write().push(x).
  • Mutate signal lists with set_with, never by mutating a get() result (also a copy).
  • bind_value fights remote updates — for server-backed fields use bind_draft.
  • show keeps bindings live while hidden; use when to drop state and subscriptions.
  • Disposal doesn’t cancel the in-flight wave: a subscriber already queued in the draining turn may fire once more; only later deliveries are guaranteed gone.

Services & the wire

  • Contract-mismatch errors on connect usually mean a leaked old server still holding the port — ss -tlnp | grep <port>, kill by PID.
  • desc is an SQL keyword — name the column description (any SQL keyword as a column name fails in CREATE TABLE).
  • Value semantics cross the wire: a mirrored list is a fresh copy per update; mutate via rpcs, never by writing the client’s mirror signal.

Process & testing

  • A completed node main exits the process — long-lived clients/servers must hold main open.
  • pkill -f <pattern> can match your own shell’s command string — kill by tracked PID.
  • Rebuild the debug binary before regenerating corpus goldens — a stale binary silently writes wrong goldens.

Glossary

One line per term, with a link to where it’s actually taught. Terms are alphabetical. If you meet a word in the docs that isn’t here, that’s a bug in the docs — please add it.

adopt — folding a remote value into a draft without re-sending it. Echoes are ignored, clean fields update, dirty fields win. Reactive state.

arena — a container that owns many values and hands out handles to them. The tool for graphs and cycles. The memory model.

binding — a name introduced by let (immutable) or mut (mutable). Values and types.

bound — a requirement on a generic parameter, like T: PartialEq: “any T that can be compared”. Data and traits.

boundary (disposal boundary) — a place where a UI subtree can die: a mounted root, a list row, a when/swap body. Each boundary has an owner. Building UI.

claim — any alias into an owner: a view, an arena handle, a loan of a resource. Valid only while the owner’s epoch is unchanged. The memory model.

codec — the wire format both ends of a connection agree on: json_codec() (readable) or binary_codec() (compact). Services & RPC.

context (ambient value) — a value carried invisibly to the code that needs it, like the current owner or turn. Established with run, read with get. Functions & closures.

contract check — at connect time, client and server compare a hash of the service’s shape. A stale client fails cleanly instead of corrupting calls. Services & RPC.

copy — what every assignment, argument pass, and field store does to a value. The receiver gets its own; the original is untouched. The memory model.

derive — an attribute like [derive(PartialEq, Debug)] that generates a trait implementation from a type’s shape. Data and traits.

dirty — a draft whose local value has edits the server hasn’t confirmed yet. Dirty fields ignore adoption; the user’s text wins. Reactive state.

draft — a local-first cell for editing server state: typing updates locally at once, commits in the background, and keeps your text on failure. Reactive state.

drop — destruction. The Drop hook runs at an owner’s scope end; drop(x) moves a value in to destroy it early. Only resources have one. Resources.

echo — your own change arriving back through a mirror. A draft recognizes it and does nothing, so your caret never jumps. Reactive state.

effect — code that runs now and again on every change of a signal, cleaned up automatically by its owner. Reactive state.

entrypointfun main in the entry module. It runs automatically; on node the process exits when it finishes. Async.

epoch — an owner’s abstract version counter. It advances when the owner is rebound, resized, moved, or dropped; a claim is valid only while it has not. The memory model.

extern — a declaration binding a host (JavaScript) function, object, or property so vilan code can call it. Platforms.

frame — one encoded message on the wire. You only meet it when building custom transports. rpc reference.

handle — a small copyable id into an arena. Storable anywhere a value is, which views are not. The memory model.

lang item — a std declaration the language itself depends on, like Option for ?. or Add for +. Spec appendix.

layer — the platform-specific part of the standard library. Base is everywhere; the browser layer is browser-only; the process layer is server-only. Platforms.

local-first — updating local state immediately and syncing in the background, instead of waiting on the network. What drafts implement. Reactive state.

mirror — an [expose]d server signal that every connected client receives a live copy of. The server writes; every client updates. Services & RPC.

monomorphization — how generics compile: each concrete use gets its own specialized code, so generic dispatch has no runtime cost. Data and traits.

owner — the object that collects subscriptions and disposes them when its subtree dies. Created by the framework at boundaries; you rarely touch one directly. Reactive state.

panic — aborting the program with a message, for states that should be impossible. Expected failures are Results instead. Control flow.

pattern — the shape on the left of a match arm: a variant to match, payloads to bind (Some(let x)), a literal, or _. Control flow.

platform — what a package builds for: node, deno, bun, or the browser. Decides which std layers are importable. Platforms.

prelude — the few names available without imports: the primitive types, List, void. Everything else is imported explicitly. Spec §4.

resource — a value with a single owner that moves instead of copying and is destroyed deterministically at scope end — a Database, an OwnedNursery. Resources.

safe integer — an integer JavaScript’s 64-bit floats represent exactly: anything within ±2^53. vilan’s i53/u53 are named for this window. Values and types.

service — a server struct whose [rpc] methods clients call and whose [expose]d signals clients mirror. Services & RPC.

signal — a value cell that code can subscribe to. The unit of reactive state. Reactive state.

spawn — starting async work without waiting for it: async expr. Gives you a Task<T>. Async.

task — the handle a spawn yields: eager, opaque, copy-refers-to-the- same-task. A task’s failure is absorbed at the spawn — a later await receives it; an unobserved failure is reported, not crashed on. Async.

nursery — the structured way to spawn: nursery(body) joins every task spawned in the body’s dynamic extent before returning the body’s value, applies the first-observed error rule, and owns the cancellation signal std IO listens on. Async.

subscription — one live “call me on change” registration on a signal. Effects manage theirs through owners; manual sub hands you the object to dispose. Reactive state.

suspension — a point where a function pauses and other code runs: a call to something async, or an explicit await. Views may not be held across one. Async.

trait — a named capability a type can implement, used as a bound on generics. Like an interface, but explicit and compile-time only. Data and traits.

transport — the thing that carries rpc calls: the reconnecting WebSocket in production, http or in-process variants for special cases. rpc reference.

turn — a batch of signal writes that becomes visible at once. Event handlers and rpc handlers each run in one automatically. Reactive state.

value semantics — the rule that data is copied, not shared, unless you use an explicit sharing tool. The memory model.

variant — one case of an enum, possibly carrying data: Shape::Circle(2.0). Data and traits.

view — a short-lived borrow of a place (&x, &mut x) that aliases instead of copying. Can’t be stored, returned into long-lived state, or held across a suspension. The memory model.

wave — one settling of a turn: every affected watcher runs once with the final values. Reactive state.

Wire — the “can travel over the network” capability: scalars, lists and options of Wire types, and anything with [derive(Wire)]. Services & RPC.