Varian Language Reference
download Download pageThis document describes the language as it actually works today, verified against
the lexer/parser/VM and the example files in examples/ and tests/. It intentionally
does not cover anything from VARIAN_Complete_Feature_Spec.docx that isn't implemented
yet — see PLAN.md for the roadmap of what's still aspirational.
Variables
let x = 10
let name = "Chidi"
let active = true
let declares a binding. At the top level, let compiles to a global; inside a
function, it's a local slot. Optional type annotations are accepted after : but are
parsed and discarded — the runtime is fully dynamically typed regardless of any
annotation written:
let price: float = 9.99 // ": float" has no runtime effect today
Reassigning an existing binding doesn't need let again:
let attempt = 0
attempt = attempt + 1
Primitive types
bool, int, float, string, arrays ([1, 2, 3]), tuples, structs, enums, and
null. Strings are immutable. There is no separate byte/rune runtime distinction
yet even though those type-annotation keywords exist.
Functions
fn greet(name, greeting) {
return greeting + ", " + name + "!"
}
fn add(a: int, b: int) -> int {
return a + b
}
Parameter and return type annotations are accepted but, like let, are not enforced —
useful for readability and for tools like vn lint, not for runtime checking.
Multiple return values
fn divide(a, b) {
return a, b // or: return a / b, nil — comma-separated values
}
Lambdas and closures
let double = |x| { return x * 2 }
print(double(21))
Lambdas capture enclosing locals and parameters by value (copied at the moment the closure is created, not live shared cells):
struct Box { val: int }
impl Box {
fn make_adder(self) {
return |x| { return self.val + x }
}
}
let b = Box { val: 10 }
let adder = b.make_adder()
print(adder(5)) // 15 — `self` was captured into the closure
If a captured value is itself a struct or array (heap-allocated, reference semantics — see below), mutations made through that reference after capture are still visible, since the closure copies the pointer, not a deep copy of the data.
Mutation semantics and Struct Arena allocation
Structs mutate in place when you call a method on them — there is no need to reassign:
struct Box { val: int }
impl Box {
fn bump(self) { self.val = self.val + 1 }
}
let b = Box { val: 1 }
b.bump()
print(b.val) // 2 — bump() mutated the same object `b` refers to
The Struct Arena & Escape Safety
To optimize heap allocations, structs created within HTTP handler tasks (or tasks where task_arena_enable() is called) are allocated inside a 64KB task-local bump arena. These structs bypass the general heap and the GC completely. They are bulk-reclaimed instantly when the handler task is recycled.
If an arena-backed struct needs to outlive the request (e.g., if you store it in a global variable, send it to a channel, or write it to an actor mailbox), the Varian runtime automatically invokes an escape-safety write barrier (escape_promote). This deep-copies the struct recursively onto the garbage-collected heap at the moment of escape, preventing dangling references.
Arrays are copy-on-write via .push() — it always returns a new array and never
mutates the original:
let a = [1, 2, 3]
let b = a.push(4)
print(a.len()) // 3 — unchanged
print(b.len()) // 4
This asymmetry is real — the idiom for "appending" to a struct field that holds an array is always self.field = self.field.push(x), reassigning the field (which does persist, since that's a struct mutation), not mutating the array value itself.
Index assignment (arr[i] = x) works correctly and mutates in place.
Control flow
if x > 10 {
print("big")
} else if x > 0 {
print("small")
} else {
print("non-positive")
}
while x < 5 {
x = x + 1
}
for i in 0..10 {
print(i) // exclusive range: 0..9
}
loop {
if done { break }
}
Nil safety
let u = User { name: "Alice" }
let missing = null
print(u?.name) // "Alice"
print(missing?.name) // nil — short-circuits instead of erroring
print(missing?.name ?? "default") // "default"
?. short-circuits to nil if the object is nil, instead of erroring. ?? evaluates
both sides (it is not short-circuiting — consistent with &&/|| in this VM, which
also always evaluate both operands) and returns the left side unless it's nil.
Boolean operators
&&, ||, and ! are the logical operators. The keywords and, or, and not are
accepted as exact aliases (lexed to the same tokens), so both styles are equivalent:
if active and not banned { ... } // same as: active && !banned
if role == "admin" or is_owner { ... } // same as: role == "admin" || is_owner
Member access also accepts these (and other reserved words) as names, so regex.match(...),
obj.and, resp.not_found etc. parse fine even though match/and/not are keywords.
Structs and methods
struct User {
id: string,
name: string,
email: string
}
impl User {
fn display_name(self) -> string {
return self.name + " <" + self.email + ">"
}
}
let u = User { id: "1", name: "Chidi", email: "chidi@example.com" }
print(u.display_name())
Struct fields can be declared without a type at all (handler, with no : Type) —
useful for fields meant to hold functions/closures or genuinely dynamic values.
Object literals (anonymous structs)
When you just need an ad-hoc bag of fields — a JSON response, a config blob, a row of
data — you don't have to declare a struct first. Write the fields directly inside
braces:
let user = { name: "Ada", age: 30, active: true }
print(user.name) // Ada — read fields with dot access
print(json_encode(user)) // {"name":"Ada","age":30,"active":true}
Keys are normally bare identifiers (name:). Quote a key when it isn't a valid
identifier — for example when it contains a space or a dash:
let row = { "first name": "Ada", "is-admin": false }
A value can be any expression, so objects nest and combine with arrays freely, and an object is a normal value everywhere — return it, pass it to a function, store it:
fn ok(data) {
return { status: "ok", count: data.len(), items: data }
}
print(json_encode(ok([1, 2, 3]))) // {"status":"ok","count":3,"items":[1,2,3]}
An object literal is exactly a struct with no declared name (its type is reported as
Object), so everything that works on structs — dot access, json_encode, being passed
around — works on it identically. The only thing you give up versus a named struct is
the declaration: there's no fixed field list and no @-validation. Reach for a named
struct when you want those guarantees; reach for { ... } when you just want the data.
Field validation decorators
struct SignupForm {
@is_email email: string,
@min_len(3) name: string,
}
let ok = SignupForm { email: "a@b.com", name: "Alice" } // fine
let bad = SignupForm { email: "not-an-email", name: "Al" } // throws at construction
Validation runs automatically every time a struct literal of that type is constructed,
matched by field name (not by position — field order in the literal doesn't need to
match declaration order). Available rules: is_email, is_url, is_alphanumeric,
min_len(n), max_len(n), is_uuid.
Generics
struct Box<T> { value: T }
fn identity<T>(x: T) -> T { return x }
Generics are type-erased — <T> is parsed for documentation/tooling purposes only,
with no runtime specialization or checking. A Box<int> and a Box<string> are the
exact same runtime representation.
Enums and pattern matching
enum Option<T> {
Some(T),
None
}
let x = Option::Some(10)
match x {
Option::Some(10) => print("got 10"),
Option::None => print("got none")
}
Structural typing (traits)
trait Shape {
fn area(self) -> int
}
struct Rect { w: int, h: int }
impl Rect {
fn area(self) -> int { return self.w * self.h }
}
struct Circle { r: int }
impl Circle {
fn area(self) -> int { return self.r * self.r * 3 }
}
fn print_area(s: Shape) { print(s.area()) }
print_area(Rect { w: 10, h: 20 }) // 200
print_area(Circle { r: 5 }) // 75
A type satisfies a trait implicitly by having matching methods — there's no explicit
impl Shape for Rect to write.
Error handling
// `?` propagates a `null` result up out of the current function
fn safe_div(a, b) {
if b == 0 { return null }
return a / b
}
fn try_div(a, b) {
let result = safe_div(a, b)? // short-circuits the whole function if null
print(result)
}
// try/catch with an optionally-bound error value
try {
throw("error!")
} catch e {
print(e)
}
? here is null-propagation (closer to Swift's optional chaining than Rust's
Result-based ?), not a typed-error-channel operator.
Decorators
@cache
fn expensive(n) {
print("computing")
return n * 2
}
@retry(3)
fn flaky() {
if should_fail { throw("failed") }
return 42
}
@cache memoizes by argument values. @retry(n) restarts the function from the top up
to n times if it throws. @validate-style rules (@is_email, @min_len(n), etc.) are
for struct fields, described above.
Compile-time execution (comptime)
let x = comptime { 10 + 20 } // x == 30, computed once at compile time
comptime { ... } runs its body immediately, in lexical position, during normal
compilation/execution — so it can call any function or use any global already
defined earlier in the program (including your own module functions), unlike a
pre-resolved-at-startup design. It cannot see anything defined after it, or anything
from an enclosing function's locals (it always runs with enclosing = NULL).
This is the basis of the comptime ORM in vn_modules/db.vn — see docs/ZENITH.md.
FFI — calling C directly
@ffi("libm.so.6", "sqrt")
fn fast_sqrt(x: c_double) -> c_double
@ffi("libc.so.6", "abs")
fn fast_abs(x: c_int) -> c_int
print(fast_sqrt(9.0)) // 3.0
print(fast_abs(-42)) // 42
An @ffi("path/to/lib.so", "symbol_name")-decorated function declaration with no body
binds directly to a C shared library symbol via libffi, with FFI-specific parameter
types: c_int, c_double, c_float, c_char, ptr.
Concurrency
See docs/CONCURRENCY.md for tasks, channels, and actors.
Native modules & the Zenith framework
See docs/STDLIB.md for math/string/io/sqlite/postgres/redis/http/auth/
validate/json, and docs/ZENITH.md for the web framework and the comptime ORM.
Tooling
See docs/TOOLING.md for vn run/test/lint/fmt.