Skip to main content

Xi language guide

A complete, copy-pasteable guide to writing Xi (Ξ) code. Xi compiles to C99 and then to a native binary. Fetch the latest copy with xi skill.

Golden rules

  • Every program has one async entry … main(args: String[]) { … } and a module App {} (the DI container; add binds inside it only to override defaults; the entry may also live inside the module block). entry always returns Integer, so -> Integer is optional and a body with no return exits 0; add -> Integer + return <code> for a non-zero exit.
  • There is no null. Absence is modeled with optionals (T? + if let) or results (T!). Never write null.
  • Statements are newline-separated; no semicolons. Blocks use { }.
  • Output: inject a Logger (import "std/log.xi") and call logger.info(...), or use system.stdout.writeln(...) directly.
  • String concatenation is +; scalars (Integer/Number/Bool) auto-coerce to text inside a + chain. int_to_string(n) also works. Strings can be triple-quoted """…""" (multiline, common indentation stripped) and, when prefixed with $, interpolated: $"Hi ${name}, ${count} left" (holes are any expression). A plain "…" is never interpolated, so ${…} stays literal.

Follow these by default - they make Xi code idiomatic and testable:

  • Model behavior as an interface + a class, and depend on the interface. Define an interface for each service/component and one (or more) class implementations; inject it by interface (deps { repo: PlaylistRepository }) so implementations are swappable and module Test { bind … } can substitute a double. Don't wrap everything in a class, though - entry main needs no class, and small pure helpers can be free functions (mapper/predicate).
  • Keep layers separated. Split a project into clear layers, each in its own file (and namespace): domain types, repository/data-access interfaces, service/business-logic interfaces, web handlers (WebRequestHandler), and a thin entry. Higher layers depend on lower-layer interfaces, never on concrete classes - let DI wire them.
  • Use modules to organize and build. Give each app a module App { id … } with includes/excludes; put several services in one repo as separate modules and build them all with xc --all (each → its own binary). See Multi-file.
  • Put settings in configuration, not in code. Describe config as an interface and bind … -> readConfig("app.yaml") (or generic readConfig<T>(...)), with a module Test config for tests; inject it like any dependency. Supports YAML/JSON/XML and hot-reload via ApplicationConfig. See Typed configuration / config.
  • Reuse dependencies - don't reinvent them. If a library already exists, add its archive URL to the module's dependencies and run xi install instead of writing it yourself. For example, use xi-sqlite for SQLite rather than hand-writing the binding:
    module App {
    id = "server"
    dependencies = ["https://github.com/code-by-sia/xi-sqlite/archive/refs/tags/v0.1.0.tar.gz"]
    }
    xi install fetches it into ./modules (auto-compiled in); call its functions by namespace. Write your own extern "C" binding only when no library exists.
  • Publishing a library: give it a namespace, add a library { id version includes } block (no entry/module - that block is inert when consumed), and run xi pack to build dist/<id>-<version>.tar.gz. Host it; others depend on the URL.

Hello, world

import "std/log.xi"

async entry (logger: Logger) main(args: String[]) -> Integer {
logger.info("Hello, world!")
return 0
}

module App {}

Compile & run: xc hello.xi (→ build/hello) or xi hello.xi (compile + run).

Comments

// line comment
/* block comment */

Values & variables

let n = 21 // immutable-style binding; reassignable with =
n = n + 1
let pi: Number = 3.14 // optional type annotation

Primitive types

TypeNotes
Integer64-bit signed (42, -7)
Numberdouble (3.14, 2.0)
Booltrue / false
StringUTF-8 text ("hi"); escapes \n \t \" \\
Chara Unicode scalar
Bytesraw binary buffer (see std/bytes)

Operators: + - * /, comparisons == != < > <= >=, logical and or not.

Compound types (structs)

type Person = { name: String, age: Integer }

let p = Person { name: "Ada", age: 36 } // construct
let who = p.name // field access

Refined types (constraints)

type Age = Number where value >= 0 and value <= 130
// constructing an out-of-range Age aborts at runtime (when checks are enabled)

Type aliases

type Name = String
type People = Person[] // plural/array alias

empty - the zero value

let p = empty Person // all fields zeroed
let xs = empty List<Integer> // empty collection

Optionals (no null)

type Row = { id: Integer }
mapper find(id: Integer) -> Row? { ... } // may return none

if let row = find(7) { // unwraps only if present
use(row)
}

Results & error handling

mapper checkAge(n: Number) -> Age! { // T! is a Result
if n < 0 { return err("age is negative") }
if n > 130 { return err("age too large") }
return ok(n)
}

mapper classify(n: Number) -> String! {
let a = checkAge(n)? // `?` propagates an err
if a < 18 { return ok("minor") }
return ok("adult")
}

let r = classify(25)
if isOk(r) { use(r.value) } else { handle(r.err) } // isOk/isErr, .value/.err

Functions - the eight kinds

Pick the kind by intent (purity is enforced for the pure kinds):

KindMeaning
mapper(T) -> U pure transform
projectorextracts/derives a field-like value
predicatereturns Bool
consumerside effects, returns nothing
producerproduces a value (often I/O)
reducer(Acc, T) -> Acc
creatorconstructs instances
actionimpure; may mutate; e.g. a web handler
mapper add(a: Integer, b: Integer) -> Integer { return a + b }
mapper square(x: Integer) -> Integer => x * x // inline body with =>
predicate isEven(n: Integer) { return n % 2 == 0 }
consumer greet(name: String) { system.stdout.writeln("hi " + name) }

where-guarded overloads (selected at runtime by the guard):

mapper fee(amount: Number) -> Number where amount > 100 => amount * 0.9
mapper fee(amount: Number) -> Number => amount

Infix functions - a 2-arg function marked infix is callable as a f b (and still as f(a, b)); left-associative, low precedence:

infix mapper plus(a: Integer, b: Integer) -> Integer { return a + b }
5 plus 3 // 8

Extension functions - qualify the name with a receiver type (primitive or user-defined) to add a method; this is the receiver. Chains like a method:

mapper Integer.double() -> Integer => this * 2 // 21.double() ... use (21).double()
type Person = { name: String, family: String }
mapper Person.fullName() -> String { return this.name + " " + this.family }

capture - EXPR capture name: Type binds a sub-expression's value (the type annotation is required) and still yields it; the name is usable for the rest of the function (works in functions, methods, entry - not inside a where guard):

let bigger = foo(10) capture a: Integer > bar(10) capture b: Integer // a,b reusable
if isActive(findUser(id) capture u: User) { use(u) } // u captured mid-call

Control flow

if cond { ... } else { ... }
if let x = maybe { ... } // optional unwrap

while cond { ... }
loop { ...; if done { break } } // infinite loop; break / continue

for item in items { ... } // arrays, List<T>, Set<T>
for i in 1..5 { ... } // ranges (see below)

scope { ... } // a plain block scope

match

match code {
200 -> { return "ok" }
404 -> "missing" // inline arm (returned)
("BA", "BD") -> "multi-key" // any of these keys
n -> "other " + n // binds n
else -> "default" // else / _ for default
}

Ranges

for i in 1..5 { } // 1 2 3 4 5 (inclusive)
for i in 0 until 5 { } // 0 1 2 3 4 (exclusive end)
for i in 10 downTo 1 { } // counts down
for i in 0..100 step 10 { } // custom stride
let r = 2..4 // ranges are values too

Collections (built-in generics)

Create with empty or a builder; no import needed.

// List<T> / Vec<T> - growable ordered array (same type, two names)
let xs = empty List<Integer>
xs.push(10) xs.get(0) xs.set(0, 9) xs.insert(1, 7) xs.swap(0, 2)
xs.len() xs.isEmpty() xs.removeAt(0) xs.clear()
for x in xs { ... }

// Stack<T> LIFO / Queue<T> FIFO / SortedQueue<T> priority (min-heap)
let st = empty Stack<Integer> st.push(1) st.peek() st.pop() // pop/peek abort if empty
let q = empty Queue<String> q.enqueue("a") q.peek() q.dequeue()
let pq = sortedQueueOf(5, 1, 3) pq.push(2) pq.peek() pq.pop() // pop returns the smallest

// Set<T> - unique elements (String compared by content)
let s = empty Set<String>
s.add("a") s.contains("a") s.remove("a") s.len() s.items() // items() -> List<T>
for e in s { ... }

// Map<K, V> - K is a primitive or String; V is any type
let m = empty Map<String, Integer>
m.put("ada", 36) m.get("ada") m.getOr("zz", 0) m.has("ada") m.remove("ada")
m.len() m.keys() m.values() // keys()/values() -> List
for k in m.keys() { let v = m.get(k) ... } // iterate via keys()

// Builders (types inferred from the first element)
let a = listOf(2, 3, 5) // also vecOf / stackOf / queueOf / sortedQueueOf
let b = setOf("x", "y", "x")
let c = mapOf("fr" to "Paris", "jp" to "Tokyo")

Note: plain arrays T[] use .len and .data[i] (e.g. args.len, args.data[0]); List<T> uses .len() and .get(i).

async / await

A free function marked async runs on a worker thread per call and returns a Future<T> immediately; await blocks for the result. await all joins a List<Future<T>> into a List<T>. A -> Future<T> return type (no async) means the function returns a future value it built (not auto-spawned).

async producer work(n: Integer) -> Integer { return n * n } // call -> Future<Integer>

let f = work(6) // worker starts; Future<Integer> now
let r = await f // 36 (blocks)
let all = await all listOf(work(2), work(3)) // List<Integer> = [4, 9]

// runWithDelay(ms){ } runs a block after a delay, returns an awaitable Future;
// it captures the enclosing params/deps (here `logger`) by value.
let d = runWithDelay(1000) { logger.info("yo") }
await d
  • async mapper/predicate still can't do I/O (purity) - use async producer/consumer.
  • async entry main and async methods stay synchronous (only free async fns spawn).
// scheduled job: deps auto-wired. Schedule is a 5-field cron "min hour dom
// month dow", or a fixed interval `every <N>ms`. Declaring one runs a scheduler
// that keeps the process alive.
scheduled (logger: Logger) greeter() cron "5 4 * * *" { logger.info("test!") }
scheduled (logger: Logger) poll() every 5000ms { logger.info("every 5s") }

Dependency injection

Depend on an interface; the compiler injects the implementor automatically (no registration needed when there's one implementor).

import "std/log.xi"

interface Greeter { mapper greet(name: String) -> String }

class FriendlyGreeter implements Greeter {
deps {} // this class's dependencies
mapper greet(name: String) -> String => "Hey " + name + "!"
}

class OrderService {
deps { greeter: Greeter, logger: Logger } // injected fields
consumer place(name: String) { logger.info(greeter.greet(name)) }
}

async entry (logger: Logger) main(args: String[]) -> Integer { // entry deps in ()
App.resolve(OrderService).place("Ada")
return 0
}

module App {} // add `bind Greeter -> FormalGreeter` here to override

Inside a class, call sibling methods unqualified - helper(x) or a recursive factorial(n - 1) - no this. prefix. They dispatch on this, including private helpers not in the interface and where-overloaded methods. Use field directly for injected deps/fields.

Function/method deps use the same (dep: I) form: consumer (logger: Logger) report(msg: String) { logger.info(msg) }.

Mutable class state. A class may hold instance data in a state { … } block, read/written via this.field. A singleton keeps its state across calls; transient/scoped start fresh. For shared, event-sourced state, prefer an atom.

class Counter implements Store {
deps {}
state { n: Integer = 0 }
consumer bump() { this.n = this.n + 1 }
projector count() -> Integer => this.n
}

Module-scoped constants. A module may declare const NAME: T = <expr> (compile-time value), referenced anywhere as Module.NAME:

module App {
const MAX: Integer = 50
const APP: String = "xi"
}
// App.MAX / App.APP - usable in free functions, class methods, other modules

Module metadata

The module block can also carry package metadata. id sets the compiled binary's name (otherwise it's the source filename). The block may be anonymous (module { ... }) or named (module App { ... }), and metadata can sit alongside binds.

module App {
id = "file-server" // -> binary named `file-server`
name = "File Server" // name/description/version/license = metadata
version = "0.12"
license = "Apache 2.0"
includes = ["./**"] // files that belong to this module (default)
excludes = ["scratch/**"] // ...minus these
dependencies = ["https://example.com/xi-sqlite-0.1.0.tar.gz"] // source archives

async entry (logger: Logger) main(args: String[]) { // entry can live inside
logger.info("up")
}
}

(The entry may also be top-level with a separate module App {} - both work.)

Multiple modules can share a folder (each owns its entry main + includes/ excludes); build one with xc file.xi or all with xc --all.

Dependencies: list .tar.gz/.zip source-archive URLs in dependencies, then xi install [file] downloads + extracts them into ./modules, which xc auto-gathers at build time (reference their functions by namespace - no import needed). A library dependency is plain Xi source with a namespace and no entry/module.

Logging (std/log)

Inject Logger; levels: debug info warn error fatal (warn/error/fatal → stderr, the rest → stdout), plus audit and unprefixed print.

logger.info("started")
logger.warn("low disk")
logger.error("request failed")

Sum / algebraic types

type Shape = | Circle { r: Number } | Square { side: Number } | Empty

mapper area(s: Shape) -> Number {
match s {
Circle c -> 3.14159 * c.r * c.r
Square q -> q.side * q.side
else -> 0.0
}
}

Sum types may be recursive - a variant's payload can be the enclosing type itself (auto-boxed) or hold it in a List<T>, so trees are ordinary values. They also serialize: tree as Json produces a tagged object ({"tag":"Add", ...}) and json as Expr reconstructs it.

type Expr = | Lit { value: Integer } | Add { left: Expr, right: Expr }
mapper eval(e: Expr) -> Integer {
match e { Lit l -> l.value Add a -> eval(a.left) + eval(a.right) }
}

Decision tables (decision kind)

decision quote(score: Number, base: Number) -> Number {
hit first
when score >= 700 => base * 0.9
when score >= 500 => base
else => base * 2
}

Interrupts (resumable conditions)

interrupt Over { x: Integer }

producer calc(n: Integer) interrupts Over {
if n > 100 { signal Over { x: n } recover { system.stdout.writeln("clamped") } }
system.stdout.writeln("done " + n)
}

// handler decides: recover (run the restart, continue) or skip (abandon)
try { calc(150) } catch e: Over { if e.x > 200 { skip } else { recover } }

Note: a catch/recover handler runs as an isolated frame - it can see globals (system.stdout) but not injected locals like logger.

Atoms & machines (brief)

An atom is an active-state store: a separate state type, an initial value, and transition reducers that take the current state s first and return a new state. Call a transition with just the extra args; read with .current.

state Cart = { items: Integer, total: Number }

atom cart {
initial Cart { items: 0, total: 0.0 }
transition addItem(s: Cart, price: Number) -> Cart {
return Cart { items: s.items + 1, total: s.total + price }
}
}
// cart.addItem(9.99) cart.current.items cart.undo() cart.canUndo()

A machine is a finite state machine value: states, the initial one, optional terminal -, and transitions name : From... -> To.

machine Door {
states Closed, Open, Locked
initial Closed
open : Closed -> Open
lock : Closed, Open -> Locked
}
// let d = Door.start(); d = d.open(); d.state; d.can(lock)
// an illegal transition signals IllegalTransition (handle with try/catch).

Standard library

import "std/<name>.xi": math, text, convert, bytes, json / yaml / xml (serialization), crypto, events, web, thread, io, fs, path, net, http, process, time, log. Namespaced calls, e.g. math.sqrt(2.0), text.toUpper("hi"), json.stringify(v).

Testing

Built-in. Write test "name" { assert <expr> }; run with xi test file.xi.

mapper add(a: Integer, b: Integer) -> Integer { return a + b }

test "addition" {
assert add(2, 3) == 5
}

test "uses a fake" (clock: Clock) { // deps injected; Test doubles below
assert clock.now() == 42
}

module Test { bind Clock -> FakeClock } // layered over App; ignored in normal builds
  • assert <expr> works anywhere: in a test a failure aborts just that test and the run continues; in normal code it prints file:line and aborts the process.
  • xi test exits nonzero if any test fails. test cases are excluded from normal xc builds. Put tests in *_test.xi files.
  • Value-showing helpers (prefer over bare assert a == b): assertEq(actual, expected), assertNe(a, b), assertClose(a, b, eps) (floats), assertOk(r) / assertErr(r) (a T! result). Add a message with assert cond : "why".
  • Run a subset: xi test file.xi --filter <substr> (matches the test name).

Web (REST) - typed extraction

Implement WebRequestHandler with where-overloaded handle. Route on method + a /users/:id pattern, then decode each request source into its own type:

type IdRef = { id: Integer } type NewUser = { name: String, email: String }

action handle(req: HttpRequest, res: HttpResponse) where web.route(req, "POST", "/users/:id") {
let ref = web.params(req) as IdRef // path params (web.query / web.headers too)
let body = web.body(req) as NewUser // JSON body
res.send(create(ref.id, body.name)) // res.send(dto) / res.sendStatus(code, msg)
}
  • web.route(req, method, pattern) -> Bool (guard; captures :params); web.params / web.query / web.headers / web.body each return a Json. Patterns may have multiple params: "/foo/:id/bar/:second", adjacent "/x/:a/:b", leading "/:a/foo/:b".
  • Serialization derives from a type's fields, both directions: <obj> as Json (compound -> Json tree) and <json> as T (any Json -> compound, lenient string coercion) - general, not web-specific; also json.parse(s) as T.
  • async entry main(...) { web.serve(8080) }. Capture works in the handler body.
  • web.shutdown() stops a running server (safe from another thread - e.g. runWithDelay(30000) { web.shutdown() } before web.serve self-terminates).

Query (std/query.xi / std/sql.xi)

A query chain does not execute where written - the compiler reifies it into a typed QueryPlan a provider interprets. import "std/query.xi".

let adults = query.from<User>("users") // root at a named source
.filter { it.age >= minAge and it.name.startsWith("A") } // minAge captured
.sortedBy { it.name }.take(10)
.collect(db) // -> List<User>, provider runs it

let picked = employees.asQuery() // ...or root at an in-memory List/T[]
.filter { it.active }.take(5).toList() // .toList() runs it locally
  • Stages: filter, map (projects; may build a record Type { f: expr }), sortedBy / sortedByDescending, take / drop, concat, join(other, { lk }, { rk }) (pair -> it.first/it.second), groupBy { key } (group -> it.key + count/sum/avg/min/max), then collect(provider) or toList(); .plan yields the plan value.
  • Lambdas are reified, not run: the it parameter becomes field refs; other names are evaluated now and embedded as bound values. Typos and unsupported methods/stages are compile-time errors.
  • Providers implement QueryProvider { run(plan) -> Json }. MemorySource is the built-in reference (load rows via its RowStore view; bind both as singleton for tests instead of a database).
  • Plans are data: sum types you can match, and plan as Json round-trips.
  • sqlRender(plan, dialect) (std/sql.xi) -> SqlStatement { text, params } with bound params. SqlDialect is an interface; SqliteDialect, PostgresDialect, MysqlDialect are bundled - dialect.name() is "sqlite"/"postgres"/"mysql". Add your own by implementing SqlDialect.

Typed configuration (std/config)

Describe config as an interface and bind it to a file; the compiler loads + deserializes it (primitives directly, compounds via codec; YAML or JSON):

import "std/config.xi"
type TaxConfig = { percent: Number, rate: Integer }
interface AppConfig {
mapper projectName() -> String // method name = top-level key
mapper tax() -> TaxConfig
}
module App { bind AppConfig -> readConfig("application.yaml") }
module Test { bind AppConfig -> readConfig("application-test.yaml") } // wins under `xi test`

Inject AppConfig like any dependency. A missing key yields the zero value. Or read one file into a value generically (JSON/YAML/XML by extension):

let tax = readConfig<Tax>("tax.yaml")

Hot-reload: inject ApplicationConfig, call cfg.watch("app.yaml", "config.changed"), run Events.runAsync(), and handle ConfigChanged in a listener.

C interop / porting a C library (extern "C")

Declare the C functions in an extern "C" block and add a build directive so xc links the library. The signatures are the binding.

import "std/ffi.xi"
extern "C" {
link "sqlite3" // -> -lsqlite3
// also: pkg "name", cflags "-I…", ldflags "-L…", include "<h.h>"
producer sqlite3_open(path: cstring, ppDb: &mut Ptr) -> Integer
producer sqlite3_close(db: Ptr) -> Integer
}

let db = empty Ptr // empty Ptr = NULL
sqlite3_open(toCString(":memory:"), &mut db) // &mut x = C out-param
  • Ptr = void* (opaque handle); cstring = const char*; &mut x = address-of (out-params); empty Ptr = null.
  • A Xi String is not a cstring - bridge with toCString(s) / fromCString(p) from std/ffi.
  • Declare externs or include a header for the same function, not both (conflicting C declarations). For a simple port, externs + link only.

A complete program

import "std/log.xi"
import "std/convert.xi"

type Item = { name: String, qty: Integer }

mapper totalQty(items: List<Item>) -> Integer {
let sum = 0
for it in items { sum = sum + it.qty }
return sum
}

async entry (logger: Logger) main(args: String[]) -> Integer {
let cart = empty List<Item>
cart.push(Item { name: "pen", qty: 2 })
cart.push(Item { name: "book", qty: 5 })

let prices = mapOf("pen" to 3, "book" to 9)
for it in cart {
logger.info(it.name + " x" + int_to_string(it.qty)
+ " @ " + int_to_string(prices.getOr(it.name, 0)))
}
logger.info("total qty = " + int_to_string(totalQty(cart)))
return 0
}

module App {}

Common mistakes to avoid

  • Forgetting module App {} at the end, or the async entry … main signature.
  • Using null - use T? + if let, or T! + ok/err.
  • Confusing array vs List access: arrays use .len / .data[i]; List uses .len() / .get(i).
  • Map.get(k) aborts on a missing key - guard with has or use getOr.
  • Using an injected logger inside an interrupt catch/recover block (use system.stdout there).
  • Adding semicolons (Xi uses newlines).