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```vilanblock must compile for the node target,```vilan,browserfor the browser target,```vilan,noruncompiles but needs external services to actually run, and```vilan,fragmentis 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
| Chapter | Covers |
|---|---|
| Coming from JavaScript | the three big shifts, a JS→vilan phrasebook |
| Hello vilan | the CLI, vilan.toml, packages & workspaces, imports |
| Values & types | bindings, primitives & numeric widths, strings & interpolation, tuples, collections |
| Functions & closures | fun, closure types, named-fn coercion, async closures and their seams, context clauses |
| Data & traits | structs, enums, impl, generics & bounds, traits, derives |
| Control flow | match/is, loops, ret, Option/Result idioms, ! and ?. |
| The memory model | value semantics, views, mut/own, Shared, Arena/Handle |
| Async | implicit await, async expr spawn, promises, timers |
| Resources | resource, moves & loans, Drop, drop(x), Option.take, Database, OwnedNursery |
| Macros & const | const evaluation, derive macros, macro { … } blocks |
| Platforms | std layers, full-stack packages, externs, assets |
Guides
| Chapter | Covers |
|---|---|
| Reactive state | signals, derived state, effects, ownership & disposal, turns, optimistic, Draft |
| Building UI | view chaining, binds, events, lists, conditionals, mounting |
| Styling | const typed styles, lengths/colors, dynamic values |
| Routing | enum routes, parse/href, link, swap, navigation |
| Services & RPC | [service]/[rpc]/[expose], Wire, mirrors, reconnection, the server side |
| Persistence & the server | std::db (SQLite), the http server, files, the process |
| A full-stack walkthrough | the Notes app end to end — every layer meeting, quoted from a real, tested example |
std reference
| Page | Modules |
|---|---|
| collections | List, Map, Set, Range, Iterator |
| option & result | Option, Result and their method sets |
| strings | str, Display, Debug, Into |
| numbers | the sized numerics, math, random |
| traits | compare, default, the operator traits, Try/Lift |
| cells | Shared, Arena/Handle |
| time | Instant, Duration, timers |
| encoding | json, wire, binary, bytes, base64 |
| net | fetch, ws |
| reactive | the full std::reactive API |
| style | the full std::style API |
| rpc | std::rpc — transports, clients, frames |
| browser | std::dom, std::ui, std::router, std::storage |
| process | db, http, fs, process, rpc_server |
| misc | io, promise, context, crypto, jwt, asset |
Specification
| Chapter | Defines |
|---|---|
| §1 Introduction | conformance, notation, processing phases |
| §2 Lexical structure | tokens, keywords, literals, operators |
| §3 Grammar | the full EBNF, precedence, patterns, types |
| §4 Names & modules | scopes, resolution, imports, namespaces |
| §5 The type system | types, generics & inference, traits, coercions, !/?. |
| §6 The memory model | the four rules, views, projections, the await rule |
| §7 Execution & async | entrypoint, evaluation order, the async model |
| §8 Contexts | ambient values: run/get, coverage, injected closures |
| §9 Const evaluation | compile-time values, the const environment, assets |
| §10 Macros | attribute/derive/block macros, macro_std, splicing |
| §11 Platform model & manifests | layers, coloring, fences, vilan.toml |
| §A Appendix | precedence & 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/TS | You write in vilan |
|---|---|
function f(x) { … } | fun f(x: i32): i32 { … } |
const x = … / let x = … | let x = … / mut x = … |
x === y | x == 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 unions | enum + match |
x?.field | x?.field (on Option, and it’s type-checked) |
null / undefined | Option<T> (Some(v) / None) |
throw / try / catch | Result<T, E> + ! to propagate |
await fetchThing() | fetch_thing() (awaiting is implicit) |
class with methods | struct + impl block |
| interfaces / duck typing | traits, 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 aSignal(reactive state) or aSharedcell, both explicit. - Don’t check for
null. Match on theOption. - Don’t wrap things in
try/catch. Errors are values. Look at theResultand 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
| Command | What 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::routesmeans “the fileroutes.vlnext 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
bool—trueandfalse.str— immutable strings.- Signed integers
i8 i16 i32 i53and unsignedu8 u16 u32 u53. A bare literal like42isi32. Other widths take a suffix:0xFFu8,60000u16,9007199254740992i53. - Floats:
f64(a bare2.5, or thefsuffix) andf32(2.5f32). BigInt— arbitrary precision, with thensuffix (7n).
Why i53 and not i64? Because vilan runs on JavaScript, and JavaScript
numbers are 64-bit floats. Every integer up to ±2^53 is exact in a float.
Beyond that, precision silently disappears. Rather than offer an i64
that quietly isn’t one, vilan names the type for what it 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 / 2is3, and-7 / 2is-3. Float division works the way you expect. - There are no implicit conversions between numeric types. Mixing an
i53and ani32in one expression is a compile error. Convert explicitly with theas_*methods, or suffix the literal.
That second rule has one trap 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’sassemantics: floats truncate toward zero, and integers fold two’s-complement into the target width, so(-1).as_u8()is255. Conversions on literals fold at compile time. Arithmetic that overflows a type’s range is undefined behavior — the compiler checks literals, not runtime math. Details in spec §7.2a.
Strings and interpolation
"…" is a plain string, and vilan does not interpret {} inside it.
To interpolate, prefix the string with i:
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.
MapandSetkey by value. Scalar keys (i32,str) work directly; a struct, enum, orListkey works once it derivesHashable([derive(Hashable)]), so two equal values are the same key. See collections.
Where’s null?
There isn’t one. A value that might be absent is an Option<T>, and the
compiler makes you handle the None case before you can use the value.
This is one of the big shifts from JavaScript, and
Control flow shows how natural it becomes. (null
technically exists at the host boundary, for externs that can return JS
null, but ordinary vilan code never sees it.)
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/.1access 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:
| Derive | Gives you |
|---|---|
PartialEq | structural == |
Debug | .debug() — a developer-facing rendering |
Default | Default::default() built from the fields’ defaults |
Json | JSON encode/decode (std::json) |
Wire | serialization 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.
WireandJsoncheck 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>— eitherSome(value)orNone. - An operation that might fail is a
Result<T, E>— eitherOk(value)orErr(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
OptionandResult.!dispatches throughTry, and?.throughTryplus theLiftmarker (std::operators). Your own two-outcome type can implement them and join in. A?.continuation that itself produces the container flattens instead of nesting, sofind(key)?.shelf()on anOption-returning method stays a singleOption. (A bare?lifts the std pair only for now — a userLiftcontainer 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:
- Default: plain values. Copies everywhere.
- A function should mutate my thing in place: pass a view (
&mut). - Two long-lived places need the same mutable state: a
Shared<T>cell. - Things need to point at each other (graphs, cycles): an
ArenawithHandleids.
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 alist.push(..)while a view fromlist.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 selfpasses 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
&mutat the function boundary, or restructure aroundSharedor a signal’sset_with. shared.read().push(x)is lost (it mutates a copy). Useshared.write().push(x).- Signals hold values too. Mutate a signal’s list with
signal.set_with(|list| …), never by mutating aget()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 exprspawns: start the work, don’t wait for it. It gives you aTask<T>— a handle to the running work.async { … }spawns a block.await taskcollects 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
nurserycall 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
&/&mutview 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
mainfinishes, 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 outlivesmaindepends on what it holds. Joining spawns in a nursery makes the question moot; a long-lived client must keepmainopen 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
turnwith 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, orSet— the compiler can’t see inside those. Use anOption, 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. Dropis only for resources, must be exactlyfun drop(&mut self), and must be synchronous and context-free (noawait, no signal writes). Cancel owned tasks through anOwnedNurseryrather 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:
constcaptures 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 fundeclares the macro. Its body is ordinary vilan, but it compiles againstmacro_std— a small compile-time standard library withsource, themetatypes (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 need | Reach for |
|---|---|
| a computed constant or lookup table | const |
| CSS or other build-time assets | const calling std’s emitters |
| an impl derived from a type’s shape | a derive macro |
| a family of near-identical items | macro { … } 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/depthinvilan.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::fsand 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 wayasyncis inferred), and checks the colors only along paths that start at yourmain. When a path crosses onto the wrong platform, the error shows that path. Module-levellets 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.constinitializers 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 layer —
std::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:
| Form | Binds |
|---|---|
[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 duringconstevaluation. The styling system’sconst 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 aSignal<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): Subscriptionis the manual version. It fires only on later changes, and you keep theSubscriptionand calldispose()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. Everyeffectinside — however deep in function calls — registers into it.get_owner()reads the ambient owner, e.g. to attach custom cleanup withowner.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
contextmechanism (functions & closures): theowner_scopecontext carries the current owner, and closure parameters markedcontext owner_scopereceive it invisibly.compruns a block under a fresh owner and returns(result, owner)— it’s the primitive undermount_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
turnadapts 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.
pushis per-keystroke safe: a generation counter means a slow older commit that lands late is discarded rather than clobbering a newer one.adoptfollows 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, andbind_draftin 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
subgives you aSubscriptionto dispose manually. Prefereffectand 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 off | State | Use for | |
|---|---|---|---|
.show(condition) | mounted, hidden | preserved | tabs, collapsibles — anything that should keep its input text |
.when(condition, body) | unmounted, disposed | dropped | content that shouldn’t exist while off (an editor for a missing record) |
.swap(source, render) | previous subtree disposed on change | per-value | pages 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, andswapembed 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_rooton the server. Mounting is a client entry, not a renderable view, so the natural factoring is a sharedfun app(): Viewwith a per-legmain: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::domstays 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
showkeeps bindings live while hidden — they keep firing. If the hidden content is expensive, usewhen.bind_valuefights remote updates (every keystroke overwrites). For server-backed fields, usebind_draft.- The
owner_scopecompile error means you built UI outside every boundary. Wrap the entry point inmount_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 topadding,gap,margin, andradius.Lengthcovers everything else:Length::px(1.0),Length::rem(1.5),Length::pct(50.0),Length::auto(), andLength::var("--w")for a CSS variable (see dynamic values below).ColorhasColor::white(),Color::black(),Color::transparent(),Color::hex("#663399"), and stepped ramps likeColor::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.styledsetsclass_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 outsideconstfails with an “emission outside const” error. Build styles inconst, 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.
Links and navigation
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.
Deep links and the server
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
parseandhrefnext to each other and test them as a pair. Their agreement is the one thing the type system can’t check for you. Aparsethat drops a segment silently turns a working deep link into a NotFound. segmentsalready forgives trailing and duplicate slashes (they produce no segment). Don’t special-case them inparse.- Query strings and hash fragments aren’t modelled yet (a deliberate
deferral).
segmentssees 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 aSignal<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. RpcErrortells you what went wrong:Transport(couldn’t reach the server),Decode, orRemote(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
taskslist and let each pagemapit 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
adoptmirror 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 aStatement. Then.run(params)executes and returns the last insert id,.first(params)fetches anOption<Row>, and.all(params)fetches aList<Row>.- Rows read by column name:
text,integer(i32),big_integer(i53 — use it for epoch-millis timestamps, which outgrow i32),real(f64), andis_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.
descis the one that bites in practice — spell itdescription.
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:
Database::open, thenexecthe schema (CREATE TABLE IF NOT EXISTS …).- Load the mirrored state from SQLite into the service’s signals.
- Wire the service’s handlers to statements. Write SQL first, then update the signal — the mirror broadcasts the signal.
fs::read_file_to_strthe client bundle and shell.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.
- 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. - 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.
- Replace. On boot the client builds the same view as live DOM and
mountclears 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(...)andsession_user(db, ...)borrow it; the compiler rejects taking ownership away — moving it (let mine = db) ordrop(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.js — vilan 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
showon 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 anypkg::storecall — somewhere the client entry reaches, and rebuild: the error names the whole chain frommaindown to the SQL.
Where each idea came from
| In this app | Taught in |
|---|---|
| the package, its two entries | Hello vilan, Platforms |
Note, derives, the enums | Data & traits |
| signals, effects, drafts | Reactive state |
views, bind_each, when, show | Building UI |
the const styles | Styling |
the route enum, swap, link | Routing |
[service], mirrors, reconnect | Services & RPC |
| SQLite, the fallback, boot order | Persistence |
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 code | The browser bundle changes → a swap: the new bundle is evaluated in place, module state carried across (below). No reload. |
| A stylesheet only | Just the CSS sidecar changed → the stylesheet is hot-swapped, no reload, no swap — the page doesn’t even flicker. |
| Server code | The 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 mistake | The 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-levelSignalorSharedcarries its payload into a fresh cell. Somut countand 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 to35917; change it if that port is taken.--hmr-port 0asks 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.
The CSS <link> idiom
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) andunwrap_or*overunwrap—unwrapis for invariants, and it panics. - Application errors belong in
Result’sE; only truly unreachable states panic. matchwithSome(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
| Type | Width | Literal |
|---|---|---|
i8 i16 i32 i53 | signed | bare = i32; others suffixed (100i53) |
u8 u16 u32 u53 | unsigned | suffixed (0xFFu8) |
f64 | float | 2.5 or 10f |
f32 | float | 2.5f32 |
BigInt | arbitrary | 7n |
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 throughPartialEq;</<=/>/>=throughPartialOrd. Numbers,str, andboolimplement them in std.- For your own types,
[derive(PartialEq)]gives structural equality — the usual path. ImplementPartialOrd/Ordby hand when ordering is meaningful (Instantdoes this in std). - The
B = Selfparameter 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:
| Trait | Operator | Trait | Operator | |
|---|---|---|---|---|
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 throughwrite().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 acrossawait).- Copying the
Sharedvalue 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
getsNoneinstead of aliasing the new occupant. getreturns 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, andsetit back — or design nodes so edges/fields update independently.- Traversal is re-
getper 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 beconst-folded, and programs using it aren’t output-deterministic — keep it out of golden-file tests.- Wire format: an
Instantserializes as itsi53millis — 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) orbinary_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
| Item | Kind | One line |
|---|---|---|
Signal<T> | struct | mutable cell with subscribers |
Source<T> | trait | anything readable + subscribable (get/sub/effect) |
Subscription | struct | an explicit subscription; Disposable |
combine | fn | tuple-signal over 2+ signals |
Owner | struct | disposal bag; the lifetime unit |
run_with_owner, comp, get_owner, owner_scope | fns/context | establish/read the ambient owner |
turn, batch, flush, FlushPolicy, turn_scope | fns/context | write batching |
optimistic | fn | paint → commit → confirm-or-rollback |
draft, Draft<T>, DraftState | fn/struct/enum | local-first editing cell |
reconcile, ReconcilePlan, RowStep | fn/structs | keyed 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
}
setnotifies 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).effectrequires an ambient owner — calling it outside every owner is a compile error (context coverage). It fires once immediately.subdoes not fire immediately, and itsSubscriptionis yours to dispose (or hand toowner.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
}
commitreturnsNoneon success,Some(reason)on failure. The parameter isasync-typed so an RPC-calling closure flows in directly; a plain synchronous closure works too.pushis per-keystroke-safe: local-first (never waits on the wire), and a generation counter ensures only the newest push settlesstate— a slow older commit landing late is discarded.adoptrules: value equal to the last synced value (an echo of your own push) → no-op; clean local (no unpushed edits) → adopt intolocal; dirty local → local wins, the remote value is remembered so the eventual push knowingly overwrites (last-write-wins).- On failure,
statecarries the reason andlocalkeeps the user’s text; the nextpushretries 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:
| Method | Value |
|---|---|
display | Display |
position | Position |
flex_direction | FlexDirection |
align_items | AlignItems |
justify_content | JustifyContent |
gap, padding, padding_x, padding_y, margin, margin_x, margin_y | Length |
width, height, max_width, min_height | Length |
overflow | Overflow |
Appearance:
| Method | Value |
|---|---|
radius | Length |
border | (width: Length, color: Color) |
background, color | Color |
font_size | Length |
font_weight | i32 |
line_height | f64 |
text_align | TextAlign |
cursor | Cursor |
opacity | f64 |
transition | str |
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).
Runtime-legal operations
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>>;
}
| Transport | Wire | Use |
|---|---|---|
SocketTransport | WebSocket (reconnecting) | what connect gives you — the production client transport |
HttpTransport | one POST per call | stateless calls, no mirrors |
LocalTransport | in-process | tests: 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
| Method | Signature (self elided) | Notes |
|---|---|---|
text | (content: str): View | static text |
class | (name: str): View | static class |
styled | (style: Style): View | classes from a compiled style |
attr | (name: str, value: str): View | static attribute |
style_var | (name: str, source: Signal<str>): View | reactive CSS custom property |
on | (event: str, handler: (|| void) context turn_scope): View | handler runs in a fresh turn |
on_event | (event: str, handler: (|Event| void) context turn_scope): View | same, with the DOM event |
child | (child: View): View | append one |
children | (items: List<View>): View | append several |
bind_text | (source: Signal<str>): View | reactive text |
bind_class | (source: Signal<str>): View | reactive class |
bind_attr | (name: str, source: Signal<str>): View | reactive attribute |
bind_value | (signal: Signal<str>): View | two-way input bind |
bind_draft | (draft: Draft<str>): View | local-first input bind (drafts) |
bind_each | (source: Signal<List<T>>, key: |T| K, render: (|T| View) context owner_scope): View — T: PartialEq, K: PartialEq | keyed rows; each row is a disposal boundary |
when | (condition: Signal<bool>, body: (|| View) context owner_scope): View | state-DROPPING conditional |
swap | (source: Signal<T>, render: (|T| View) context owner_scope): View — T: PartialEq | dispose + rebuild per changed value |
show | (condition: Signal<bool>): View | state-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
}
getis statically covered: the compiler proves every call path runs inside arun— an uncovered read is a compile error, not a runtimeNone.- Closures capture their contexts at creation; parameters declare
context needs with the
contextclause (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.mdand 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.
- Lexing (§2) — source text to tokens.
- Parsing (§3) — tokens to the syntax tree.
- Macro expansion & loading (§10) — module loading, attribute/ derive/block expansion, splicing. Generated code re-enters phases 1–2.
- Name resolution (§4) — names to declarations.
- Type checking (§5) — types, generic binding, bound checking.
- Memory checking (§6) — view confinement, the aliasing rules.
- Context checking (§8) — ambient-value coverage.
- Async inference (§7) — asyncness, seam checking.
- 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; converselya+-blexes asa,+-,band 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:
| Level | Operators | Notes |
|---|---|---|
| 1 | :: paths, calls, . [] ! ?. | §3.6 |
| 2 | prefix ! - 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) |
| 10 | is pattern | at 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 { … } == qcompares;Point { x = 1, y = 2 }.length()folds the member chain) — except in condition positions: anif/forcondition, afor … initerable, and amatchsubject parsecondition-expr, whose operands exclude struct initializers, so the{afterif Foois the block. Parenthesize a literal to use it in a condition (if p == (Point { x = 1 }) { … }). constcaptures 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 modulename, resolved against the std package’s layers for the current platform (§11).pkg::name— the modulenameof the importing file’s own package.depname::name— the modulenameof the dependency declared asdepnamein 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 itemprint.import std::reactive::{ Signal, combine };— binds each set member.import std::option::Option::{ self, Some, None };— a path into a TYPE:selfbinds 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. - Primitives —
bool,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)isT; the unit isvoid). - Closure types —
|T, U| R,|| R,|| void; structural in their parameter and return types. Anasyncclosure type (§7.4) and acontext-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 writtenvoid.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 aNevervalue 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:
- 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.
- 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()bindsT := i32). Only the callee’s own binders participate — a caller-side generic introduced by substitution is never re-bound against the expectation. - After binding, every bound’s satisfaction is checked; an unsatisfied bound is an error naming the parameter and bound.
- 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. Withv = eof a type implementingTry<T, B>: ifv.verdict()isGood(t), the value ist; ifBad(b), the enclosing function returnsR::from_bad(b)whereRis its declared return type (which must implementTry<_, B>compatibly).OptionandResultimplementTryin std.e?.m…— lift. Withv = eof a container type implementingLift+Try: ifvis good with valuet, the continuation (.mand the following plain postfixes, §3.6) applies tot; the result re-wraps in the container — unless the continuation itself yields the container type, in which case it is returned as-is (flattening). Ifvis 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
matchwhose VALUE legs’ types don’t unify. Diverging legs (ret,panic,jump) areNeverand don’t participate (§5.1). - An
i53/i32operand 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 shape | epoch 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 (aHandle’s generation) and every access re-establishes validity by comparison, answeringOption<&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:
| concern | static (proof) | dynamic (check) |
|---|---|---|
| interior access | &/&mut views; borrows provenance (§6.5) | Arena.get(handle): Option<&T> |
| invalidation | rule 4 (§6.4): reassignment, mutating call | generation mismatch → None |
| suspension | no view across await (§6.6) | handles cross freely; the next access re-validates |
| death | resources: use-after-move; drops placed statically (§6.8) | a stale handle / Weak → None |
| exclusivity | declined — aliased views and content writes are legal | traps 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 selfon a method,bump(&mut c)at a call site,x: &mut Tin a signature all carry the same convention. - A view may not be stored in a struct field, a collection element,
or a
Signal/Sharedpayload; may not be returned except through aborrowsprojection; may not be captured by a closure that outlives the place; may not cross anawait(§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:
| Convention | Written | Meaning |
|---|---|---|
| bare | x: T | by value (a copy — rule 1) |
| own | own x: T | by value, explicitly (documentation of intent) |
| ref | &x / x: &T | readonly view |
| ref mut | &mut x / x: &mut T | writable 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
resourceis a declaration modifier, written inexternal’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 (theWire/Hashableall-fields machinery with the polarity flipped: any resource member marks the whole). Declaringresourceon such a type is allowed and checked; omitting it never hides resource-ness. - The modifier is required at leaves. An
external structis 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. Dropmay be implemented only for a resource type (see below); an impl on a data type is an error steering to addresource.
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 ofais 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 xare loans, unchanged;own xis a move — and for a resource only a move: anownargument that is not the binding’s last use is an error (where a dataownwould silently copy). - R4 — returns move out, including through
if/matchtails (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 isOption(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 — sonursery(|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/Setand every external generic (Shared,Task,Promise,Context) reject resource type arguments in v1 — their internals are host code the move checker cannot see.Optionis 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): Tpasses; a body that reads its parameter twice fails at the instantiation site, not inside std. For anown Tparameter 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 whereanyis expected is an error (print(db)included):anyis 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:
dropis synchronous. Anasyncor awaitingdropbody is rejected — teardown runs synchronously in v1. (Cancel owned tasks through anOwnedNursery, whose owndropcancels them.)dropis context-free. Adropbody that requires an ambient context (for example, one that writes aSignal, 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
letresource has process lifetime (the serve-forever server’sDatabase). It is consequently loan-only: moving it into a local, anownargument, ordrop(x)would hand a process-lifetime resource to a droppable owner and is rejected; method calls and&/&mutpassing are accepted. - Panic during unwind. A
dropthat panics while a panic is already unwinding replaces the in-flight error (JSfinallysemantics; 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-awaitrestriction is about loans, not ownership. Under cancellation a bridged operation rejects, the frame unwinds, and drops run. - Exit after finally. When a value-returning
mainowns a resource, its process exit is sequenced after the teardownfinallyruns, 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
Tinside 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’sany-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:
- 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 anasync-typed closure value. - 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).
- 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 expr— spawn: evaluateexpr’s suspending computation concurrently; the spawn expression itself does not suspend and yieldsTask<T>whereTisexpr’s type (the task handle is opaque, and copying it refers to the same task).async { … }spawns a block.await expr— suspend until theTask<T>(or raw hostPromise<T>) operand settles; yieldsT.
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
syncparameter (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 typeasync |…| Tinstead; - legal when it returns
void— spawn 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
nurserycall; 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:valueis the context’s value for the dynamic extent ofbody(the closure’s execution and everything it calls, transitively).runyieldsbody’s value.flavor.get()— the strict read: yields the establishedT.flavor.get_safe()— the safe read: yieldsOption<T>—Some(value)under an enclosingrun,Noneotherwise.
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 enclosingrun. Code holding only strict reads carries the bareT. - A safe
get_safe()never demands coverage: uncovered paths supplyNone. Code reachable without aruncarries the value asOption<T>; at a covered→safe boundary the bare value wraps inSomeautomatically.
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 —mainis semantically the outermost, run-less extent); - a module-level initializer;
- any caller chain that does not pass through a
runof 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
runbody 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, preferconst— §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) —@processbuilds 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; defaultsrc/),entry(the entry file, when there is exactly one),target(a platform; defaultnode), anddependencies(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) andtarget(defaultnode). 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 owndependenciesare declared once for the members to share: a member writesdep = { 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 — andproject = truecombines with no other key.[build]— codegen options:preset("debug"|"release") and the per-feature overridesindent,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) anddepth(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):
| Level | Operators |
|---|---|
| 1 | :: paths · calls · .member · [index] · ! (try) · ?. |
| 2 | prefix ! - await async & &mut * |
| 3 | * / % |
| 4 | + - |
| 5 | << >> (span-adjacent) |
| 6 | & |
| 7 | ^ |
| 8 | | |
| 9 | == != < <= > >= |
| 10 | is |
| 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:
| Item | Module | Language use |
|---|---|---|
primitives (bool, str, numerics, BigInt) | std::boolean/string/number | literal types |
List<T> | std::list | list literals, for |
Option<T> | std::option | ?. results, view-returning lookups |
Try, Verdict, Lift | std::operators | !, ?. (§5.10) |
Add Sub Mul Div Rem Shl Shr BitAnd BitOr BitXor | std::operators | operators (§5.7) |
PartialEq, PartialOrd | std::compare | ==/ordering (§5.7) |
Iterator/Iterable | std::iterator | for … in |
Task<T> | std::task | async/await (§7.3) |
Promise<T> | std::promise | host-interop promises (§7.3) |
Context<T> | std::context | contexts (§8) |
panic, assert | std::io | divergence, 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]. matchcan’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 + 1000andstamp < 1000are fine on ani53(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 withas_*or unify the declarations.
Reactive & UI
shared.read()is a copy —shared.read().push(x)is lost; write through the cell:shared.write().push(x).- Mutate signal lists with
set_with, never by mutating aget()result (also a copy). bind_valuefights remote updates — for server-backed fields usebind_draft.showkeeps bindings live while hidden; usewhento 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. descis an SQL keyword — name the columndescription(any SQL keyword as a column name fails inCREATE 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
mainexits the process — long-lived clients/servers must holdmainopen. 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.
entrypoint — fun 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.