Axle v0.14.1

Language tour

A single-pass tour of the surface syntax — the language proper. The stdlib reference covers the library types; here we stay on the syntax you write yourself.

If you have written Java, TypeScript, or Rust, most of this will feel familiar. Axle’s own flavour is in three places, flagged as you reach them: error sets live in the return type (: i32 ! IOException), memory is inferred (no garbage collector, no manual free), and ownership keywords annotate how a call treats its arguments.

Reading path. The sections build on each other:

  1. Primitive types and Variables — the values you start from.
  2. Control flow and Operators — how you compute with them.
  3. Structs, classes, traits, enums — how you model data and behaviour.
  4. Exceptions and Deferred cleanup — how you handle failure.
  5. Lambdas, ownership keywords, concurrency, modules, FFI — the rest of the surface.

Note — every code block below is real Axle. The self-contained ones (those with a fn main) compile as-is; the shorter fragments show one form in isolation. The first section is a map of the shapes that live at a file’s top level — skim it, then read on; each shape is expanded in its own section further down.

Top-level declarations

use std::collections::ArrayList;     // module import

fn add(a : i32, b : i32) : i32 {     // free function
    return a + b;
}

class Counter {
    value : i32,                     // field
    constructor(initial : i32) {
        self.value = initial;
    }
    pub fn bump(self, delta : i32) : i32 {     // method
        return self.value + delta;
    }
}

trait Greeter {                  // trait — the capability contract
    fn greet(self, name : string) : string;   // classes opt in via `: Greeter`
}

@derive(Eq, ToString)                // synthesises `equals` + `toString`
class Point {
    x : i32;
    y : i32;
    constructor(x : i32, y : i32) {
        self.x = x;
        self.y = y;
    }
}

enum Shape {                         // value-type enums — access a variant
    Circle(i32),                     // as `Shape::Circle(5)`, compare with
    Rect(i32, i32),                  // `==`, and `match` on it (destructuring
    Empty,                           // the payload). `match` is exhaustive.
}

Free functions, classes, structs, traits, and enum declarations all live at the top level of a .axle file. Inside classes: fields first (each terminated by ;), then constructor / drop() (the destructor), then methods.

The class header places the base class and any traits in one : list — class Sprite : Shape, Renderable, Serialisable { … }. The : list is the only supertype syntax. A trait may carry default method bodies (inherited by an implementer that omits the method) and an impl Self table that equips primitives with the trait without a : Trait clause. Pass a trait parameter as dyn Trait to make the dynamic dispatch explicit: fn process(r : dyn Reader) : i32 { return r.read(); }.

@derive(Eq, Ord, Hashable, ToString, Clone) on a class synthesises standard implementations field-by-field. See Generics and traits for bounds (<T : A + B>), the match type compile-time type switch, and the full @derive table.

An enum is a value type (a named integer tag, never heap-allocated). The optional : T annotation picks the backing integer type (i8–i64, default i32); a variant may take an explicit discriminant (Green = 5, auto-incremented otherwise). Variants may also carry data — scalar or string payloads, stored inline (no allocation):

enum Shape { Circle(i32), Rect(i32, i32), Empty }

fn main() : i32 {
    let s : Shape = Shape::Rect(4, 5);
    return match s {
        Shape::Circle(r) => r * r,
        Shape::Rect(w, h) => w * h,      // binds the payload fields
        Shape::Empty => 0,
    };
}

A match that names every variant needs no _ arm; a missing variant is a compile error. An arm may name a variant bare — Circle(r) or Empty without the Shape:: prefix — and it resolves against the scrutinee’s enum as that variant, not as a fresh catch-all binding. Enums get their own chapter — backing types, discriminants, string / owned-object payloads, methods and trait implementation — in Enums.

An or-pattern arm (A | B) may match several tags at once but may not bind a value on any branch — Some(x) | None is rejected, because the None branch would leave x uninitialised. Match the tag alone (Some(_) | None) or split the binding into its own arm.

Primitive types

i8 / i16 / i32 / i64            signed integers (wrap on overflow)
u8 / u16 / u32 / u64            unsigned integers (wrap on overflow)
f32 / f64                       IEEE-754 floats
bool                            true / false
char                            a single UTF-32 code point
string                          immutable, UTF-8, refcounted
void                            statement-position only — never a value

Signed and unsigned do not mix. a + b, a < b, a & b with one of each is an error, and you write the cast that says which reading you meant :

let big : u32 = 4000000000;
let one : i32 = 1;

// big + one                      // E0037 — one is signed, the other is not
let ok : u32 = big + (one as u32); // say it, and it compiles

The rule exists because the same 32 bits are -1 read signed and 4294967295 read unsigned, and the two disagree about whether -1 < 1. C picks unsigned and answers false; Axle asks instead. Shifts are exempt — x << n does not join its operands — and mixing an integer with a float is unchanged (u32 + f64 is f64).

-x on an unsigned value is an error too (E0038): it is well-defined, since Axle’s integers wrap, which is exactly the problem — -a on a u32 holding 5 reads as “minus five” and computes 4294967291. Write ~x for the complement, or 0 - x for a subtraction from zero.

There is no literal suffix. A literal takes the type of whatever it flows into, so big != 4000000000 compares two u32s and let x : u8 = 300; is rejected for being outside [0, 255].

Composite shapes :

T[N]            fixed-size array, count known at compile time
T[]             dynamic array (`malloc<T>(n)`), header carries length
Shared<T>       refcounted handle — see the dedicated chapter
Task<T>         result of `spawn expr` — consumed with `.join()`
ptr<T>          unsafe raw pointer for FFI (gated behind `unsafe { }`)

A string literal has two interchangeable spellings — the classic double-quote and the TS-style backtick — that lex to the same token and both support ${…} interpolation:

let name = "Ada";
println("hi ${name}");      // hi Ada
println(`hi ${name}`);      // identical; backticks also span newlines

Variables

Two declaration forms:

  • let — mutable binding. Reassignment is allowed.
  • const — immutable binding. Reassignment is rejected at compile time with E0004 “Cannot assign to immutable variable”, whether the write is direct (x = …), through a field (x.f = …), an index (x[0] = …), or a deref (*x = …).
const MAX_LINES : i32 = 1000;        // immutable
const pi  = 3.14159;                 // type inferred → f64

fn main() : i32 {
    let count : i32 = 0;             // mutable
    count = 1;                       // ✓ allowed
    // MAX_LINES = 0;                // ✗ E0004

    let name = "Alice";              // type inferred → string
    println(name);
    return count;
}

let declares a reassignable binding; there is no separate mut qualifier on locals — every let binding is mutable. The ownership keywords described below (own / mut on parameters) are about calling-convention contracts, not local-binding mutability.

Prefer const for any binding you don’t intend to mutate — the compiler then propagates its value/range across the scope and refuses accidental writes through the binding. When the right-hand side is a constant expression, the const also participates in compile-time slots like T[N] array sizes.

Nullable types

A class or struct reference is non-null by default. Opt into nullability with T | null; the compiler then forces a null check before any member access:

class Box {
    pub value : i32;
}

fn firstValue(b : Box | null) : i32 {
    if (b != null) {
        return b.value;       // narrowed to `Box` inside the check
    }
    return 0;                 // here `b` is still `Box | null`
}

The narrowing is flow-sensitive: an if (x != null) guard, a while (x != null) guard, or the else-branch of an x == null test proves the binding non-null for the guarded body only. A member reached without one of those is refused — a method call included, not only a field read.

Safe navigation

?. is the guard written inline. It evaluates the receiver once, yields null when it is null, and otherwise reaches the member — so the result is nullable and pairs with ??:

class Box {
    pub value : i32;
    constructor(v : i32) { self.value = v; }
    pub fn doubled(self) : i32 { return self.value * 2; }
}

struct Point {
    pub x : i32;
    pub y : i32;
    pub fn sum(self) : i32 { return self.x + self.y; }
}

fn main() : i32 {
    let b : Box | null = new Box(21);
    let none : Box | null = null;
    let p : Point | null = Point { x: 3, y: 4 };

    if ((b?.doubled() ?? 0) != 42) { return 1; }
    if ((none?.doubled() ?? -1) != -1) { return 2; }
    if ((p?.sum() ?? 0) != 7) { return 3; }   // a value receiver, too
    return 0;
}

?. works on every receiver kind — a class, a trait, a struct held by value, a primitive — and on a field of any type as well as a method. b?.value on an i32 field yields i32 | null, so a present 0 and an absent value stay distinguishable.

For the null-handling operators (??, ??=), see Operators.

Control flow

fn main() : i32 {
    let x : i32 = 1;
    let i : i32 = 0;
    let n : i32 = 4;
    let xs : i32[] = [1, 2, 3];
    let cond : bool = false;

    if (x > 0) { println("pos"); } else if (x == 0) { println("zero"); } else { println("neg"); }

    while (i < n) { i = i + 1; }
    do { i = i - 1; } while (cond);

    for (idx of 0..n) { println(idx.toString()); }   // exclusive upper bound
    for (idx of 0..=n) { println(idx.toString()); }  // inclusive upper bound
    for (v of xs) { println(v.toString()); }         // straight over an array's elements

    let value : i32 = 2;
    let label : string = match value {
        0       => "zero",
        1 | 2   => "small",
        3..=10  => "medium",             // range pattern (inclusive)
        _       => "other",              // `_` catches everything else
    };
    println(label);
    return 0;
}

break and continue work in any loop ; nested loops are swallowed by the inner-most.

match is a rich construct in its own right — literal, binding, range, or-pattern, struct-destructuring, enum-payload, null and guard patterns, plus the exhaustiveness rule. This section shows the common shapes; the complete reference is Pattern matching.

The for … of head iterates a range (0..n, 0..=n) or an array (T[N], T[]) — for (v of xs) binds v to each element in turn, and is exactly for (i of 0..xs.length) { let v = xs[i]; … }. Iterating a collection or a string directly is not supported — index over a range instead: for (i of 0..items.size()) { … }.

match is an expression — its value can be returned, bound or passed directly. The arm arrow is =>:

fn classify(x : i32) : string {
    return match x {
        0       => "zero",
        1..=10  => "small",
        _       => "large",
    };
}

match type T is the compile-time counterpart — inside a generic class method it selects an arm by the static identity of the class’s type parameter, folded to the matching branch per instantiation:

class Tag<T> {
    v : T;
    constructor(v : T) {
        self.v = v;
    }
    pub fn name(self) : string {
        return match type T {
            i32    => "integer",
            f64    => "float",
            string => "text",
            _      => "other",
        };
    }
}

See Generics and traits for the complete rules. A free function can also be generic (fn id<T>(v : T) : T), with its type argument inferred from the call.

Operators

The usual arithmetic (+ - * / %), comparison (== != < <= > >=), logical (&& || !) and bitwise (& | ^ ~ << >> >>>, integer operands) sets, plus:

class Box { v : i32; constructor(v : i32) { self.v = v; } }

fn lookup() : Box | null { return null; }
fn fallback() : Box { return new Box(0); }
fn check() : bool { return true; }
fn retry() : bool { return false; }

fn main() : i32 {
    let x : i32 = 12;
    x += 5;            // every binary op has a compound form:
    x <<= 2;           // += -= *= /= %=  &= |= ^= <<= >>= >>>=
    x++;               // increment / decrement (prefix or postfix)

    let s : Box | null = lookup();
    let v = s ?? fallback();   // null-coalesce: `s` when non-null, else `fallback()`
    s ??= fallback();          // assign only when `s` is null

    let ok : bool = true;
    ok &&= check();            // assign only when `ok` is true
    ok ||= retry();            // assign only when `ok` is false
    return x;
}

The three short-circuit compounds (&&= ||= ??=) keep their binary op’s laziness — the right-hand side runs only when the assignment can change the target. Their target must be a plain variable or field chain (an indexed target is rejected with a hint, since its receiver would be evaluated twice).

cond ? a : b is the conditional expression, and it is the way to choose a value inside one: an if is a statement, so there is no let x = if … form to reach for instead.

fn main() : i32 {
    let lum : f64 = 0.8;
    let level : i32 = lum > 0.5 ? 7 : 3;
    return level;
}

Ranges (0..n exclusive, 0..=n inclusive) are first-class values; as converts between numeric types (with one exception below), is / !is tests a runtime type. Integer literals accept hex / binary / octal forms with _ separators: 0xFF, 0b1010_0011, 0o17, 1_000_000.

Static and namespace access uses ::, never .. A static fn, a stdlib utility method, an enum variant, and a module-qualified name are all reached through :: — Account::open("alice"), Color::Red, Shared::wrap(p), math::pi(). The . operator is for instance members only (account.deposit(5)); spelling a static member with . is rejected (E0027).

Two guard rails on the numeric operators:

  • A float→int conversion via as is rejected at compile time — it is lossy, so its rounding must be named. Use x.round() (nearest), x.truncToInt() (toward zero), x.floorToInt() or x.ceilToInt() instead; each returns an i64 and saturates an out-of-range value (NaN maps to 0).
  • Division by zero is an error, never an Inf / NaN producer. A literal zero divisor — integer x / 0 or float x / 0.0 alike — is rejected at compile time (E0019); float division does not yield IEEE ±Inf. A runtime zero divisor traps instead of diverging.

Tuples — several values, one expression

A tuple groups a fixed number of values of any types. It is the shape a function reaches for when one return value is not enough and a named type would be ceremony:

fn divmod(a : i32, b : i32) : (i32, i32) {
    return (a / b, a % b);
}

fn main() : i32 {
    let r = divmod(17, 5);
    let q = r.0;                         // 3 — elements are read by position
    let m = r.1;                         // 2
    return q + m;
}

Taking one apart

let (a, b) = … binds every element at once, and evaluates its right-hand side exactly once:

fn divmod(a : i32, b : i32) : (i32, i32) { return (a / b, a % b); }
fn bounds() : (i32, i32) { return (0, 100); }

fn main() : i32 {
    let (quotient, remainder) = divmod(17, 5);
    const (lo, hi) = bounds();           // `const` binds the same way, immutably
    return quotient + remainder + lo + hi;
}

Every position is bound, or none is: a name list that does not cover the tuple is E0755 rather than a silent drop, since a shorter list throws away a value the callee returned on purpose. A non-tuple on the right is E0754 — reading by position is what a tuple is for, and everything else answers by name.

The names are yours, not the type’s — they may shadow an outer binding exactly as a single-name let may.

Naming the positions

A tuple type may name its positions, so a caller can read the element by what it is rather than where it sits:

fn clientSize() : (w: i32, h: i32) {
    return (1280, 720);
}

fn main() : i32 {
    let size = clientSize();
    return size.w;                       // 1280 — the same element as size.0
}

A name is a reading aid over a position, not part of the type. (w: i32, h: i32) and (i32, i32) are the same type: a labelled tuple flows into an unlabelled parameter and back, and two libraries that both return a pair of i32 stay interchangeable without either repeating the other’s names.

fn clientSize() : (w: i32, h: i32) { return (1280, 720); }

fn area(s : (i32, i32)) : i32 {      // takes the bare form …
    return s.0 * s.1;
}

fn main() : i32 {
    return area(clientSize());       // … and a labelled one flows straight in
}

Reading a name no position of the type carries is E0016, and the diagnostic lists the names it does carry.

Reach for a tuple when the grouping is local and obvious — a quotient and a remainder, a width and a height. Reach for a struct the moment the group has a name worth writing down, is stored in a field, or crosses a module boundary: a tuple has no methods, no defaults and no visibility.

Structs — value semantics

A struct groups plain data and is copied on assignment — two bindings never alias:

struct Point {
    x : i32;
    y : i32;
}

fn main() : i32 {
    let a = Point { x: 1, y: 2 };    // literal initialisation
    let b = a;                       // a full copy
    b.x = 99;                        // … so `a.x` is still 1
    return a.x;
}

A literal supplies every field — omitting one is E0743, because the slot would keep its zero bytes rather than a value you chose. A field whose value comes from a binding of the same name may be written in shorthand:

struct Point { x : i32; y : i32; }

fn main() : i32 {
    let x = 1;
    let y = 2;
    let p = Point { x, y };              // = Point { x: x, y: y }
    return p.x + p.y;
}

A field can carry a default, and a literal can fill the rest from a base with ..:

struct Material {
    r : f64 = 0.5;
    g : f64 = 0.5;
    b : f64 = 0.5;
    shine : f64;
}

fn main() : i32 {
    let dull   = Material { shine: 1.0 };              // r/g/b default to 0.5
    let bright = Material { shine: 64.0, ..dull };     // the rest from `dull`
    return bright.shine.truncToInt() as i32;
}

The value comes from the first source that supplies it — the literal, then ..base, then the default. The base is evaluated once, however many fields come out of it.

A struct whose fields all carry a default is written S { } — the literal that names nothing and means all of them:

struct Opts { retries : i32 = 3; label : string = "none"; }

fn main() : i32 {
    let quiet = Opts { };               // retries 3, label "none"
    return quiet.retries - 3;
}

Those braces are a literal because of where they are. A { opens a block only at the head of a construct that has one — if x { }, while x { }, match x { }, for a of xs { } — so an identifier there stays an identifier. A bracket ends the ambiguity and the literal reads again: if (P { a: 1 }.ok) { … } and if check(P { a: 1 }) { … } are both fine.

A struct’s default must be a constant: a literal, operators over literals, a :: path such as Mode::On, or a literal of another struct built from those. Anything else is E0760, reported at the declaration. The reason is where the default is lowered — at each literal’s site, in that scope, because it stands in for what you would otherwise have typed there. A default reading a name would therefore read whatever that name means at the site, and one that calls would run once per construction, resolving the callee in the site’s scope.

An array literal — [e, …] or T[N](v) — is a constant by that measure and is accepted. It is settled by its spelling, a default is evaluated once per literal so two values never share one buffer, and the struct’s teardown releases what each value holds.

A class field may carry a default too, and it is not restricted: a class is built by its constructor, so its default is prepended to every constructor body — one place, one scope. px : i32[] = malloc<i32>(8) is legal on a class and E0760 on a struct.

And the constructor must actually assign the fields that need it. A field whose type is a reference — Shared<T>, Weak<T>, an owned object, an array, a string — cannot hold “nothing”: an unassigned one starts as a null pointer that its type says can never be null, and the object’s teardown dereferences it. So a constructor that leaves such a field unwritten on a path that completes is E0740, and so is a class that declares one of those fields with no constructor at all:

class Holder {
    r : Shared<Leaf>,      // E0740 — nothing ever assigns it
}

class Holder {
    r : Shared<Leaf> | null,   // ✓ genuinely optional, and the null is checked
}

class Holder {
    r : Shared<Leaf>;
    constructor(r : Shared<Leaf>) { self.r = r; }   // ✓ always assigned
}

Numbers, booleans and T | null fields are exempt: their zero value is a value the type already admits.

The choice between the two is value semantics vs identity. A struct is its fields — copying it copies the data, and two copies with equal fields are interchangeable (think a 2-D point, an RGB colour, a size). A class has an identity — a binding refers to one object, assigning it shares that object, and it can carry methods, a constructor, and a base class:

structclass
Assignment (let b = a)copies the datashares the object
Identitynone — equal fields are equaleach new is a distinct object
Methods / constructornoyes
Inheritance / traitsnoyes
Created witha { … } literalnew T(…)
Reach for it whensmall plain databehaviour, identity, or a lifecycle

Use a struct for small plain-data shapes; reach for a class the moment you want identity, methods, constructors, or inheritance.

Visibility and member modifiers

A member — a field or a method — is visible in one place, and the marker names which. The places nest, so each rung admits everything the one before it did:

markerwho reaches the member
(none)the class that declares it — for a struct or enum, the file
pub(derived)that class and every type deriving from it
pub(file)the file the class is declared in
pub(crate)the crate
pubeverywhere, across the crate boundary

One ladder, both halves of a class: pub fn get(self) and pub balance : i64 put the same word in the same place. A method you call from outside its class therefore needs a marker — including from main, and from another class in the same file, since the unmarked rung is the class and not the file. That is what leaves pub(file) a rung worth writing.

pub(derived) is the one rung that names a set of types rather than a place, and it covers subclasses and trait implementers alike.

The unmarked rung follows the declaration form. A class hides its state behind its methods, so an unmarked class member belongs to that class. A struct is its state, so its unmarked members belong to the file — which is what keeps Vec2 { x, y } readable in the code that declared it.

Two members take no marker at all. A constructor is reached by new C(…), and a class you can name is a class you can build. A trait member is as visible as the trait: a trait exists to be called through, so a method implementing its contract stays reachable on the receiver’s own type too, not only through the trait.

There is no public / private / protected keyword; the marker is always pub, optionally narrowed. Two more modifiers attach to members: static for class-level members and readonly for write-once fields. A field may also carry an initialiser, = value:

class Account {
    pub static MAX_OVERDRAFT : i64 = 500;   // one slot for the class

    pub owner       : string,    // everywhere
    pub(crate) tag  : i64,       // this crate
    pub(file) cache : i64,       // this file
    balance         : i64 = 0,   // no marker = this class only; runs in every ctor
    readonly id     : i64,       // assignable only in the constructor

    constructor(owner : string, id : i64) {
        self.owner = owner;
        self.cache = 0;
        self.id = id;            // ✓ readonly write — inside the ctor
    }

    pub fn deposit(mut self, amount : i64) : void {
        self.balance = self.balance + amount;
    }

    pub static fn open(owner : string) : Account {
        return new Account(owner, 0);
    }
}

A field’s initialiser is prepended to every constructor body, in declaration order and after a leading super(…), so it runs in the class’s own scope and the constructor’s own statements still win over it. A class that declares one and writes no constructor gets one, so new Account(…) never leaves an initialised field zeroed. A struct’s default differs in where it runs, not in what it means: it applies at a literal that omits the field.

A static field is one slot for the class, not one per instance — it takes no space in an object and is read through :: (Account::MAX_OVERDRAFT). It requires a constant initialiser, since nothing constructs a class-level slot per object, and that value is materialised at each use, which makes a static field a class-scoped constant. A struct field and an enum variant refuse static: a struct is a value with no class behind it, and a variant is not storage.

A write to a readonly field outside the owning class’s constructor is rejected at compile time (E0004 — the same error as a write through a const binding). static fn members are called on the class itself through ::: Account::open("alice"). Reaching a static member with a . (Account.open(…)) is an error (E0027) — use ::.

Ownership keywords on parameters

A parameter name can carry an optional prefix keyword that declares the calling-convention contract. Borrowed (no keyword) is the default and the most common.

struct Point { x : i64; y : i64; }

fn read   (p : Point)        : i64  { return p.x; }    // borrowed — caller keeps ownership
fn modify (mut p : Point)    : void { p.x = p.x + 1; } // mutable in place
fn consume(own p : Point)    : void { }                // takes ownership
fn share  (p : Shared<Point>): void { }                // refcounted handle

fn main() : i32 { return 0; }

mut and own are prefix qualifiers — they don’t change the runtime representation, only the contract. Shared<T> is a real type wrapper (refcounted, 16-byte header) — written in the type position, not as a prefix.

See the Memory model for the escape-analysis rules and storage tiers, and Shared<T> reference counting for the refcount lifecycle in detail.

A parameter named _ is the discard marker: it still takes its slot in the signature, but binds no name the body can read, so fn f(_: u32, _: u32) : void { … } is legal — two _ parameters are not a name collision, since neither is reachable by name. A lambda’s parameter list follows the same rule ((_, _) => … is fine), but repeating any other name, in a function’s parameter list or a lambda’s, is an error (E0003).

Naming arguments at the call site

Six positional arguments of the same type is how a light and an eye end up swapped. An argument may carry the name of the parameter it is written in front of, so the call site says what each value is:

struct Colour { r : f64; g : f64; b : f64; }

class Texture {
    pub px : i32;
    constructor(px : i32) { self.px = px; }
    pub static fn checker(size : i32, cells : i32, a : Colour, b : Colour) : Texture {
        return new Texture(size * cells);
    }
}

fn main() : i32 {
    let orange = Colour { r: 1.0, g: 0.6, b: 0.1 };
    let brown  = Colour { r: 0.4, g: 0.2, b: 0.1 };
    let t = Texture::checker(size: 128, cells: 8, a: orange, b: brown);
    return t.px;
}

The label is checked against the declaration and then removed — the call is exactly the one the positional form produces. It works on free functions, constructors, instance methods, and static methods, and mixes with positional arguments.

A label annotates a position; it does not move the value. checker(cells: 1, size: 2) is an error (E0745), not a reorder — so a swapped pair is caught at compile time instead of running. A name the callee has no parameter for is an error too.

Lambdas

(params) => expr is a first-class function value; the type is written the same way, with the parameter types in parentheses:

fn main() : i32 {
    let add = (x : i32, y : i32) => x + y;   // non-capturing
    let r : i32 = add(2, 3);                 // 5
    return r - 5;
}

A lambda that reads an enclosing binding captures it by value. The capture is restricted: a by-value scalar, or a Shared<T> handle (co-owned into the closure’s environment, so its refcount is balanced like any other alias).

fn makeAdder(n : i32) : (i32) => i32 {
    return (x : i32) => x + n;      // captures `n` by value
}

Everything else is refused, each with its own code:

Rejected shapeError
A parameter with no type and no context to infer it fromE0730
Capturing an owning / non-scalar valueE0731
Writing to a captured binding — the capture is a copy, so the write would not be visible outsideE0733
A capturing closure flowing into spawn, or held live across awaitE0734
async (params) => body, a lambda referencing self, or a nested capturing lambdaE0700

Exceptions

Axle’s error handling is checked, but without Java’s ceremony. A function that can fail declares what it fails with in its return type — the failure is part of the signature, as visible as the success type — and the compiler proves at every call site that each possible failure is handled or re-declared onward. Nothing fails silently, and a function that can’t fail carries no error clause at all.

Why put the error set in the return type instead of a separate throws list? Because a fallible function returns one of two things — a value or an error — and the type should say so. : i32 ! ArithmeticException reads as a sum: “an i32, or an ArithmeticException”. The ! is the seam between them.

fn divide(a : i32, b : i32) : i32 ! ArithmeticException {
    if (b == 0) { throw ArithmeticException("division by zero"); }
    return a / b;
}

fn safe() : i32 {
    try {
        return divide(10, 0);
    } catch e : ArithmeticException {
        return -1;
    }
}

Once a call can fail, the compiler forces you to say what happens to that failure. There are exactly three ways to discharge it:

WayWritten asMeaning
Handle inlinedivide(a, b) catch e { … }run a recovery block right at the call
Propagatedivide(a, b)?re-throw to the caller — the ! set of this fn must already list the error
Handle in a blocktry { … } catch e : T { … }a try covering several fallible calls, one clause per error type

Any failure a function doesn’t list in its ! set must be caught — the compiler enforces it statically (E0005), and that holds for every family (RuntimeException and Error included): there is no unchecked exemption. There is no panic keyword; for a bug you never want to surface, hard-abort with std::ffi::runtime::panicMsg.

Checked failure is not just for functions you mark — stdlib operations throw too, and the same rule applies. An out-of-range ArrayList.set / removeAt raises a checked IndexOutOfBoundsException; ignoring it is the same E0005. A try statement discharges it, and costs nothing on the path that does not throw. Where the call’s value is what you want, the inline expr catch e { … } form folds the handler into the expression instead — note the trailing ;, since the whole thing is one statement, and note that its handler must end on a value of the guarded type or leave with return / throw: a catch expression whose call yields void and whose handler falls through has no value to produce, and is refused:

use std::collections::ArrayList;

fn main() : i32 {
    let xs : ArrayList<i32> = new ArrayList<i32>();
    xs.add(10);
    try {
        xs.set(0, 99);
    } catch e : IndexOutOfBoundsException {
        println("index out of range");
    }
    xs.removeAt(5) catch e { return 1; };            // recovery may also exit the fn
    return xs.get(0) ?? -1;                          // get returns `T | null`, not a throw
}

Note the asymmetry that runs through the collections: a fallible read like get returns a T | null you unwrap with ??, while a fallible write like set / removeAt throws. The read has an obvious “no value” answer (null); the write does not, so it signals failure the only other way — an exception.

Declare your own exception types with the exception keyword — the body uses the same grammar as a class. An exception implicitly derives Exception, so message : string is inherited; in throw position a call on the exception type constructs and throws it (no new):

exception NotFound {
    path : string,                       // `message` is inherited
    constructor(path : string) {
        super("not found: " + path);     // forward the message
        self.path = path;
    }
}

fn lookup(path : string) : i32 ! NotFound {
    if (path.isEmpty()) { throw NotFound(path); }
    return 0;
}

A catch e : Base clause matches Base and every subclass, a clause can union several types with |, and an optional finally block runs on every exit path — normal, caught, rethrown, or an early return:

fn risky() : void ! FileNotFoundException, IOException, ParseException { }
fn recover() : void { }
fn cleanup() : void { }

fn main() : i32 {
    try {
        risky();
    } catch e : FileNotFoundException {
        recover();
    } catch e : IOException | ParseException {   // bound as the common base
        println(e.message);
    } finally {
        cleanup();                               // always runs
    }
    return 0;
}

The fine print — return-value timing, nested finally ordering, how finally interacts with defer — lives in Error handling → finally.

See std/lang for the built-in exception hierarchy.

Deferred cleanup

use std::io::BufferedReader;
use std::io::FileInputStream;

fn firstLine(path : string) : string ! IOException, FileNotFoundException {
    let r = new BufferedReader(new FileInputStream(path));
    defer r.close();              // runs at every exit of `firstLine`
    return r.readLine();
}

defer registers a statement to run when the enclosing block exits — through a return, an uncaught throw, a break / continue leaving it, or fall-through at its end. At the top level of a function body that is function exit. Multiple defers fire in LIFO order, and in that same order relative to the compiler’s own cleanup: a defer written under a declaration runs before that binding is destroyed, so the object it names is still whole.

A defer written inside a nested block (if, for, { … }) runs at the end of that block, where what it names is still in scope. Conditional cleanup needs an explicit if around the resource acquisition, not around the defer itself.

defer is the cleanup mechanism. The language has no try (resource = open()) with-resources form — pair each acquisition with a defer for its release.

Concurrency

fn heavyWork(x : i32) : i32 { return x * 2; }
fn quickWork() : i32 { return 1; }

fn main() : i32 {
    let t : Task<i32> = spawn heavyWork(42);   // runs on a new OS thread
    let other : i32 = quickWork();
    if (t.isAlive()) { /* still running */ }   // non-consuming peek
    let result : i32 = t.join();               // block + take the result
    return result + other;
}

spawn f(args) runs the call on a worker thread and returns a Task<T>. The execution model is read in the verb: a Task is joined (.join() blocks and takes the result, one-shot), a Future is awaited. The second world is async fn — its body compiles to a coroutine state machine, lazy until awaited:

async fn partA(x : i32) : i32 {
    await Async::sleep(1);           // a leaf suspension point
    return x * 10;
}

async fn fetch(x : i32) : i32 {
    let r : i32 = await partA(x);    // suspends until partA resolves
    return r + 1;
}

fn main() : i32 {
    let f : Future<i32> = fetch(1);  // nothing runs yet
    return await f;                  // pumps the machine → 11
}

The synchronisation primitives (ReentrantLock, AtomicI32, BoundedChannel, …) live in std::concurrent — see Multithreading for the patterns and the async restrictions.

Modules and imports

use imports a single symbol (bare name), several symbols from one module in braces, or binds a whole module as a namespace:

use std::collections::ArrayList;                  // one symbol — bare `ArrayList`
use std::io::{FileInputStream, FileOutputStream}; // several from one module
use std::text;                                    // whole module — qualified access

fn main() : i32 {
    let s : string = text::f64ToString(2.0);      // module::name
    println(s);
    return 0;
}

Note — a module-qualified name like text::f64ToString reaches a free function the module exports. A static fn on a class is reached through the class instead: Account::open("alice"), not account::open (see Operators — ::). Numeric work splits the same way: the per-value operations are methods on the primitive (x.sqrt(), x.abs()), while the constants and the two-argument functions are free functions in std::numeric::math (math::pi(), math::atan2(y, x)).

Which form to reach for is a two-line rule:

You want…ImportThen call
a type (class / trait / exception)the name — use std::collections::ArrayList;new ArrayList<i32>()
a module’s free functionsthe module — use std::text;text::f64ToString(x)

Prefer importing the module when you want its free functions — use std::text; then text::f64ToString(x). One use line then covers every function the module exports, and each call carries the module:: prefix that says where it came from. Importing a single function by name (use std::text::f64ToString;, then a bare f64ToString(x)) is also accepted, and is the natural form when a file reaches for exactly one. Importing a type by its bare name is the opposite default (use std::collections::ArrayList;) — a type is used unqualified (new ArrayList<i32>()), so its own name is enough to place it.

Nested stdlib modules also answer to a flat short spelling: use std::math; is the same module as use std::numeric::math;. The full alias set: std::math, std::random, std::process, std::env, std::sysinfo, std::cli, std::log, std::regex, std::boxing, std::util, std::path.

User modules follow the same pattern with the crate:: root (use crate::config::SCREEN_W; → <src>/config.axle) — see Projects and dependencies.

An imported function can be renamed with as (use std::sys::exit as quit; — then call quit(0)). Glob imports (use std::collections::*) are not supported — list each imported name explicitly.

FFI

For talking to libc / OS APIs that the stdlib doesn’t already wrap :

@link(symbol = "write")
extern "C" fn libc_write(fd : i32, buf : ptr<i8>, n : i64) : i64;

fn writeRaw(fd : i32, buf : ptr<i8>, n : i64) : i64 {
    unsafe {
        return libc_write(fd, buf, n);
    }
}

The building blocks — extern "C" fn, @link(symbol = …), ptr<T> / ptr, extern "C" struct with sizeof/offsetof, thin function pointers for callbacks, and unsafe { } — are covered in the dedicated FFI page. static fn (class method with no self receiver) is the recommended home for FFI wrappers that conceptually belong to a class.

The same marker with a body goes the other way — this program defines the symbol and a C caller calls in, with --emit=header writing the .h. That direction is Exporting to C.

Performance hints

Two annotations attach to loops :

fn mix(state : i32, v : i32) : i32 { return state ^ v; }

fn main() : i32 {
    let n : i32 = 64;
    let count : i32 = 64;
    let a : i32[] = malloc<i32>(n);
    let b : i32[] = malloc<i32>(n);
    let out : i32[] = malloc<i32>(n);
    let xs : i32[] = malloc<i32>(count);
    let table : i32[] = malloc<i32>(n);
    let state : i32 = 0;
    let accum : i32 = 0;

    @vectorize
    for (i of 0..n) { out[i] = a[i] + b[i]; }

    @vectorize(width: 8)
    for (i of 0..n) { out[i] = a[i] * b[i]; }

    @vectorize(disable)
    for (i of 0..n) { state = mix(state, table[i]); }

    @unroll(4)
    for (i of 0..count) { accum = accum + xs[i]; }
    return accum + state;
}

See SIMD and auto-vectorisation for what each one maps to and how target-feature detection caps the permitted widths.


That’s the surface. The chapters below take each piece further:

See also

languagesyntaxtouroverview