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:
- Primitive types and Variables — the values you start from.
- Control flow and Operators — how you compute with them.
- Structs, classes, traits, enums — how you model data and behaviour.
- Exceptions and Deferred cleanup — how you handle failure.
- 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
asis rejected at compile time — it is lossy, so its rounding must be named. Usex.round()(nearest),x.truncToInt()(toward zero),x.floorToInt()orx.ceilToInt()instead; each returns ani64and saturates an out-of-range value (NaNmaps to0). - Division by zero is an error, never an
Inf/NaNproducer. A literal zero divisor — integerx / 0or floatx / 0.0alike — 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:
struct | class | |
|---|---|---|
Assignment (let b = a) | copies the data | shares the object |
| Identity | none — equal fields are equal | each new is a distinct object |
| Methods / constructor | no | yes |
| Inheritance / traits | no | yes |
| Created with | a { … } literal | new T(…) |
| Reach for it when | small plain data | behaviour, 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:
| marker | who 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 |
pub | everywhere, 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 shape | Error |
|---|---|
| A parameter with no type and no context to infer it from | E0730 |
| Capturing an owning / non-scalar value | E0731 |
| Writing to a captured binding — the capture is a copy, so the write would not be visible outside | E0733 |
A capturing closure flowing into spawn, or held live across await | E0734 |
async (params) => body, a lambda referencing self, or a nested capturing lambda | E0700 |
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:
| Way | Written as | Meaning |
|---|---|---|
| Handle inline | divide(a, b) catch e { … } | run a recovery block right at the call |
| Propagate | divide(a, b)? | re-throw to the caller — the ! set of this fn must already list the error |
| Handle in a block | try { … } 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::f64ToStringreaches a free function the module exports. Astatic fnon a class is reached through the class instead:Account::open("alice"), notaccount::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 instd::numeric::math(math::pi(),math::atan2(y, x)).
Which form to reach for is a two-line rule:
| You want… | Import | Then call |
|---|---|---|
| a type (class / trait / exception) | the name — use std::collections::ArrayList; | new ArrayList<i32>() |
| a module’s free functions | the 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
- Pattern matching —
matchand every pattern kind in depth, with the exhaustiveness rule. - Enums — variants, backing types, payloads, methods, and trait implementation.
- Generics and traits — generic classes,
bounds,
match type T, and@derive. - Conventions — idiomatic Axle: naming, file layout, when to reach for which ownership mode.
- Memory model — what
new T(...)actually does, the four storage tiers, anddefer. Shared<T>reference counting — the one opt-in memory model and its lifecycle rules.- SIMD and auto-vectorisation — vector types,
@vectorize,@unroll. - FFI — calling C from Axle —
extern "C" fn,@link(symbol = …),ptr<T>,unsafe { … }. - Annotations reference — every
@…form. - Reading compiler errors — the diagnostic format and error-code catalogue.
- Recipes — patterns by task.
- Compiler internals — what the compiler does behind the scenes.
- Concept index — every concept on one page.