Skip to content

Best Practices & Idioms

When writing Axon, follow the conventions established by the standard library. These rules make your code easier for both humans and AI agents to read, write, and modify.

  • Lowercase with hyphens for module names and multi-word identifiers: axon-std, hash-map, http-server. Underscores are reserved for FFI symbols that need to match C headers.
  • Lowercase single-word for hot-path primitives: len, push, pop. The short name is the contract — every collection has a len.
  • Public functions use pub. Internal helpers omit pub. The compiler enforces visibility.
  • Module names match file names. (import foo "std/foo.axs") looks up std/foo.axs — never alias.
  • One binding per line in (let ...) and (struct ...). Multi-line bindings go on their own indented lines for readability:

    (let (
    (count i64 0)
    (sum i64 0)
    (max i64 0)
    )
    (block ...))
  • No trailing commas, no semicolons. S-expressions are uniform — (a b c) is the only form.

  • 2-space indentation. Three or more spaces is a parser error in the formatter (CI enforces).

  • Blank lines between top-level decls in modules. The formatter adds them automatically.

  • Function calls start on their own line for multi-argument calls — arg alignment is enforced by axonc format.

  • Always annotate types. The compiler will infer locals, but explicit annotations are mandatory for function signatures. AI agents that produce inferred-only signatures hallucinate types under refactoring pressure.
  • Use the narrowest integer type that fits. i8 for byte values, i16 for char codes, i32 for most counters, i64 for sizes and addresses. Wider than i64 requires a bigint wrapper.
  • Prefer bool to i64 for true/false values. The compiler can omit dead branches when it knows a value is boolean.
  • Use string for text, (ptr u8) for raw bytes. Mixing them is a type error.
  • Return (Result T E), not (T, i64). Errors-as-data is enforced at the type level.

  • (Option T) for “may not exist”. Don’t use sentinel values like -1 for “not found”.

  • Pattern-match on both branches:

    (match (call map_get m key)
    ((some val) (call process val))
    ((none) (call print_str (str "not found"))))
  • Never use panic for expected errors. (axon_panic ...) is for unrecoverable invariant violations only.

  • Channels first, mutexes second. If two threads need to share state, use a channel. Mutexes are a code smell — they indicate that the data should probably be an actor.
  • Yield in hot loops. Cooperative green threads only switch at await, chan_recv, evloop_wait. If your hot loop never yields, it starves every other task.
  • One owner per resource. The compiler enforces this with (ref 'a T) and (move T) semantics. Trying to share a Vec<T> between two actors is a compile error.
  • Profile first, optimise second. Every axonc --emit-llvm program.axs -o program.ll gives you readable LLVM IR — read it before guessing.
  • Prefer stack allocation. (let (x i64 0)) allocates on the stack; (alloc i64) allocates on the heap. Use heap allocation only when the lifetime outlives the current scope.
  • Pre-allocate collections. (let (v (vec_with_cap T (i64 1024)))) reserves capacity up front. Resizing a Vec is O(n) and can fragment the allocator.
  • Avoid string concatenation in loops. Each str_concat allocates a fresh buffer; for N concatenations that’s O(N²) total. Build the result with a single allocation and use str_from_buf instead.
  • Mark hot functions @inline. The compiler inlines aggressively by default, but explicit @inline annotations give you control when the heuristic guesses wrong.
  • Golden-file tests are the default. Write tests/foo.axs plus tests/foo.expected. The test:axon-e2e job diffs stdout against the expected file. No test framework required.
  • One assertion per test function. When a test fails, you want to know which invariant broke, not “something in this 200-line function”.
  • Contract tests for stdlib. Use axon_assert_eq_i64 / axon_assert_ne_i64 / axon_assert_true to verify behaviour — these print PASS lines that the CI parser picks up.
  • Don’t test the compiler. If you want to assert that axonc rejects a program, the existing tests/errors/ directory is the right home. Application-level tests go in tests/.
  • One concern per module. std/string.axs does string operations, not file I/O. Cross-concern code goes in std/io.axs or a higher-level module.
  • Module names match file paths. (import http "std/http.axs") is the convention; the import path is a literal file path.
  • Forward declarations are free. (extern foo ((_ i64)) void) doesn’t require foo to exist at parse time. Use this to break circular dependencies between two modules that reference each other.
  • pub only on the public API. Internal helpers stay private. The formatter will warn on pub use that isn’t reachable from another module.
  • (set x x) for side effects. If you want side effects, write (call some_function); don’t pretend to “compute” via self-assignment.
  • (if cond (return x) (return y)). Use (if cond x y) directly. The return form obscures the data flow.
  • String comparison via str_eq == 0. Use string.eq from the stdlib. The equality check is (ne (call str_eq a b) (i64 0)) — but string.eq returns a bool directly.
  • Float equality. (eq x y) with f64 values is almost always wrong. Use (abs (sub x y)) < epsilon or compare against a known tolerance.
  • Long chains of (if (cond1) (if (cond2) ...)). Use (match ...) or extract a helper. Nested if is the wrong abstraction.

Read std/*.axs. The standard library is the canonical example of idiomatic Axon. If your code looks like the stdlib, you’re doing it right.