Skip to content

Language Guide

torvik-lang edited this page Jul 30, 2026 · 8 revisions

Torvik Language Guide (v1.5)

This guide covers everything implemented in Torvik v1.5. For planned features, see the Roadmap.

Torvik is a self-hosting, compiled, general-purpose language with a Norse-inspired keyword set. It compiles to native binaries through LLVM (emitting LLVM IR that is linked with clang), so there's no virtual machine and no garbage collector. It runs on Linux and Windows (both x86-64). macOS is not yet supported.

Table of Contents


Hello World

df main() -> void {
    echo!("Hello from Torvik!");
}

Every program needs a main function that returns void. Compile a single file with torvc, or use rune to manage a project:

torvc hello.tv -o hello      # compile directly
rune run                     # build and run the current project

Comments

// Line comments start with //
set x: i64 = 42;  // trailing comment

#- Block comments use #- ... -#
   and can span multiple lines.
   They also nest, so you can #- comment out -# a block
   that already contains a comment. -#

Variables

Torvik has two ways to bind a value, and both require a type annotation:

  • set → a mutable variable (can be reassigned)
  • fixed → an immutable binding (cannot be reassigned — enforced at compile time, for locals and globals alike)
set count: i64 = 0;           // can change
fixed name: str = "Freya";    // cannot be reassigned

count += 1;

Redeclaring a name with a different type in the same function is a clean compile error, not silent corruption.


Types

Signed integers: i8, i16, i32, i64, i128 Unsigned integers: u8, u16, u32, u64, u128 Float: f64 Boolean: bool String: str

set age: i64 = 28;
fixed pi: f64 = 3.14159;
fixed active: bool = true;
fixed message: str = "Hello";
fixed big: u128 = 340282366920938463463374607431768211455;

i64 is the default integer type. The wide i128/u128 types are fully supported — declare, print, do arithmetic and comparisons, pass them to functions, store them in lists, and declare them as globals (a 128-bit global takes a single literal initializer, at any magnitude up to the type's full range). Unsigned arithmetic and comparisons are correct throughout: a u64 operand anywhere in an expression selects unsigned division, modulo, shifts, and ordering.

Character literals like 'A' are one-character strings.


Operators

  • Arithmetic: + - * / %
  • Comparison: == != < > <= >=
  • Logical: && || ! — short-circuiting since v1.5.0 (they previously evaluated both sides, so a guard like i < len(xs) && xs[i] == 1 could read out of bounds), and usable as values: bind fixed ok: bool = a && b;, chain any number of operands, and compare a boolean-returning call to a literal (contains(s, "x") == false).
  • Bitwise: & | ^ ~ << >>
  • Compound assignment: += -= *= /= %=

A leading - negates a value (-5, set n: i64 = -x;) and also works as a chain operand, including a binary minus immediately followed by a unary minus — a - -b, a + -5, a * -f(x) — in both integer and float expressions. A leading - binds to exactly one value: -m + 2 is (-m) + 2, not -(m + 2). The bitwise NOT prefix ~ (integers only) works the same way: ~0 is -1, and ~m & 7 is (~m) & 7.

Comparisons accept any operand shape on either side — variables, literals, function-call results, list elements, and parenthesized expressions:

check char_at(s, i) == "x" { ... }
check substr(name, 0, 3) == "st_" { ... }
check len(a) + 1 != len(b) { ... }

Mixed arithmetic and comparison folds with correct precedence without extra parentheses (a + f(x) > b).


Printing and Interpolation

  • echo!(value) → prints with a trailing newline
  • echo(value) → prints without a newline

Interpolation works directly inside string literals. The braces can hold a variable name or a full expression — arithmetic, a function call, an index, or a combination:

fixed name: str = "Thor";
set count: i64 = 5;

echo!("Hello {name}!");
echo!("You have {count} items.");
echo!("Next year: {count + 1}");        // arithmetic
echo!("Doubled: {dbl(count)}");         // a function call
echo!("First rune: {runes[0]}");        // an index

Use {{ and }} for literal braces. For formatted output there's also fmt, which supports the same named interpolation plus positional {} placeholders:

fixed line: str = fmt("{} + {} = {}", a, b, a + b);

Everywhere else, a string is just a string

Interpolation belongs to echo, echo!, and fmt — and only to a string literal written directly as one of their arguments. A string literal anywhere else is plain data: braces are ordinary characters, nothing is read from scope, and no escapes are needed. That's what lets CSS, JSON, or template text sit in an ordinary string untouched:

fixed css: str = ".a{color:red}";          // exactly this text
fixed slot: str = "{{title}}";             // exactly {{title}} — no collapse
fixed msg: str = fmt("Hi {name}");         // fmt interpolates: Hi Odin
echo!(slot);                                // prints {{title}} — the variable's
                                            // contents are never re-scanned

The last line shows the flip side: interpolation happens at compile time on literals, never at run time on values — printing a variable that happens to contain braces prints them exactly as they are.


Conditionals

check / fallback:

check n > 0 {
    echo!("positive");
} fallback {
    echo!("non-positive");
}

fallback is optional. Conditions combine with &&, ||, and !.


Ternary

A compact conditional expression using ?> (then) and !> (else):

fixed result: str = n > 0 ?> "positive" !> "negative";

Both branches must produce the same type, and the branches can be any expression, including function calls.


Guard

guard asserts a condition and takes an early exit if it fails, so the rest of the function can rely on it:

df reciprocal(n: f64) -> str {
    guard n != 0.0 fallback {
        return "undefined";
    }
    // n is known to be non-zero from here on
    return float_to_str(1.0 / n);
}

Aetts and Pattern Matching

An aett is a family of named values — Torvik's enumeration, named for the ættir, the families the runes of the futhark are grouped into. Access variants with :::

aett Status { Pending, Active, Closed }

set s: Status = Status::Pending;
echo!(s);           // 0 — variants are i64 ordinals, in declaration order
echo!(typeof(s));   // Status

Aett values behave like integers (print, compare, store in lists, pass to functions) while the aett name serves as a type annotation for variables, parameters, and return types.

when matches a value against patterns, with => arms (a single statement or a block) and fallback => as the default:

when s {
    Status::Pending => echo!("waiting");
    Status::Active  => { echo!("live"); }
    Status::Closed  => echo!("done");
}

A when over an aett with no fallback must cover every variant — a missing one is a clean compile error naming exactly what's uncovered, so adding a variant later surfaces every when that needs updating. Integer-literal patterns work too (negatives included), with a required fallback.


Loops

whilst — a condition-controlled loop:

set i: i64 = 0;
whilst i < 5 {
    echo!(i);
    i += 1;
}

each over a range. START..END is exclusive; START..+END is inclusive:

each i in 0..5  { echo!(i); }   // 0 1 2 3 4
each i in 1..+5 { echo!(i); }   // 1 2 3 4 5

each over a collection directly — the loop variable is bound to each element, typed as the list's element type:

set words: list<str> = list_new();
push(words, "for"); push(words, "the"); push(words, "horde");
each w in words { echo!(w); }   // for / the / horde

break and continue are supported in every loop.


Functions

df add(a: i64, b: i64) -> i64 {
    return a + b;
}

df main() -> void {
    echo!(add(3, 7));   // 10
}

Functions declare parameter types and a return type. Use -> void for functions that return nothing. Recursion and forward references are supported — you can call a function defined later in the file.

Optional parameters (^). A parameter marked ^ may be omitted by the caller; an omitted optional takes a type-appropriate zero (0 / 0.0 / false / ""). Required parameters must come before optional ones.

df greet(name: str, ^count: i64) -> void {
    echo!("hi {name} x{count}");
}
greet("a");        // hi a x0
greet("b", 3);     // hi b x3

Variadic parameters (*). The last parameter may be marked * to gather the remaining arguments into a list<T>, iterated with each. At most one variadic, last, combining with optional in the order required → optional → variadic.

df join_words(sep: str, *words: str) -> str {
    set out: str = "";
    each w in words { out = out == "" ?> w !> str_concat(out, sep, w); }
    return out;
}

Arguments are type-checked at the call site. A definite mismatch — a number where a string is expected, a decimal where an integer is expected, a list where a scalar is expected — is a clean located error rather than silent garbage. Passing the wrong number of arguments is likewise a clean range error.


Collections

Torvik has three built-in collection types:

  • list<T> — an ordered, growable sequence
  • table<K, V> — a key/value map
  • bag<T> — an unordered set
set nums: list<i64> = list_new();
push(nums, 10); push(nums, 20); push(nums, 30);

echo!(nums[0]);              // 10  (index access)
echo!(len(nums));            // 3
list_insert(nums, 1, 15);    // [10, 15, 20, 30]
list_remove(nums, 0);        // [15, 20, 30]

fixed last: i64 = list_pop(nums);   // remove & return the last element -> 30

Lists work with any element type, including list<str>, list<f64>, and list<i128>. The element type usually comes from the annotation, but Torvik can also infer it from the first push when you leave the annotation off:

set names = list_new();     // element type inferred...
push(names, "odin");        // ...as str, from this push
echo!(names[0]);            // odin

Inference covers str, integer, f64, and bool element types. See the Standard Library page for the full set of collection functions (list_new(), push(), table_set(), bag_add(), and so on).


Pipelines and Membership

Weave — ~> pipes a value through one or more functions, left to right. x ~> f is f(x), and x ~> f ~> g is g(f(x)):

df shout(s: str) -> str { return str_concat(s, "!"); }

fixed raw: str = "  hello  ";
fixed loud: str = raw ~> trim ~> shout;   // shout(trim(raw)) -> "hello!"

A weave stage can also take arguments — the woven value is inserted as the first argument, so x ~> f(a, b) means f(x, a, b). This works with your own functions plus replace and substr. Stages are checked at compile time: the argument count (counting the inserted value) must match, and the woven value's type must match the stage's first parameter — 5 ~> shout where shout takes a str is a clean error, not a runtime crash:

fixed slug: str = "  Hi There  " ~> trim ~> lower ~> replace(" ", "-");
echo!(slug);   // hi-there

Membership — <| tests whether a value is in a collection and yields a bool. Integer lists compare by value; string lists compare by content:

set ids: list<i64> = list_new();
push(ids, 10); push(ids, 20);
check 20 <| ids { echo!("found"); }

set names: list<str> = list_new();
push(names, "odin");
check "odin" <| names { echo!("member"); }

Strings

Strings are first-class values with content semantics. ==/!= test equality and < <= > >= order lexicographically, no matter how the strings were produced — comparing a heap-built string against a literal compares the text, not pointers.

Common string builtins include str_concat, substr, char_at (returns a one-character string), byte_at (returns the byte value), trim / triml / trimr, upper, lower, replace, len, and the conversions int_to_str, float_to_str, and tostr. See the Standard Library for the complete list.


Error Handling: Results

result<T> is an explicit success-or-failure value: build with ok(value) and err(message) (or err(code, message)), test with is_ok / is_err, and read with unwrap, unwrap_or, err_msg, and err_code. result<i64>, result<str>, and result<f64> work as locals, parameters, and return types, and try_readfile / try_toint / try_tofloat give result-returning forms of the matching builtins:

df safe_div(a: i64, b: i64) -> result<i64> {
    check b == 0 { return err("division by zero"); }
    return ok(a / b);
}

Concurrency: Ravens and Bridges

Torvik runs work across threads with ravens (tasks) and bridges (channels), built on one safety rule: tasks share no mutable state. Values are copied as they cross a thread boundary, so the reference-counting runtime stays lock-free and data races aren't expressible.

raven spawns a df function on its own OS thread — fire-and-forget, or bind a task<T> handle and collect the result with join:

df fetch(url: str) -> str { return get(url); }

df main() -> void {
    raven log("started");                 // fire-and-forget
    set h: task<str> = raven fetch(u);    // handle
    fixed page: str = join(h);            // blocks, takes the result
}

Arguments deep-copy at the spawn, so a task owns its own copy of everything it receives. A spawned function must be self-contained — it (and everything it calls) may not touch globals — because even reading a global's refcount from two threads would race; the compiler enforces this and names the offending function and global. A panic in a task stops the whole process immediately, never silently.

A bridge<T> is a typed, buffered channel between tasks. Values deep-copy on send; bridge_close ends the stream; and try_recv returns a result<T> that goes err once the bridge is closed and drained — the clean way to end a worker loop:

df produce(ch: bridge<i64>, n: i64) -> void {
    set i: i64 = 0;
    whilst i < n { send(ch, i); i += 1; }
    bridge_close(ch);
}

df main() -> void {
    set ch: bridge<i64> = bridge_new(4);
    raven produce(ch, 10);
    set sum: i64 = 0;
    whilst true {
        set r: result<i64> = try_recv(ch);
        check is_err(r) { break; }
        sum += unwrap(r);
    }
    echo!("sum: {sum}");
}

A bridge is the one object tasks share (passed into a task by reference, protected by its own lock); every other value is copied. Both carry the scalar types, str, i128/u128, and aett values. See docs/GUIDE.md for the full chapter.


Compile Warnings

The compiler warns (never fails) on legal-but-suspicious code: unused variables (prefix a name with _ to opt out), unreachable code, and deprecated builtins, each with the same line-and-caret display as errors. Suppress with torvc --no-warn (or -q), or per file with directives at the top — !@NO_WARN; for everything, !@ALLOW[unused_variable]; for one category (stackable). A typo'd directive is a clean compile error, not a silent no-op.


Memory Model

Torvik uses automatic reference counting (ARC). There's no garbage collector and no manual free() — memory is released deterministically as soon as a value is no longer referenced.

  • Leak-free for supported patterns. Ordinary code that builds and discards strings and collections releases its memory deterministically.
  • Clean out-of-memory behavior. If an allocation can't be satisfied, the program panics cleanly rather than corrupting state.
  • No reference cycles. Reference counting can't reclaim cycles, but Torvik currently has no construct that can create one.
  • Lock-free across threads. Concurrency copies values as they cross thread boundaries, so refcounts never need atomics — the sole exception is a bridge's own refcount, since a bridge is deliberately shared.

Next Steps

Clone this wiki locally