LEVIATHAN v0.1 · in development

leviathan-lang.com / docs / learn

Learn Leviathan

A guided tour of the language, front to back. We start with a one-line program and finish by shipping a real project — picking up types, classes, pattern matching, error handling, and concurrency along the way. Every snippet is real Leviathan; read top to bottom, or jump to a chapter from the sidebar.

One idea to hold onto

Leviathan chases a single conviction: most of what looks like a language's "special case" is really a general rule nobody bothered to find yet. A constructor is just a method with a keyword; an operator is just a method named with a symbol; a property is just a typed view over a slot. Once you see the rule, the whole language gets smaller. Watch for it as we go.

1 · Hello, Leviathan

A Leviathan program is a set of declarations plus top-level statements that drive it. The smallest useful program is one line — console is an ordinary prelude object, and writeln is a real method on it.

console.writeln("Hello, Leviathan!");

Source files use the .lev extension. The compiler binary is leviathan; the build driver you'll actually run day-to-day is trident. To run a file directly through the tree-walk interpreter:

leviathan --run hello.lev        # execute on the oracle interpreter
trident run                      # ...or let trident drive a whole project

As a program grows you'll group code into namespaces and give it an entry point — a function to call, or a file whose top-level statements run. We'll get there in chapter 13. For now, top-level statements are all you need.

2 · Values & types

Leviathan is statically typed. You can spell a type explicitly, or let the compiler infer it with var (reassignable) or let (single-assignment). Inference never changes the fact that every binding has one static type.

int    count = 3;
string name  = "Ada";
bool   ready = true;
float  ratio = 0.75;

var total = count + 10;     // inferred int, still reassignable
let pi    = 3.14159;        // inferred float, fixed after this line

Here's the first place the "one rule" shows up: primitives are objects. int, string, bool, float, and char are value types stored unboxed — but they carry real methods that dispatch through the same machinery as any class.

(-7).abs();            // 7
"Hello".toUpper();     // "HELLO"
(42).toString() + "!"; // "42!"
3.7.floor().toInt();   // 3

Strings interpolate with ${...}, which desugars to concatenation through .toString():

string who = "world";
console.writeln("Hello, ${who}! You have ${count + 1} messages.");

When you want a binding fixed, reach for const. It isn't a type — it scopes a slot's write window to its initialization, then leaves only the read view. (There's a construction-time sibling, readonly, for class fields — chapter 5.)

const int maxRetries = 5;
// maxRetries = 6;   // compile error: cannot assign to const

3 · No null: optionals

Leviathan has no null. Absence is a real, typed value called None, and T? is simply sugar for the union T | None. Because absence is in the type, the compiler makes you handle it — you can't quietly forget.

string? token = request.header("Authorization");   // maybe there, maybe not
// token.length();   // compile error: must handle the None case first

Two operators cover the common cases. ?? supplies a default when a value is None; ?. short-circuits a call or access to None.

string shown = token ?? "anonymous";     // fall back to a default
int? len     = token?.length();          // None if token is None

For anything richer, narrow by flow typing. Once you've checked, the compiler tracks that the value is present inside that branch — including across && and along member paths.

if (token != None && token.length() > 0) {
    console.writeln("authorized: ${token}");   // token is a plain string here
} else {
    console.writeln("no credentials");
}

Conditions must be bool — there is no "truthiness." An empty string, a zero, and None are all distinct and none of them is secretly false. See Types & Generics for the full narrowing rules.

4 · Collections

Arrays, maps, and sets are pure values. Every "changing" method returns a new collection; you rebind to "change." This is what makes them safe to pass around — no one can mutate your array behind your back.

Array<int> nums = [1, 2, 3, 4, 5, 6];
Array<int> even = nums.where((n) => n % 2 == 0);   // [2, 4, 6]
Array<int> doubled = nums.map((n) => n * 2);       // [2, 4, 6, 8, 10, 12]
int sum = std::sum(nums);                          // 21

nums = nums.add(7);        // rebind — "nums" now ends with 7
int first = nums.first();  // 1

Map<K, V> is insertion-ordered and equally pure. Because get/set are keywords, the vocabulary is at / with / without — or bracket sugar, where m[k] = v rebinds.

Map<string, int> ages;      // bare declaration -> empty map
ages = ages.with("Ada", 36);
ages["Alan"] = 41;           // rebind sugar

int a = ages.at("Ada");            // 36  (throws if missing)
int b = ages.atOr("Grace", 0);     // 0   (default if missing)

Iterate with for..in over ranges, arrays, and map entries:

for (int i in 1..3) { console.writeln(i); }          // 1, 2, 3 (inclusive)

for (string k in ages.keys()) { console.writeln(k); }

for (Pair e in ages) {                                // entries as Pair<K, V>
    console.writeln("${e.first} is ${e.second}");
}

For big pipelines that should stay lazy, bridge into a Seq with asSeq() — nothing runs until a terminal pulls:

Array<int> r = nums.asSeq()
    .map((x) => x * x)
    .where((x) => x % 2 == 1)
    .take(3)
    .toArray();    // squares computed only for the 3 kept elements

5 · Classes & objects

Now the payoff of the "one rule." A class is a set of members, and a member is just a typed slot bound to a label. Some slots hold data; some hold something callable (a method). Constructors, operators, and properties are all members too — marked, not special-cased.

class Counter {
    public string label;
    public int value = 0;

    // A constructor is a member marked by 'new'. The name is only a label.
    new Counter(string startLabel) {
        label = startLabel;
    }
    // A second constructor, selected by its label rather than the class name.
    new WithValue(string startLabel, int start) {
        label = startLabel;
        value = start;
    }

    // A method. Arrow body: '=>' IS the return.
    string describe() => "${label} = ${value}";

    // An operator is a method whose name is a symbol.
    Counter (+)(int n) => Counter::WithValue(label, value + n);
    bool    (==)(int n) => value == n;     // (!=) derives automatically as !(==)
}

Constructing takes no new at the call site — you name the type (or a labeled constructor):

Counter c = Counter("hits");            // -> label "hits", value 0
Counter d = Counter::WithValue("x", 3); // labeled constructor
Counter e = c + 5;                       // the (+) operator: value becomes 5
bool  hit = e == 5;                      // the (==) operator: true
console.writeln(e.describe());           // "hits = 5"

Properties are typed views over a slot, declared with get/set. Objects are references (shared on assignment and passing); primitives and arrays are values.

class Thermostat {
    int celsius = 20;
    get fahrenheit() => celsius * 9 / 5 + 32;     // computed, read-only
    set fahrenheit(int f) celsius = (f - 32) * 5 / 9;
}

A field whose value is fixed once at construction is readonly; a compile-time constant field is const; a back-reference that shouldn't keep its target alive is weak. Full details on Members & Accessors.

6 · Interfaces & multiple inheritance

An interface declares required members — including fields — and allocates nothing. The implementing class is the single allocating site.

interface Named {
    string label;         // a required field
    string describe();    // a required method
}

class Tag : Named {
    public string label;
    public string describe() => "#${label}";
}

Leviathan allows multiple inheritance — and solves the classic diamond problem by being explicit. Two members collide only when their name and type both match. When they do, you resolve it with distinct, which keeps a separate slot per source, reached by qualification.

class Counter { public distinct int value = 0; }
class Badge   { public distinct int value = 99; }

class Widget : Counter, Badge {
    new Widget() {
        this.Counter::value = 5;    // two distinct 'value' slots —
        this.Badge::value   = 7;    // no ambiguity, no silent merge
    }
    // A bare read of 'value' here is a compile error: which one did you mean?
}

That's the design stance in miniature: rather than guess, the compiler refuses to guess and hands you a precise way to say what you meant. See Classes & Interfaces for collision collapse rules and covariant returns.

7 · Value types: structs & enums

When you want data with no identity — a coordinate, a row, a small record — use a struct. A struct is copied on every bind, pass, return, and store (deeply), has no identity, and is final. A method that writes this must be marked mutating.

struct Point {
    int x;  int y;
    int dot() => x * x + y * y;
    mutating void translate(int dx, int dy) { x = x + dx; y = y + dy; }
}

Point a = Point(3, 4);
Point b = a;              // a full copy — b is independent
b.translate(1, 1);        // mutates b only; a is still (3, 4)

An enum is a value type with a closed set of members carried by int. Members live on the static side, reached with ::.

enum Status : int { OK = 200, NotFound = 404, Teapot = 418 }

Status s = Status::NotFound;
int   code = s.code();              // 404
string txt = s.toString();          // "NotFound"
Status? maybe = Status::fromCode(200);   // Status?  — None if no member matches

Because value structs with all-scalar fields have no identity, an Array<Point> can be stored column-major automatically for big speedups — with zero change to your code. See columnar storage.

8 · Pattern matching

match dispatches on a type or a value in one readable construct. It's an expression when its arms yield a value, and a statement otherwise. First match wins.

string grade(int score) => match (score) {
    90..100 => "A";
    80..89  => "B";
    70..79  => "C";
    else    => "F";
};

Matching on type narrows the subject inside each arm — the same machinery as is and catch:

string describe(IShape sh) => match (sh) {
    Circle c => "circle r=${c.radius}";
    Square s => "square ${s.side}";
    else     => "shape";
};

Over an enum, match is exhaustive: cover every member and you need no else — and omitting one is a compile error that names what you missed.

enum Method { GET, HEAD, POST }

string verb(Method m) => match (m) {
    Method::GET  => "read";
    Method::HEAD => "peek";
    Method::POST => "write";
};   // exhaustive — no else needed

9 · Errors & resources

Leviathan draws a deliberate line. Expected absence is a value; programmer errors throw. A parse that might fail returns a T?; an out-of-bounds index throws a catchable RuntimeException.

int? parsed = "42".toInt();      // strict: None on garbage, never a silent 0
int n = parsed ?? 0;

// Throwing path — the thrown value must implement IException.
try {
    throw RuntimeException("boom");
} catch (IException e) {
    console.writeln("caught: ${e.message}");
}

There is no finally. Deterministic cleanup is using: it owns a resource that implements IDisposable and runs close() on every way the block exits — falling off the end, return, a throw unwinding past it, or a break. Multiple resources close in reverse order.

void backup(string path) {
    using File src = File(path, std::read);
    using File dst = File(path + ".bak", std::write);
    dst.writeln(src.readln());
}   // dst.close() runs, then src.close() — on every exit edge

10 · Functions, generics & injection

A function has no this (it lives in a namespace or a class's static side); a method has one. Both overload by argument type, and both support named arguments and default parameters. A body is exactly one statement — use a block or an arrow.

int add(int a, int b) => a + b;

void greet(string name, string greeting = "Hello") {
    console.writeln("${greeting}, ${name}!");
}

greet("Ada");                       // "Hello, Ada!"
greet("Alan", greeting: "Hi");      // named argument reorders freely

Generics infer their type arguments from the call, and fall back to the target type:

T firstOr<T>(Array<T> xs, T dflt) => xs.isEmpty() ? dflt : xs.first();

int  x = firstOr([1, 2, 3], 0);     // T = int, inferred from the argument
string s = firstOr([], "none");     // T = string, from the default

For wiring dependencies, bind supplies a value and inject requests one. Binding is lexical and nearest-wins — and it must enclose the call site, not the callee. That's what makes swapping a real dependency for a fake in tests a one-line change.

interface ILogger { void log(string s); }
class ConsoleLogger : ILogger { public void log(string s) => console.writeln(s); }

void run() {
    audit();                 // its ILogger parameter is filled by injection
}

{
    bind ILogger => ConsoleLogger();   // in scope for the call below
    run();
}

11 · Concurrency

Concurrency is honest: a function that returns a Promise<T> returns an actual promise — no implicit wrapping, no async keyword, no function coloring. await is the one privileged operation, and it parks the current task until the promise settles.

Promise<int> slowSquare(int n) {
    => Promise(n * n);        // an actual Promise, returned honestly
}

int squared = await slowSquare(6);   // 36 — parks until it settles

For real parallelism, std::spawn starts a worker. The handle is a promise, so await is the join. Workers are fully isolated: everything crossing the boundary crosses by copy.

Worker<int> w = std::spawn(() => heavyCompute(data));
// ... do other work meanwhile ...
int result = await w;        // join the worker, get its result

Workers talk through a Channel<T>, and structured concurrency comes from TaskGroup over the same using rule you already know — no task outlives its scope. See Concurrency & Async for tasks, cancellation, and awaitTimeout.

12 · A taste of metaprogramming

Leviathan can run itself at compile time and generate code — with zero runtime reflection. An attribute is an inert, typed annotation; a rule matches a shape and injects code; and comptime folds a computation to a literal before the program ever runs.

// Fold a value at build time — the loop never runs at runtime.
comptime int TABLE_SIZE = nextPrime(1000);

// An attribute: its fields ARE its arguments.
attribute Route { string method; string path; }

@Route("GET", "/users")
Array<User> listUsers() => db.users();

A rule in a namespace can then find every @Route and wire up a router at compile time — the generated code is checked and compiled exactly like code you wrote by hand, so it costs nothing extra at runtime. The full story (rules, body-replacing rewrites, and procedural macros) is on Metaprogramming.

13 · Shipping a project

A real project is described by a trident.toml manifest. trident resolves it and drives the leviathan compiler for you.

name    = "app"
entry   = "main"            # a function to call, or a file to run
sources = ["*.lev"]         # globs expand alphabetically
version = "0.1.0"

[[dep]]                     # a local dependency is just more source
path = "jsonlib"
as   = "Json"

Then the everyday commands:

trident check      # parse + resolve + type-check, no execution
trident run        # compile and run on the interpreter
trident build      # produce a native executable (via the LLVM backend)

Your main reads its inputs, wires dependencies, and launches — exactly the shape we built up over the last twelve chapters:

namespace App {
    void main() {
        Array<string> args = env::args();
        console.writeln("running with ${args.length()} args");
    }
}

App::main();     // top-level statement drives the program
Where to go next

You've seen the whole language in outline. For the precise rules, method catalogs, and engine notes, dive into the Language Reference — every chapter above has a matching reference page with the full detail.