v0.7.5MITsix targetsno runtime

Functional programming that ships a binary, not a runtime.

Algebraic data types, exhaustive matching and an effect system the compiler checks, lowered through LLVM to a native executable with no VM, no collector and no libc inside it.

curl -fsSL https://raw.githubusercontent.com/chrispaig3/axiom/trunk/scripts/install.sh | bash
  • 0undefined symbols in a compiled program. nm -u says so.
  • 1 msseparates it from C and Rust on the same loop. Measured, not asserted.
  • 103,748lines of Axiom that compile Axiom — and rebuild themselves byte-for-byte.
shapes.axa whole program
(import IO)

(data Shape
  (Circle Int)
  (Square Int))

(:: area (-> Shape Int))

(fn (area s)
  (match s
    ((Circle r) (* 3 (* r r)))
    ((Square w) (* w w))
  )
)

(:: main Int)

;@axiom:effect(io)
(fn (main)
  (let (
    (c (area (Circle 4)))
    (q (area (Square 6)))
  )
    {
      (println "circle = {c}")
      (println "square = {q}")
      0
    }
  )
)
$ axiom run shapes.ax
compiling…
01

It compiles itself. Byte for byte, every time.

Not a rewrite in progress — the Rust implementation it replaced has been deleted. A clean checkout rebuilds the whole thing with nothing but llc and a C linker, and checks the fixpoint on the way, every time — scripts/bootstrap-from-seed.sh.

bootstrap/*.llcommitted IRseedllc + ccstage1built by the seedstage2built by stage1stage3built by stage2must be byte-identicalor the build refuses to hand you a binary
103,748lines of Axiom in the compiler that compiles Axiomcat self_host/*.ax | wc -l
642.ax files in the tree, every one parsed by the grammar gategit ls-files '*.ax' | wc -l
80diagnostic codes, each with a written explanationaxiom explain --list
82gate scripts in the battery that runs before a pushls scripts/check-*.sh | wc -l
02

What it actually looks like.

Six complete programs, each one compiled and run to produce the output shown under it. They walk from the thing every language has — types and matching — to the things only this one does: checked effects, a region per row, and a join across processes.

(import IO)

(import Err)

(data Parcel
  (Ordered)
  (InTransit { carrier : String, days : Int })
  (Held { why : String })
  (Delivered))

(:: status (-> Parcel String))

(fn (status p)
  (match p
    ((Ordered) "packing")
    ((InTransit carrier days) (format "{carrier}, {days} days out"))
    ((Held why) (format "held at {why}"))
    ((Delivered) "delivered")
  )
)

(:: alert (-> Parcel (Option String)))

(fn (alert p)
  (match p
    ((Ordered) (Some "not shipped yet"))
    ((InTransit _ _) None)
    ((Held why) (Some (format "call about {why}")))
    ((Delivered) None)
  )
)

(:: row (-> String Parcel Int))

;@axiom:effect(io)
(fn (row id p)
  (let (
    (s (status p))
    (a (optUnwrapOr (alert p) "-"))
  )
    (println "{id:<9}{s:<22}{a}")
  )
)

(:: main Int)

;@axiom:effect(io)
(fn (main)
  {
    (println "parcel   status                action")
    (row "AX-1041" Ordered)
    (row "AX-1042" (InTransit "DHL" 2))
    (row "AX-1043" (Held "customs"))
    (row "AX-1044" Delivered)
    0
  }
)
$ axiom run parcels.ax
compiling…
Shipment status report. Add a fifth state and the compiler names both places it belongs. Pattern Matching
03

Same loop. Same machine. Same speed.

Axiom lowers to LLVM IR and hands it to llc, which is the backend Rust and clang use. On work that is just arithmetic and branches, that means the machine code is the machine code — and the measurement says so.

Run time, compile time, binary size and undefined symbol count for the same Collatz workload in Axiom, Rust and C.
MeasurementAxiomRustC
Run time3,000,000 Collatz sequences · best of 20, interleaved0.446 s0.447 s0.446 s
Compile to a native binaryone file, cold · best of 15, interleaved0.140 s0.111 s0.132 s
Binary sizethe executable on disk35,560 B469,592 B33,432 B
Undefined symbolsnm -u <binary> | wc -l0701
  1. Run timeWithin one millisecond across all three. Axiom emits LLVM IR, so a loop that is only arithmetic and branches gets the machine code the other two get.
  2. Compile to a native binaryAxiom is the slowest of the three, by twenty-nine milliseconds against rustc — 1.26x — and eight behind clang at 1.06x. Published because it is what was measured, by the script beside the sources.
  3. Binary sizeThirteen times smaller than the Rust binary, and within seven percent of C — with no C runtime inside it at all.
  4. Undefined symbolsThe whole program is in the file. Nothing is resolved at load time, because there is nothing left to resolve.

How it was measured

Collatz step counts for 1..3,000,000, summed and printed. Signed 64-bit integers, no allocation, no library call in the hot loop. All three binaries print 428343467. Each figure is the best of its runs, not the mean: interference only ever makes a run slower, so the minimum is the closest estimate of the cost itself — the methodology the repository uses for its own benchmarks.

The runs are also interleaved — one repetition of each binary, in turn — and that correction changed the answer. A first pass that ran each binary in a block put Axiom 1.6× behind Rust, which would have been a finding if it were real. It was an artefact: a background build starting midway through taxes whichever block it lands on. Alternating gives every binary the same interference, and the three collapse onto each other.

The three programs are in the repository — web/bench/ — and run-bench.sh beside them produces every cell of this table, so it can be re-run rather than taken on trust.

Apple M1, macOS 26.6.2, darwin-aarch64 · Axiom 0.7.5 · rustc 1.98.1 · clang 23.1.0. Go and Haskell are absent on purpose: no toolchain for either was on the machine, and this project does not publish a number it has not measured. One micro-benchmark is one micro-benchmark — it says nothing about allocation-heavy work, which scripts/bench-datastructures.sh measures separately, and less flatteringly.

the three commands
axiom build --input collatz.ax --output out-axiom
rustc -O collatz.rs -o out-rust
clang -O2 collatz.c -o out-c
04

Four decisions that set it apart.

Design, not benchmarks — the numbers are one section up. Every Axiom cell here is held by something in the repository; the other columns are restricted to facts nobody disputes.

How memory is managed

A bump allocator over mmap, with explicit arena reclamation where peak memory matters. No tracing collector, and no borrow checker to satisfy.

  • Rustownership and borrowing
  • Gotracing GC
  • Haskelltracing GC

Whether effects are tracked

Inferred per function, and checkable from source. ;@axiom:effect(io) above a declaration is a claim the compiler tests against what the body actually reaches — and a false one is an error.

  • Rustnot tracked
  • Gonot tracked
  • Haskelltracked, written by hand

What a macro operates on

The program tree itself, because the syntax is already a tree. Expansion runs before the type checker, so everything a macro generates is checked like anything else.

  • Rusttoken streams
  • Gono macros
  • HaskellTemplate Haskell

What it takes to build and run

axiom run f.ax. One step, one binary, no build file. A dependency is a path on your machine — there is no registry to configure and no lockfile to resolve.

  • Rustcargo and crates.io
  • Gogo build and modules
  • Haskellcabal or stack

Axiom is 0.x. The feature-by-feature status table — what is complete, what is partial, what was removed, each with the test that holds it — is in docs/status.md, and nothing on this page is a promise that table does not make.

05

Three kinds of work it was built for.

Axiom is small on purpose, and the places that pays off are the ones where a runtime you did not write is a liability.

Systems programming

Ship a binary, not a runtime.

Your program is the whole artifact. The allocator is emitted into your executable, I/O is the syscall, and nm -u on the result lists zero undefined symbols — nothing to initialise at start-up, because there is nothing left to resolve.

  • Six targets, one flag. --target picks the syscall ABI and emits code for any of the six from any host; only the final link needs that target's linker.
  • Arenas where peak memory matters. Reclamation is explicit and specified rule by rule, not inferred and hoped for.
  • Rust when you want it. An extern block names symbols in a static archive; one flag builds the crate on the far side.
Read the memory model
Security-sensitive code

Say what a function may do. Have it checked.

;@axiom:restrict(no-io,no-alloc,no-foreign) is not a comment. It is a claim the compiler tests against the effect row and the call graph — and when it fails, the message names the exact path of calls to where the effect enters.

  • Silence is a checked claim. A function that performs I/O and does not declare it is an error, not a lint you can turn off.
  • No load-time surface. nm -u is empty, so there is no symbol for a dynamic linker to bind, and nothing to redirect.
  • Exploits first, codes second. A traversable module path and an injectable linker name were each measured working before they became a diagnostic and a fixture.
Read the diagnostics guide
Agent-written code

Answer the machine in its own format.

Most toolchains publish their failures and keep their successes to themselves. Axiom publishes both — one line per diagnostic and one line per symbol — so an agent can ask what a file already provides without paying to read it again.

  • Fixes arrive applicable. A span and a replacement, applied by byte-range substitution rather than by parsing English.
  • Addresses that survive an edit. Every function, type and struct carries a content-derived id that does not move when the file is reordered or reformatted.
  • One syntactic form. No operator precedence, no parsing edge cases — the thing generating the code has less to get wrong.
Read the agent harness

One thing Axiom is not built for, said here rather than discovered later: concurrency is one narrow form and no runtime. parallel runs its bindings beside the caller and joins them in the order written — as child processes by default, whose isolation is true by construction, or as threads under --threads. Only a machine word crosses a join, and a binding that captures a reference the parent holds is refused under either lowering. There is no async, no scheduler and no task system; stdlib/Par.ax is a bounded pool over the same primitives, and it is the rest of the story.

06

Two audiences. One set of facts.

One structured diagnostic is built at every stage that can refuse, and the renderers never see anything the compiler did not already know. So the report you read and the line your tooling parses can never disagree about what went wrong.

Why did this fail?

count.axrefused
(:: main Int)
(fn (main)
  (let ((x 0))
    {
      (set x 1)
      x
    }))
error[AX3012]: cannot assign to immutable binding `x`
 --> count.ax:5:12
  |
3 |   (let ((x 0))
  |          - `x` is bound here
...
5 |       (set x 1)
  |            ^ `x` cannot be assigned
  |
  = help: declare it mutable: `(mut x ...)` ~> mut x
  = help: only a binding introduced by `(let ((mut x ...)) ...)` may be the target of `set`
  = help: run `axiom explain AX3012` for a full explanation

compilation failed due to 1 previous error
The human report quotes both spans and elides the lines between them, labels the binding site as well as the offence, and ends with a fix rather than a restatement. Columns count characters, not bytes, so a caret under a line containing an em dash lands where the eye expects.

What does this file already provide?

The other question a tool asks is not about failure at all. axiom symbols runs the same pipeline as check and prints one line per symbol — kind, name, location, exact type. An agent greps ^D Maybe for the constructor set instead of re-parsing the file.

Fn       add                  (Int -> (Int -> Int))                    [main.ax:9:5-8]
Data     Option               data Option                              [builtin]
Ctor     Some                 (a -> Option a)                          [builtin]
Ctor     None                 Option a                                 [builtin]
Data     Vec                  data Vec                                 [builtin]
Data     Maybe                data Maybe                               [main.ax:1:7-12]
Ctor     Nothing              Maybe a                                  [main.ax:2:4-11]
Ctor     Just                 (a -> Maybe a)                           [main.ax:3:4-8]
Struct   Point                struct Point                             [main.ax:5:9-14]
The @27bcb2… is a content-derived id that does not move when the declaration is reordered, reformatted, or read from another path — identity is the id, and the rest of the row is the contract.

Every fact, in 193 bytes — and the fix comes with it

The exact span, the kind of error, the primary label, the message, and a replacement a tool applies as a byte-range substitution instead of parsing English.

Both blocks are real compiler output. This line is the golden of one diagnostics fixture, re-rendered against the live compiler on every run — output nobody could reproduce would fail the build. diagnostics.md has the grammar; axiom explain --list names all 80 codes.

main.axa typo on line 6
(:: helper (-> Int Int))
(fn (helper x) (+ x 1))

(:: main Int)
(fn (main)
  (helpr 5))
E AX3001 main.ax:6:4-9 undefined-variable "undefined variable `helpr`" #"no binding named `helpr` in scope" ?6:4-9:"a similarly named binding `helper` is in scope; did you mean this?"~>"helper"

The toolchain is one binary.

No build system to configure, no formatter to choose, no test runner to add, no language server to install separately. Eleven subcommands, one download, and the same compiler behind all of them.

  • axiom build
  • axiom check
  • axiom run
  • axiom test
  • axiom emit-llvm
  • axiom fmt
  • axiom explain
  • axiom symbols
  • axiom repl
  • axiom lsp
  • axiom version
axiom explain
Every one of the 80 diagnostic codes has a full written explanation behind it — and where a fix is machine-applicable, it travels with the error as a span and a replacement.
axiom lsp
Written in Axiom like the rest of the compiler, and answers 23 requests — including a distinction most languages do not have: a function is written twice, so declaration lands on the signature and definition on the body.
tree-sitter-axiom
All of the highlighting, plus rainbow-bracket queries. One query file serves Neovim, Helix and the tree-sitter CLI — and it is parsed against all 642 .ax files in the repository on every change.
  • Neovim
  • Helix
  • Emacs 29+
  • Zed
  • VS Code (server only)

docs/lsp.md has a configuration for Neovim, Helix, Emacs and VS Code; Zed needs only the grammar. The server and the grammar are independent: the server colours nothing, so a buffer with it attached and no grammar installed is plain text.

07

Two prerequisites, then one command.

You need llc from LLVM and a C compiler for the final link. That is the whole list — Axiom's compiler is written in Axiom, so there is no other toolchain to install first. The installer is at the top of this page; if you would rather build from source, that is what a contributor does.

Build from source

bootstrap/ holds the compiler's own LLVM IR, one file per host it can build on, committed — so this needs nothing but the two prerequisites above.

git clone https://github.com/chrispaig3/Axiom && cd Axiom
./scripts/bootstrap-from-seed.sh --install .axiom-bin

Write something

IO is Axiom's own standard library, so the compiled binary calls no C function at all — and the ;@axiom:effect(io) line is checked, not decorative.

hello.axHello, Axiom!
(import IO)

(:: main Int)

;@axiom:effect(io)
(fn (main)
  {
    (println "Hello, Axiom!")
    0
  }
)

The commands you will use

axiom run f.ax
compile and execute in one step
axiom build --input f.ax --output f
a native binary that depends on nothing
axiom check f.ax
type-check, no code generation
axiom test tests/
run every test in a file or a directory
axiom explain AX3001
the full explanation behind any code

Try it in the next minute.

llc and a C compiler are the only prerequisites. The installer verifies the archive, builds and runs a program that imports the standard library, and only then reports success.

curl -fsSL https://raw.githubusercontent.com/chrispaig3/axiom/trunk/scripts/install.sh | bash