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.
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 const3 · 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 elements5 · 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 matchesBecause 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 needed9 · 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 edge10 · 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 freelyGenerics 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 programYou'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.