Xo Language Specification (Core)
Status: draft 0.10 (2026-10-10; 0.9 released as toolchain v0.9.0, decision 0089)
Edition: 2026
Companion: spec/std.md (prelude methods and standard library surface)
Xo is a small, statically typed, garbage collected language for backends and web services. It keeps what makes Go productive (few keywords, one obvious way, fast builds, static binaries, a strong standard library, a compatibility promise) and is designed so that an AI agent can write, check, build, and debug programs for any OS with fast, structured, unambiguous feedback.
Changes in 0.10
- Assignment order (5.2, decision 0140): an assignment computes its value before it reads the binding it stores into, so changes the value makes to that binding are kept (the native backends dropped them).
for b in bytes(3.5, decision 0138):Bytesis iterable and yields each byte as aUInt8.while let(3.5, decision 0137):while let Some(v) = expr { }loops while a refutable pattern matches, andwhile let v = optis the Option shorthand, as forif let.- Real-time functions (6.5, decision 0133):
uses realtimeon a function declaration forbids allocation (XO1401), loops without a bound (XO1402), calls of functions that are not real-time, function values, and interface methods (XO1403), recursion (XO1404), and blocking (XO1405). Native builds defer the frees of a real-time section to its end and fault withXO-F007when it allocated. - Loop bounds (3.5):
for x in xs bound N { }andwhile cond bound N { }, a contract on any loop: a constant range longer than N isXO1402; otherwise starting iteration N + 1 faults withXO-F006.boundis a contextual word.
Changes in 0.9
Meaning changes: std/http over TLS speaks HTTP/2 when both sides offer
it, on every backend (below), and
one variable passed to two var parameters of a call is an error
(XO0503, 5.2, decision 0111; the Go backend shared it, the native
backends reported XO1201). A value that holds a handle (Mutex,
Chan, Task, Conn, …) or a function value is not comparable: ==,
Map keys, Set elements, and derive Eq/Hash on it are XO0404
on every backend (2.9, decision 0112; the Go backend compared the
handles, the native backends reported XO1201). Native support grew
(--no-gc, 10.1):
- Interface types as values (2.4) build natively (decision 0100).
- Newtypes (2.8, decision 0097: construction,
e.0,Email(p), Debug text, equality, Map keys) and list patterns (3.5) build natively. - Float32,
Secret[T],Buf[T], order ofderive Ordtypes, and the std value methods listed in decision 0101 build natively. std/path,task.semaphore,task.once, andtask.map_concurrentbuild natively (std.md 7, 9).- Every Fs method and attenuation,
Stdio.write, and standard input reads (7.2, 7.5: canceled by a cancellation or a stop request) build natively (decision 0102). - Error frames (11.1) are recorded natively and printed in main’s error report (decision 0104).
std/timebuilds natively (std.md 13, decision 0094), with the same per use zone data and output as the Go backend; so doDuration.from_secs,scale,ceil_seconds, andTime.parse_rfc3339(2.6).- std/http serves and calls HTTPS on every backend (std.md 4.6, decisions
0105 and 0106):
http.tls_cert,Server.with_tls,Client.with_roots, andTlsErrare new; the native backends speak TLS with Mbed TLS, so anhttpsURL is no longerXO1201there. Over TLS, servers and clients speak HTTP/2 when ALPN agrees onh2(decision 0121; the Go backend’s server spoke HTTP/1.1 only before, its client already used HTTP/2); plainhttpstays HTTP/1.1. use c "<header>" [link "<lib>"]... [as name] [uses ...]calls C from native builds (4.5, decisions 0093 and 0116): bindings generated from the header (xo bind --c), scalars, Str, Bytes, flat structs, and opaque handles at the boundary, the effectffi(6.1) unless the line narrows it,XO1104andXO1105for headers and names that do not bind, andXO1201on the Go backend.
Changes in 0.8
From the spec audit of 2026-10-09 (bench/results/spec-audit-2026-10-09.md):
no meaning changes, the spec now says what is not implemented yet.
Method syntax stays receivers (decision 0090), Map keeps insertion
order (0091), error frames are lazy and always on (0092), and foreign
calls will be recorded at the boundary (0093); std/time is accepted as
specified in std.md 13 (decision 0094).
xo build --ciexists: it fails before building on an unformatted file or adbgleft in code (10.1, 10.5, 11.4).- 10.6 lists every diagnostic and fault code with its severity, kept equal
to the toolchain’s registry by a test.
XO1001is reserved. --jsonis accepted by the commands that report results, not by every command;xo testtakes--cases=N, not--fuzz(10.1).- [later] markers for what is specified but not built:
xo why,xo debug, the task line of error traces, spans and OpenTelemetry, SBOMs,std/sys,OpenApiandProtoderives. - Modules (4.2, 4.3; decision 0086 steps 1 and 2):
require <path> <version>of Xo modules with minimal version selection, git fetch into a read only module cache,xo.lockhashes verified by every command,xo get,xo mod tidy,xo vendor,--offline, andxo.workworkspaces (--workspace=off). New codesXO1301toXO1308(10.6). An import from a module that is not required isXO1306. - From the Codex r2 rerun (
bench/results/codex-xo-r2-2026-10-09.md): a closure whose body never completes has result typeNeverwhen nothing else determines it (3.3);x.display()is callable on everyDisplaytype, derived and std errors included (std.md 1); severalmatcharms on one line keep parsing, with a fix that splits them (XO0175). - Effect ceilings and checked semver (4.2; decision 0086 steps 3 and 4):
require <path> <version> uses none(oruses net, fs.read) in xo.mod anduse <dir> uses ...in xo.work bound what a dependency’s functions may declare; a call above the ceiling isXO1309at the call site.xo getprints the API and effect changes of every version change (-uupgrades every requirement).xo publish [--check]compares the exports and effects with the previous tag and rejects a breaking minor or patch release (XO1310,XO1311); it never tags or pushes.xo initwrites xo.mod and gitignores xo.work. Module paths ending in/vN(N >= 2) are major version N of the same repository; the suffix is skipped for the local name (4.1). std/time(std.md 13, decision 0094) is implemented on the Go backend: zones with per use embedded data (--tzdata=systemreads the host database), civil dates and times with explicit daylight saving gaps and overlaps, and layouts with named fields. A constant layout is checked at compile time:XO0420for an unknown field (10.6),XO0177for date patterns of other languages. Native backends report it asXO1201(planned).- Import direction rules (4.2, 10.3; decision 0095):
deny <pkg> -> <pkg>, ...lines in xo.mod, with/...patterns; a forbiddenuseis the new errorXO1312at that line, a rule naming no package isXO1301.xo query importsandxo query depslist theuselines. std/timeerrors display as sentences (std.md 13.5):ZoneErrandCivilErrno longer deriveDisplay.- Newtypes (2.8; decision 0097):
e.0reads (and on avarassigns) the underlying value, and the patternEmail(p)matches it, irrefutably whenpis (let Email(s) = e). Go backend; native builds reportXO1201as for every newtype (native support added in 0.9). xo check --fix(10.1, 10.2; decision 0096) applies every unambiguous machine fix, reformats the changed files, and reports what remains with the list of applied fixes; plainxo checkstays read only. The MCPchecktool takesfix.- From the second pass of the spec audit:
x.debug()is callable on every value, as std.md 1 says every type implementsDebug(it wasXO0601on concrete types); reproducible builds are stated for the same source directory (12.1), which a test now checks. - Modules in repository subdirectories (4.2; decision 0086): the module
github.com/acme/tools/cliincli/with tagscli/vX.Y.Z; fetch, version selection, xo.lock, vendoring,xo get, andxo publishhandle the prefix. Nested modules are left out of the module around them.XO1311now also reports a subdirectory module whose path does not end with its directory. - Go calls are recorded (11.3; decision 0093 stage 1), replacing “Go calls
are not recorded”: calls with boundary values only are answered from
the trace by
xo replay; calls with a handle or a callback run again and are reported (re executed foreign call `<name>`); pure functions arereplay rerun(std’s pure packages, and xo.mod linesreplay rerun go <path> [names], 4.4). Old traces still replay. - Release builds (8, decision 0099):
xo build --releasecompiles outrequires(debug)andensures(debug)on every backend and fails while a workspace overrides a required module (XO1308, formerly reported only by--ci).--cidoes not imply--release;xo run,xo test, and plainxo buildare debug builds. - Error frames are lazy (11.1, decision 0092, Go backend): a frame keeps
copies of the parameter values and formats them, at most 64 bytes'
worth, only when the trace is printed; output is unchanged. About 10
times cheaper per propagated error with short parameters, 29 times
with 90 byte ones, and independent of parameter size. Frames are on in
release builds;
--release --error-args=falsedrops the parameter values ((...)). - Module proxies, checksum log, definition hashes (4.2, 4.3, 10.3;
decisions 0124 to 0126, completing 0086):
XO_PROXYandXO_NOPROXYwith a read only proxy protocol andxo mod proxy serve;XO_SUMDBandXO_NOSUMDBcheck every first fetch against a signed Merkle log (newXO1313,XO1314;XO1302now also covers proxies);xo query hashand the definitions list ofxo publish.
Changes in 0.7
Decisions 0041 (Go packages) and 0042 (two phase shutdown).
Request scoped arenas are on by default for HTTP handlers (5.5; decision 0066): an implementation detail with no observable effect, off with
--no-arenas.Go packages with
use go(4.4).Two phase shutdown (7.5).
Mutex.with_read, shared read only access (5.4, std.md 2.7; decision 0063). A closure parameter declaredvarwhere the expected function type takes it by value is errorXO0401(it changed a copy only).BytesBuilder, the move only builder ofBytes(5.3, std.md 2.7; decision 0078).Warning
XO0305is not reported for a_arm that hides at least 8 variants and more than half of the enum (3.5; decision 0080).A rune literal where an integer type is expected is its code point (1.6);
Int(r),Rune(n), andRune.try(n)convert betweenRuneand the integer types (2.1; decision 0079).xo build --hybridcompiles hot leaf loops to native assembly inside Go backend builds (10.1; decision 0084).The native backends check
ensuresand struct invariants and unwind faults (deferred code, scopes, Mutexes,task.isolate, a 500 from an HTTP handler) as the Go backend does (3.5, 8, 9.1; decision 0085).Habit diagnostics: error
XO0177(syntax from another language), and habit notes and fixes on existing codes, for Go, Rust, TypeScript, Python, Swift, and Kotlin forms (10.2; decision 0087). No program that was accepted is rejected.clock.sleep_or_cancel(d)returns cancellation asClockErr.Canceledinstead of unwinding; reading standard input is a cancellation point, and a stop request ends it too (7.2, 7.5; decision 0088).
Changes in 0.6
Driven by the independent replant of planted safety bugs
(bench/results/planted-xo-2026-10-07.md). Decision 0038.
- Reading a
Mapby index, or changing an element in place through it, is errorXO0410; useget,get_or_fault, orupdate.m[k] = vstays (3.6). - Warning
XO0305: a_arm hides variants of an enum declared in the same module (3.5). - Warning
XO0804: a value read in oneMutex.withis used, or acted on, in a laterwithon the same mutex (5.4). - Regression tests for shrunk contract fuzz failures go in the module’s
regressions_test.xo, nottestdata/regressions/(9.2). - Go packages with
use go(4.4), with thegoeffect. for x in chover channels (3.5, 7.3);task.Onceandtask.map_concurrent_untilin std.- Two phase shutdown: the first signal requests a stop (
task.stop_requested,task.stopping), cancellation follows after a grace period (7.5). - Std:
Map.get_or_fault, hex and base64 onBytes,Time.from_unix_millis,Atomic.max,Range.to_list,Patch.apply_to/apply_opt, andstd/path.
Changes in 0.5
Driven by the stage 2 benchmark (bench/results/stage2-xo-check-2026-10-07.md).
Decision 0037.
- XO0520: a mutated copy of a collection element must be stored again; reading it afterwards no longer counts (5.2).
ismay be combined with&&and||(3.5, 15).- Each arm of a tail
iformatchis in return position (2.6). testingis usable in helper signatures in_test.xofiles (9.2).
Changes in 0.4
Driven by the P1 parser and checker, the planted bug benchmark, and the harder
tasks (bench/results/planted-and-harder-2026-10-07.md,
bench/results/check-corpus-v0.3.md). Decisions 0028 to 0035.
- Task groups that can be stored in structs,
os.tasksfor program lifetime work,try_send/try_recv,task.map_concurrent(7.1, std). - Discarding a
Resultwithlet _ =is an error; use.ignore_err("reason")(2.6). - Effect arguments of generic types may be omitted (6.2).
x is Patterntests a pattern (3.5). All compound assignments, tuple fieldst.0, localconst, qualified types infrom, doc comments on fields and variants.- XO0520 also covers
.or_err(...)?copies and values mutated after being stored in a collection (5.2). Mutex.withkeep and fail pattern documented (5.4).- Grammar readings from the parser and typing readings from the checker are written into the relevant sections (1.3, 1.6, 2.1, 2.6, 2.10, 3.3, 7.1, 15).
- Streaming HTTP bodies,
Fs.write_only, fullerSet,testing.run_main(std).
Changes in 0.3
Driven by the second paper test (bench/results/paper-2026-10-07-v0.2.md).
Decisions 0021 to 0027.
- String interpolation is
${expr};{and}are literal in plain strings. Raw stringsr#"..."#may contain quotes (1.6). deferblocks are shielded from cancellation (3.5, 7.2).- XO0520 applies only to values copied out of a collection (5.2).
?works onOptionin functions returning an optional (2.6).- A line starting with
.continues the previous line (1.3). - Implicit conversions are listed in one place: there are three (2.10).
- Block and loop typing, generic literal inference, qualified variant
patterns, module local names, pure means no capability effects,
uses (...)allowed on declarations, process signals (various sections).
Changes in 0.2
Driven by the paper test in bench/results/paper-2026-10-07.md. Decisions
0011 to 0020 in knowledgebase/decisions/.
- Explicit failure with
return Err(e)and tailErr(e)(2.6). NewNevertype (2.1). Ok Err Some Nonereplaceok err some none.resultandoldare contextual insideensuresonly (1.4, 2.5, 2.6).- Full pattern syntax including
_, literals, ranges, or patterns, nested patterns, positional and named variant fields (3.5). - Unused binding rule relaxed for parameters and
_names (3.1). - Effect variables and effect subsumption (6.2). Multiple effects in a function type are parenthesized.
- Scope rules: background tasks, exit behavior, error propagation;
scope ... timeoutremoved in favor oftask.with_timeout(7). - Cancellation is observable as a
Cancelederror from fallible blocking calls (7.2). Mutex.withsignature and write back rule (5.4); lost update checkXO0520andMap.update(5.2).- Test blocks get an implicit
os: TestOs;std/testrenamedstd/testing(9.2). - Logging is ambient and untracked (6.1).
- Slicing with ranges, closure literal grammar, trailing commas, const struct
literals,
{}is always an empty map (1.6, 3.x, 15).
0. Design goals
- No guessing. Every name resolves to exactly one definition, found by
reading local code. No overloading, implicit conversions, inheritance,
macros,
init(), or hidden control flow. - The compiler is an API. All tool output is structured JSON with stable codes and machine-applicable fixes.
- Errors cannot be ignored, nil does not exist, matches are exhaustive.
- Authority is explicit. Side effects are declared in signatures and
performed only through capability values passed down from
main. - Concurrency is structured. No task outlives its scope. No shared mutable state without an explicit synchronized type.
- Every target is checked on every build. No build tags.
- Every failure is reproducible. Effects are recordable and replayable.
- Compatibility. Code that builds under an edition keeps building under that edition forever. New editions ship with automatic rewriters.
Staging
Each section is tagged with the phase in which it is implemented.
- [P1] Prototype:
xofrontend written in Go, emits Go source, uses the Go toolchain for codegen, GC, scheduler, and cross compilation. - [P2] Own runtime: deterministic scheduler, full record/replay, task tree.
- [P3] Own backend and content addressed code store.
The language semantics do not change between phases. Only implementation quality and tooling depth change.
[later] marks a feature this draft specifies but the toolchain does not
implement yet, in a section otherwise implemented. The spec audit
(bench/results/spec-audit-2026-10-09.md) lists every such gap.
Experimental sections
None. The five rules that were experimental through draft 0.6 (value
semantics, required uses on pub functions, no module declaration, block
scoped defer, enforced naming with pub) were accepted on 2026-10-08 from
the benchmark evidence (decisions 0003, 0004, 0006, 0009, 0010).
1. Lexical structure [P1]
1.1 Source files
- Encoding: UTF-8. Extension:
.xo. - A module is a directory. All
.xofiles in a directory belong to the same module and share one namespace. There is no module declaration in the file: the directory path is the module identity. usedeclarations are per file, as in Go.- Files ending in
_test.xocontain only tests and test helpers.
1.2 Comments
// line comment
/// doc comment, attaches to the next declaration (Markdown)
There are no block comments. Fenced xo code blocks inside doc comments are
rendered by xo doc; compiling and running them as examples under xo test
is planned ([P2]).
1.3 Statement termination
Newlines terminate statements, using Go’s rule: a newline ends a statement if
the last token on the line is an identifier, literal, ), ], }, ?, or one
of return break continue. Semicolons are not written by programmers and are
removed by xo fmt.
Exception: a line whose first token is . continues the previous line, so
method chains can be split:
http.json(429, {"error": "rate limited"})
.with_header("Retry-After", "${secs}")
A line whose first token is a declaration clause (uses, requires,
ensures, invariant, derive), the -> of a result type, else after a
requires, or the { that opens a function body, also continues the
declaration above it.
A line cannot start with a binary operator: after a > 0 at the end of a
line, || b > 0 on the next would begin a new statement. End the first
line with the operator instead (a > 0 ||, then b > 0). A line
starting with ||, &&, ??, |, ^, +, /, %, <<, >>, a
comparison, or a spaced * or & is error XO0178, whose fix moves the
operator up (decision 0136). -, !, ~, and .. start expressions,
so a line starting with them is a new statement.
Consequences: } else { and } else if must be on the same line as the
closing brace. A list that spans lines needs a trailing comma after the last
element. Trailing commas are allowed in every comma separated list.
1.4 Keywords (28)
as break const continue defer else ensures
enum fn for if in interface invariant
let match pub requires return scope select
struct test type use uses var while
Predeclared identifiers (cannot be redefined at top level):
- Values and functions:
true false self target fault dbg - Variants:
Ok Err(ofResult),Some None(ofOption), usable without a type prefix everywhere. - Types: those in section 2.1 and the prelude interfaces in
spec/std.md. - In test blocks only:
os,expect,testing.
Contextual words (meaningful only in one position, usable as identifiers
elsewhere): derive (after a type body), from (in an enum variant),
with (postfix struct update), after (in a select arm), seed (after a
test name), result and old (inside ensures), is (binary operator
position), bound (after a loop header’s expression, 3.5), realtime (in
a declaration’s uses clause, 6.5).
To keep parsing unambiguous, an unparenthesized struct literal may not appear
in an if, while, for, match, requires, or ensures expression (the
same rule as Go). Write if (p == Point{x: 0, y: 0}) { ... }.
1.5 Identifiers and naming
- Identifiers:
[A-Za-z_][A-Za-z0-9_]*. - Naming is enforced by the compiler (error
XO0101), not left to linters:- Types, enum variants, interfaces:
UpperCamel. - Functions, methods, variables, fields, modules:
snake_case. - Constants:
UPPER_SNAKE. - Effect variables:
snake_case.
- Types, enum variants, interfaces:
- Exported names are marked with
pub, not by capitalization.
1.6 Literals
42 1_000_000 0xff 0b1010 0o755 // Int
3.14 1e-9 // Float
true false // Bool
'a' '\n' '\u{1F600}' // Rune
"hello ${name}, you have ${count + 1} items" // Str with interpolation
"GET /users/{id}" "{\"a\": 1}" // braces are literal
"price: \$5" // \$ is a literal $ before {
r"raw: no escapes, no interpolation \d+"
r#"raw, may contain "quotes": {"a": 1}"#
"""
multi line string, common leading indentation is removed
"""
b"raw bytes\x00" // Bytes, escapes but no interpolation
5s 250ms 10us 2m 1h // Duration
[1, 2, 3] [] // List
{"a": 1, "b": 2} {} // Map
#{1, 2, 3} #{} // Set
${expr}interpolates any expression whose type implementsDisplay.Secret[T]values render as<redacted>.$not followed by{is literal.- Raw strings
r"..."have no escapes and no interpolation.r#"..."#may also contain", which makes JSON fixtures easy to write. - An integer literal takes the integer type expected in context (
Int32,UInt8, …), defaulting toInt. It never becomes aFloat; write2.0. - A rune literal is a
Rune, except where an integer type is expected (an annotation, a parameter, the other operand of an operator, a pattern on an integer, aconstof an integer type): there it is that type’s value of its code point, so byte level code readsb[i] == '"'andmatch c { 'a'..='z' => ... }on aUInt8(decision 0079). The code point must fit the type;Int8andUInt8take only ASCII ('é'is two bytes in UTF-8, so it is never one byte): errorXO0401otherwise. - Duration literals are an integer followed by
ns,us,ms,s,m, orh.1.5sis not a literal; write1500ms. - Escapes in
StrandBytes:\n \t \r \0 \\ \" \' \$ \xHH \u{...}. InStr,\xHHis at most\x7f. {}in expression position is always an emptyMap. An empty block used as a value is written().
2. Types [P1]
2.1 Builtin types
| Type | Description |
|---|---|
Bool | true or false |
Int | 64 bit signed. Overflow faults. |
Int8 Int16 Int32 Int64 | Sized signed integers. Overflow faults. |
UInt8 UInt16 UInt32 UInt64 | Sized unsigned integers. Overflow faults. |
Float | 64 bit IEEE 754. Float32 also exists. |
Rune | Unicode scalar value |
Str | Immutable UTF-8 text |
Bytes | Immutable byte sequence |
Duration, Time | Monotonic duration and wall clock instant |
List[T] | Immutable persistent vector |
Map[K, V] | Immutable persistent hash map, insertion ordered |
Set[T] | Immutable persistent hash set, insertion ordered |
Buf[T] | Mutable, move only growable buffer (section 5.3) |
Chan[T] | Channel handle (section 7) |
Mutex[T], Atomic[T] | Shared mutable state (section 5.4) |
Secret[T] | Value that is redacted in every output path |
Option[T], written T? | Optional (section 2.5) |
Result[T, E] | Success or failure value (section 2.6) |
(A, B) | Tuple |
fn(A) -> B ! E uses fx | Function type (section 6.2) |
() | Unit |
Never | The type with no values. Type of return, break, continue, fault(...), proc.exit(...), and infinite loops. Converts to every type. |
There are no implicit numeric conversions. Conversions are calls on the target
type: Float(i), Int(f) (truncates toward zero, faults on NaN or out of
range), Int32(x) (faults out of range), Int32.try(x) -> Int32?. Parsing
text is Int.parse(s) -> Int?, Float.parse(s) -> Float?.
A Rune is not a number, but converts both ways with an integer type
(decision 0079): Int(r) (and every sized integer type, faulting out of
range: UInt8('é') faults) is its code point; Rune(n) from any integer
type is the character with code point n and faults when n is not a
Unicode scalar value (negative, a surrogate 0xd800 to 0xdfff, or above
0x10ffff); Rune.try(n) -> Rune? is None there instead. A Rune
does not convert to Float (XO0409, with a note pointing at Int(r)).
Float comparison (decision 0053; the same for Float32):
==and!=are IEEE 754: NaN is not equal to anything, itself included, and-0.0 == 0.0. Structural==on structs, tuples, enums, options, lists, and recursive (boxed) values compares Float fields this way, so a value holding a NaN is not equal to itself (x == xis false).<,<=,>,>=use the total order ofOrd(std.md 1): every NaN sorts after+Infand equals every other NaN in the order, and-0.0and0.0are equal in the order. SoNaN < NaNis false,NaN <= NaNis true,NaN > xis true for every non NaNx,-0.0 < 0.0is false, and!(a < b)is alwaysa >= b. Because the two families differ,a <= b && a >= bdoes not implya == b(both NaN).- Unary
-flips the sign of every Float, zero included:-0.0is negative zero.
Duration may be negative (d.abs() gives the magnitude).
Operators on Duration and Time:
| Expression | Type |
|---|---|
d1 + d2, d1 - d2 | Duration |
d * n, n * d, d / n (n: Int) | Duration |
d1 / d2 | Int (truncated) |
t + d, t - d | Time |
t1 - t2 | Duration |
| comparisons | Bool (both are Ord) |
Methods (d.seconds() -> Float, d.scale(f: Float) -> Duration, and more) are
listed in spec/std.md.
2.2 Structs
pub struct User {
pub id: Int
pub name: Str
pub email: Str?
created: Time
}
- Construction names every field:
User{id: 1, name: "a", email: None, created: now}. Type arguments of a generic struct are inferred from the fields or the expected type:Job{id: 1, input: "x"}is aJob[Str]. Fields with a declared default may be omitted:struct Config { port: Int = 8080 }, soConfig{}is valid. - Update syntax creates a modified copy:
u with {name: "b"}. - Structs are values. Assignment copies (cheaply: fields are shared because all builtin collections are immutable).
- Fields may be separated by newlines or commas.
2.3 Enums (sum types)
pub enum LoadErr {
NotFound(id: Int)
Db(msg: Str)
Timeout
}
- Variants may carry named fields. Construction uses names:
LoadErr.NotFound(id: 42). A single field variant may also be constructed positionally:LoadErr.NotFound(42). - The enum prefix may be omitted when the expected type is known (in a
matcharm, an argument, a return, a typed binding, or nested inside one of those):return Err(NotFound(id: 42)). - Enums from other modules are qualified with the module:
task.Timed.TimedOut,http.ClientErr.Canceled. Type arguments of generic enums are inferred:task.Timed.Failed(e). - Enums are closed.
matchmust be exhaustive (errorXO0301). Variants with a field of typeNevercannot be constructed and may be omitted from amatch.
2.4 Interfaces
pub interface Store {
fn get(self, id: Int) -> User ! LoadErr uses net
fn put(self, u: User) -> () ! LoadErr uses net
}
- Satisfied structurally and implicitly, as in Go.
- Interfaces may be used as types (dynamic dispatch) or as generic constraints.
Neversatisfies every interface.- A type satisfies an interface through its methods whether or not they are
pub. Calling a nonpubmethod by name from another module is still an error. - Every derivable name (
Display,Ord,Json, …) is also an interface and may be used as a constraint:fn reply[T: Json](v: T).
2.5 Optionals
T?isOption[T]: eitherSome(v)orNone. There is no nil, null, or zero value pointer.- A plain
Tis accepted whereT?is expected and is wrapped inSomeautomatically (one of the three implicit conversions, section 2.10). - Unwrapping:
if let Some(e) = user.email { send(e) } else { log.warn("no email") } let e = user.email ?? "unknown@example.com" let e = user.email.or_err(LoadErr.NotFound(id: user.id))?if let e = opt(withoutSome) is also accepted as shorthand forif let Some(e) = opt. - In a function that returns
U?,opt?yields the value or returnsNone:
Usingfn retry_after(resp: http.Response) -> Duration? { let secs = Int.parse(resp.header("Retry-After")?.trim())? 1s * secs }?on anOptionin a function that returns aResultis errorXO0204, with a fix that inserts.or_err(...).
2.6 Results, errors, and explicit failure
A function that can fail declares its error type after
!:fn load(id: Int) -> User ! LoadErr. Such a function returnsResult[User, LoadErr]to its caller.!with no type means! Error, the builtin open error interface.fn main(os: Os) !isfn main(os: Os) -> () ! Error.T ! Neveris the same type asT: a function that cannot fail.Returning. In a function declared
-> T ! E, a returned value (a tail expression orreturn x) may be:- a
T, which means success, or Err(e)withe: E, which means failure, or- any
Result[T, E]value, returned as is.
fn parse_port(s: Str) -> Int ! ConfigErr { let n = Int.parse(s).or_err(ConfigErr.NotANumber(s))? if n < 1 || n > 65535 { return Err(ConfigErr.OutOfRange(n)) } n }If
Tis itself aResult, success must be writtenOk(x)explicitly (errorXO0203otherwise).- a
Each arm of a tail
iformatchis in return position, so arms may mixTvalues andErr(e):match x { 0 => 1 _ => Err("no") }.Propagating.
expr?on aResult[T, E2]yields theTor returns the error from the enclosing function.E2must convert to the enclosing error typeE:E2isE, orEisErrorandE2implementsDisplay, orEhas afrom E2variant.
from variants wrap other error types:
enum ApiErr { BadRequest(msg: Str) Load(from LoadErr) // `?` on a `! LoadErr` call wraps into ApiErr.Load }A
fromvariant has one positional field: construct withApiErr.Load(LoadErr.Timeout), match withLoad(inner).Mapping.
r.map_err(f)converts the error inline:json.decode[User](b).map_err(fn(e) { ApiErr.BadRequest(msg: "${e}") })?.Handling is mandatory. A
Resultvalue that is never used (not propagated with?, matched, passed, or returned) is errorXO0201. AResultbound to a name that is never used is alsoXO0201, whatever the name (including_x).Discarding needs a reason.
let _ = expron aResultis errorXO0206. Writeexpr.ignore_err("why this failure does not matter"), which yieldsT?and records the reason, so every discarded error is searchable and reviewable:let _ = dst.remove(tmp).ignore_err("best effort temp file cleanup") let nick = nickname_field(fields).ignore_err("nickname is optional") ?? ""r.ok()remains for when failure simply means “no value”.A closure whose tail is a
Resultis a failing closure; theResultis returned as is. A closure declared-> Twith no!cannot fail.A non
Resultvalue may be discarded as an expression statement.
2.7 Generics
pub fn map[T, U](xs: List[T], f: fn(T) -> U) -> List[U] { ... }
pub fn largest[T: Ord](a: T, b: T) -> T { if a > b { a } else { b } }
- Square brackets, Go style. Constraints are interfaces. Effect variables are declared in the same list (section 6.2).
- Explicit type arguments at a call:
json.decode[User](body). - Monomorphized at compile time. Type arguments are inferred at call sites when
unambiguous; otherwise error
XO0402lists the candidates. - No higher kinded types, no specialization, no variadic generics.
2.8 Type aliases and newtypes
type UserId = Int // alias, interchangeable with Int
type Email struct(Str) // newtype, distinct, construct with Email("x")
type Handler = fn(http.Request) -> http.Response ! Error uses net
A newtype has the equality, hashing, and JSON of its underlying value (decision 0097):
e.0is the underlying value, typed with the newtype’s type arguments (Wrap[Int]’s.0isInt); on avarit is assignable like a tuple field. Any other index isXO0601.- The pattern
Email(p)(mod.Email(p)for another module’s newtype) matches whenpmatchese.0; it is irrefutable whenpis, solet Email(s) = edestructures.Email(..)matches any value. Without parentheses, with other than one positional pattern, or against another type it isXO0302. When the value is an enum with a variant of the same name, the variant is meant. - Construction and reading are open wherever the type is visible; a type
that must guard its value is a struct with a non
pubfield.
type Email struct(Str)
fn domain(e: Email) -> Str {
let Email(s) = e
s.split("@").last() ?? ""
}
fn is_blank(e: Email) -> Bool { e.0.trim() == "" }
2.9 Derived behavior
Every type automatically implements:
Debug: structural printing used by the debugger, error frames, anddbg().EqandHashwhen all fields do.
A type is comparable when it implements Eq: every field, enum payload,
tuple element, and element or type argument is comparable. Function values
and handles are not (decision 0112): Chan, Mutex, Atomic, Scope,
Task, Listener, Conn, task.Group, task.Semaphore, task.Once,
http.Router, http.Server, http.Client, http.BodyWriter,
http.TlsCert, sql.Db, sql.Tx, and the std/testing fakes
(FakeNet, FakeClock, MemFs, FakeEnv, FakeStdio, FakeProc).
A value that holds one, directly or in a field, payload, tuple, or
collection, cannot be compared with == or !=, cannot be a Map key
or Set element, and its type cannot derive Eq, Hash, or Ord
(XO0404, with a note naming the field that holds the handle). Compare
an identifying field instead (a.id == b.id). Interface values compare
through their dynamic value and stay comparable; the opaque Go values of
use go bindings keep Go’s equality (4.4). Automatic Debug is
unaffected.
Opt in with derive on the declaration:
pub struct User { ... } derive Display, Ord, Json, OpenApi
- Derived
Displayprints the same text asDebug:NotFound(id: 42),User{id: 1, name: "a"}. ImplementDisplayby hand (fn (e: ApiErr) display() -> Str) for user facing text. Json,OpenApi, andProtoderives generate codecs and schemas from the same declaration so wire formats and docs cannot drift. JSON rules are inspec/std.md. [after 1.0]OpenApiandProtoare planned for after 1.0 (owner, 2026-10-09; additive, so not a 1.0 blocker): the derives are reserved, accepted only when every field derives them too (no builtin type implements them yet), and nothing is generated.SqlRow(structs only) generates a decoder from a std/sql row: each field is read from the column of the same name (spec/std.md10.4).
2.10 Implicit conversions
There are exactly three, and each applies only where the target type is already known:
- A
Twhere aT?is expected becomesSome(T). - A value where an interface type is expected (a parameter, a field, a collection element, a return) becomes that interface, if the type implements it. This includes passing a capability fake where the capability interface is expected.
- An error at a
?site converts to the enclosing error type (2.6).
Conversions are not applied when joining branches (if/match arms must
already agree) or to the else value of requires. Comparing opt == v
wraps v in Some.
Everything else, including all numeric conversions, is explicit.
3. Declarations and statements [P1]
3.1 Bindings
let x = 10 // immutable binding
var total = 0 // mutable binding
total += x
const MAX_USERS = 1000
const DEFAULT = Config{port: 8080}
constmay appear at top level or in a block. Initializers must be compile time values: literals, other constants, struct and enum construction, and collection literals of those.- Unused local bindings, local constants, and imports are errors (
XO0110). Not covered: function and closure parameters, names starting with_, and_itself.xo fixremoves unused bindings. - Shadowing in the same block is an error. Shadowing in a nested block is allowed.
3.2 Functions
/// Returns the user with the given id.
pub fn fetch_user(db: Db, id: Int) -> User ! LoadErr
uses net
requires id > 0
{
let row = db.query_one("select id, name, email from users where id = $1", id)?
User{id: row.int("id")?, name: row.str("name")?, email: row.str_opt("email")?, created: row.time("created")?}
}
- The last expression of a block is its value.
returnexits early. An empty body{}has type(). - A block whose last item is a statement (an assignment,
let,for,while cond,defer) has type(). while { ... }with nobreakhas typeNever, so it may end a function body of any type (all exits arereturn).- Parameters are immutable unless declared
var(in out, see 3.3). Named arguments are allowed and recommended when a call has more than two parameters of the same type:transfer(from: a, to: b, amt: 5). Positional arguments must come before named ones. - Default parameter values are allowed:
fn listen(addr: Str, backlog: Int = 128). - No variadic functions. Pass a
ListorMap.
3.3 Closures
let double = fn(x: Int) -> Int { x * 2 }
xs.map(fn(x) { x * 2 }) // types inferred from context
srv.handle("GET /hi", fn(req) { http.text(200, "hi") })
let load = fn(id: Int) -> User ! LoadErr uses net { store.get(id)? }
- Parameter and return types may be omitted when the expected function type is known.
- Closures capture by value. Mutating a captured
varinside a closure is errorXO0502. To share mutable state with a closure, capture aMutexorAtomichandle. - Creating a closure performs no effects; calling it does.
returninside a closure returns from the closure.- Effects of a closure are inferred from its body unless declared.
- A closure whose body never completes normally (it ends in
while { ... }withoutbreak,fault(...), or anotherNeverexpression) and whose result type nothing else determines has result typeNever, sos.spawn_background(fn() { while { ... } })needs no type argument (Task[Never, E]).
3.4 Methods
fn (u: User) display_name() -> Str { u.name }
pub fn (var a: Account) deposit(amt: Money) requires amt > 0 {
a.balance += amt
}
- A
varreceiver mutates the caller’s binding in place (in out semantics). It can only be called on avarbinding (errorXO0501), avarparameter, or a field of one (st.users.put(k, v)wherestisvar). - Methods may be declared only in the module that declares the type.
3.5 Control flow and patterns
if cond { ... } else if other { ... } else { ... } // also an expression
for x in xs { ... }
for (i, x) in xs.indexed() { ... }
for (k, v) in m { ... }
for job in jobs_chan { ... } // receives until the channel is closed and drained
for b in data { ... } // each byte of a Bytes, as a UInt8
for i in 0..10 { ... } // half open range, 0..=10 is inclusive
for _ in 0..3 { ... }
while cond { ... }
while let Some(job) = queue.pop() { ... } // until the pattern fails
while { ... } // infinite loop
for x in xs bound 64 { ... } // a loop bound: a contract (below, 6.5)
while cond bound 1000 { ... }
match code {
200..=299 => "ok"
429 | 503 => "retry later"
_ => "error"
}
ifwithoutelsehas type().foriterates over a List, Buf, Set, Map (as(k, v)tuples), Chan, Range, or Bytes. OverBytesit yields each byte as aUInt8, in order (decision 0138); a byte compares with an ASCII rune literal (b >= 'a') and converts withInt(b). Index pairs come fromxs.indexed()on a List; for Bytes, loop over0..data.len()and readdata[i]. AStris not iterable: choose.runes()or.bytes().while let pattern = value { ... }computesvaluebefore each iteration and runs the body while it matches the pattern, with the pattern’s names bound in the body; the loop ends at the first value that does not match (decision 0137). Any refutable pattern works (while let Tok.Num(n) = next()), andwhile let v = optis shorthand forwhile let Some(v) = opt, as withif let(2.5).bound Nfollows the value:while let Some(x) = q.pop() bound 64.x is Patternis aBoolexpression that tests a pattern without binding names:expect(r is Err(Timeout)),if resp is Ok(_) && retries < 3.isis a contextual word in operator position (precedence of==), so it combines with&&and||like a comparison.- Compound assignment works for
+= -= *= /= %=onvarbindings and their fields. There are no bitwise compound assignments. - A value counts as stored again (for XO0520) by any later
push,put,insert,set,add,update, orm[k] = vof it, including later in the same loop. - Tuple fields are
t.0,t.1, … matchis an expression. Arms are separated by newlines. An arm body may be an expression, a block, or a single statement such as an assignment (type()):Timeout => last = Some(AttemptErr.Timeout).- Labeled loops:
outer: for ... { break outer }. - Loop bounds (decision 0133).
bound Nafter the iterated value of aforor the condition of awhilestates that the loop runs at most N iterations; N is a constant Int expression (literals,constnames,+ - * / %), not negative, elseXO1402. Aforover a range with constant ends that is longer than N isXO1402. Otherwise the bound is checked at run time on every backend: starting iteration N + 1 faults withXO-F006(loop ran past its bound of N iterations). Like arequiresclause it is a contract (9.1):xo testfuzzes a function with a bounded loop. Real-time functions (6.5) need a bound on every loop whose count is not constant.while { }takes no bound; writewhile true bound N { }. defer stmtordefer { ... }runs when the enclosing block exits (not the function, unlike Go), in reverse order, including on errors, faults, and cancellation. Values are read when the deferred code runs.- Deferred code is shielded from cancellation: inside it, cancellation
points do not report cancellation, so cleanup such as removing a temp file
works after a timeout. A shielded
deferthat runs longer than 5 seconds of real time faults (XO-F005); the Go backend checks this when the deferred code returns, and not in test worlds, whose clock is virtual. - The native backends (
--backend=llvm,--backend=arm64) run deferred code on every way out alike, faults included (decisions 0070, 0085). - Deferred code that faults ends there; the deferred code registered before it and that of the blocks around it still runs, and the new fault replaces the one being unwound, if any (the last fault is reported). The task is no longer shielded afterwards. Every backend behaves this way (decision 0085, amended 2026-10-10).
Patterns are used in match, let, if let, while let, and for:
| Pattern | Matches |
|---|---|
_ | anything, binds nothing |
x | anything, binds x |
42, "GET", 'a', true | equal literal |
1..=9, 'a'..='z' | value in inclusive range |
p1 | p2 | either (both must bind the same names) |
(a, b) | tuple |
Some(p), None, Ok(p), Err(p) | prelude variants |
Timeout, http.ClientErr.Canceled | unit variant, bare or qualified |
NotFound(p) | variant fields positionally, in declared order |
NotFound(id: p) | variant fields by name; .. ignores the rest: Db(msg: m, ..) |
Variant(..) | variant with any fields |
User{id: p, ..} | struct fields by name |
[], [first, ..], [.., last], [a, .., z] | list shape (.. at most once, anywhere) |
(p), () | grouping, unit |
Patterns nest: Some(Ok(v)), Err(LoadErr.NotFound(id)).
An unguarded _ (or catch all binding) arm that hides variants of an enum
declared in the same module is warning XO0305: list the variants, so that
adding one later is a compile error at every match. Enums from other modules
may use _, since their owners can add variants. A _ arm that hides at
least 8 variants and more than half of the enum is a default (“these few are
special, the rest are not”, as on a token kind enum) and is not reported; the
warning names at most 8 hidden variants, then how many more (decision 0080).
An UpperCamel name in a pattern always refers to a variant; bindings are
snake_case (1.5), so the two never collide.
let requires an irrefutable pattern (let (a, b) = pair).
3.6 Indexing and slicing
xs[i]onList,Bytes: faults when out of range.xs.get(i)returnsT?.Mapcannot be read by index:m[k]in an expression, or an in place change through it (m[k].f = v,m[k] += 1,m[k].push(x)), is errorXO0410, because a missing key is the Map form of a nil dereference. Usem.get(k)(V?),m.get_or_fault(k, "why the key must exist"), orm.update(k, init, fn(var v) { ... }). On avarmap, the whole value assignmentm[k] = vis allowed and meansm.put(k, v).Strcannot be indexed by position (userunes()or slicing).- Ranges slice:
xs[1..],xs[..n],xs[a..b]onList,Bytes, andStr(byte offsets; slicing aStrinside a UTF-8 sequence faults). Slices of immutable values are O(1) and share storage.
3.7 Holes
??? is a typed hole, valid anywhere an expression is expected.
xo checksucceeds with warnings and reports, for each hole, the expected type, the effects in scope, and in scope values or functions that fit (diagnosticXO0900, see section 10).xo buildrejects programs with holes.xo run --holesandxo test --holesallow them and fault if a hole is evaluated.
Holes let an agent write a type correct skeleton first and fill it in steps.
4. Modules and dependencies [P1, content addressing P3]
4.1 Imports
use std/http
use std/json as j
use github.com/acme/billing/invoice
- The last path segment is the local name unless
asis given, with-replaced by_(use acme/rate-limitbindsrate_limit). A/vNmajor version suffix of a module path is skipped (use github.com/acme/tick/v2bindstick). If the result is still not a valid identifier, or is a keyword,asis required (XO0602). - A path is segments of ASCII letters, digits,
_,-, and.separated by/(PathSeg, section 15). Only the last segment becomes the local name, so dashes elsewhere need nothing (use github.com/xo-lang/xo/lexerbindslexer). A leading or trailing/, an empty segment, or a segment that is exactly.or..isXO0158: import paths are absolute, never relative (use ./lib), and name each package one way, sodenyrules (4.2) see every import. A.inside a segment is fine (example.com,.github,v1.2). - Imports are explicit. There is no dot import or glob import.
- Cyclic module imports are an error.
4.2 xo.mod
module github.com/acme/api
edition 2026
require github.com/acme/billing v1.4.0
[P1] Dependencies (decision 0086):
require <module path> <version>requires another Xo module. The module path is its git location (github.com/acme/billingis fetched from https://github.com/acme/billing.git; the module is the repository root and its xo.mod declares the same path); the version is a tagvX.Y.Z(-prereleaseallowed). A module is required once.require go <module> <version>pins a Go module foruse goinstead (4.4).- A module may live in a subdirectory of its repository, as in Go:
github.com/acme/tools/cliis the directorycli/of the repositorygithub.com/acme/tools, its versions are the tagscli/vX.Y.Z, and a version is that directory’s tree at the tag. On github.com, gitlab.com, and bitbucket.org the repository is the first three path elements; on other hosts the longest prefix of the path that git answers for is the repository. A directory holding its own xo.mod is never part of the module around it (it is not fetched, hashed, or vendored with it). - There is no
replace,exclude, orretract(XO1301); local copies are reached only through a workspace (below). - Minimal version selection: the build uses, for each module, the highest version that any reached xo.mod requires (the main module’s, then those of every required version, transitively). No ranges, no solver; a version changes only when an xo.mod changes.
- Imports resolve inside the main module, the embedded
std, and the selected module versions (the longest module path that prefixes the import path). A module must require every module it imports from: importing a package of a module xo.mod does not require, even one in the build because another module requires it, isXO1306. - Fetching runs git and extracts the tag’s tree (
git archive) into a read only module cache; no code from a module runs at fetch or build setup (no scripts, no hooks). A version that cannot be fetched isXO1302. - Module proxies (decision 0126):
XO_PROXYlists where versions come from, in order: proxy URLs (http://,https://),direct(git), andoff(no fetching); the default isdirect. After a comma the next source is tried only when a proxy answers 404 or 410, after a pipe on any error.XO_NOPROXY=<patterns>(comma separated globs over leading path elements,github.com/acme/*) always goes direct. The protocol is read only HTTP:<path>/@v/list,<path>/@v/<version>.mod,.zip(the tree, files under<path>@<version>/), and.lock(the xo.lock lines), the path and version escaped as in the module cache (!before a lower cased capital). A proxy is not trusted: its tree must declare the module path and match xo.lock as a git fetch must.xo mod proxy serve [--addr=host:port] [--fetch] [<cache dir>]serves a module cache this way (--fetchfills it from git). A malformedXO_PROXYisXO1314. - Offline (
--offlineon any command, orXO_OFFLINE=1) nothing is fetched: every version must be in the module cache or vendored, elseXO1303. xo vendorcopies every selected version intovendor/<module path>/withvendor/modules.txt. Whenvendor/modules.txtexists, builds use only vendor/ (no cache, no network); a requirement it lacks isXO1307.- Commands:
xo get <path>[@version|@latest|@none]sets a requirement (latest tag by default) and rewrites xo.mod and xo.lock;xo mod tidyrequires every imported module, removes requirements nothing imports (unless they raise a version the graph selects), and rewrites xo.lock.
[P1] Workspaces: an xo.work file at or above the main module makes
the modules it lists build from their directories:
xo 2026
use ./api
use ./billing
- A listed module overrides every
requireof it; minimal version selection runs over the requirements of all listed modules. - The workspace applies when it lists the main module;
--workspace=off(orXO_WORK=off) ignores it,XO_WORK=<file>names another one. xo check,test,query,edit, andmcptake every listed module as one program (a rename reaches all of them, atomically).- xo.lock is never written with workspace overrides (
xo get,xo mod tidy, andxo vendorignore the workspace); builds record the local directories in build information only. A release build (xo build --release, 8) andxo build --cifail while a workspace overrides a required module (XO1308). xo.workis gitignored byxo initin a single module repository; a monorepo may commit it.
[P1] Effect ceilings (decision 0086 step 3): a uses clause after
the version bounds the effects of a dependency.
module github.com/acme/api
require github.com/acme/billing v1.4.0 uses none
require github.com/acme/geo v2.1.0 uses net.dial, fs.read
- Every call from the requiring module into the dependency (any of its
packages), and every use of one of its functions as a value, may
perform only effects within the ceiling (
fsadmitsfs.read;uses noneadmits nothing), elseXO1309at the call site naming the dependency, the ceiling, and the effect. The effects counted are those the dependency’s function declares; effect variables (6.2) carry the caller’s own effects and do not count. - An upgrade whose new version adds an effect to a function this module
calls therefore fails at that call. A require without
useshas no ceiling. Unknown labels areXO1301. - In a workspace,
use ./billing uses nonein xo.work sets the ceiling of that module for calls from the other workspace modules; their xo.mod ceilings apply as well. xo getprints, for every requirement whose version changes, the exports added, removed, or changed and the effects added or removed, and the exports above the ceiling;xo get -uupgrades every requirement to its latest version.
[P1] Releases (decision 0086 step 4): a version is a vX.Y.Z tag of
the repository whose root holds the module, or a <dir>/vX.Y.Z tag for a
module in the directory <dir>. xo publish [--check] [--version vX.Y.Z] compares the exports (the declarations xo query api
lists, in every package of the module) and their effects with the
previous tag:
- A removed export, a changed signature, or an effect added to a
function needs a major version; in a minor or patch release each is
XO1310(before v1.0.0, a minor release may break and a patch may not). Added exports need a minor version. - Without
--versionthe release is the next minor version (the next patch before v1.0.0), so a breaking change fails unless a major version is asked for. The JSON result lists every change, the breaking ones, the bump they need, and the smallest version they allow. XO1311when the release cannot be made: the module is not in a git repository, a module in a subdirectory has a path that does not end with that directory, the tag exists or is not above the last release, or the major version does not match the module path. Without--check, uncommitted changes areXO1311and the module must load and check without a workspace.- Publishing is tagging:
xo publishprintsgit tag vX.Y.Z && git push origin vX.Y.Z(git tag cli/vX.Y.Z ...incli/) and runs neither. - Major versions 2 and above live at a module path ending in
/vN(github.com/acme/billing/v2), in the same repository: the xo.mod of those tags declares that path, and a require of a path without the suffix takes only v0 and v1 tags (XO1301otherwise).
[P1] Import direction rules (decision 0095): deny lines state which
packages of the module may not import which.
module example.com/notes
deny example.com/notes/domain -> example.com/notes/store, example.com/notes/api
deny example.com/notes/store -> example.com/notes/api/...
deny <pattern> -> <pattern>, ...: a package matching the left pattern may notusea package matching any pattern on the right. A pattern is a package import path (of this module, a required module, or std), or such a path ending in/..., which matches the path and every package under it (example.com/notes/...is the whole module, so “domainimports only std” isdeny example.com/notes/domain -> example.com/notes/...).- Every direct
useline is checked, whether the import is used or not: a forbidden one is errorXO1312at theuseline, naming the rule and its xo.mod line; it has no machine fix (removing an import is a design change). Transitive edges are not checked: a chain is caught at its first forbidden step. - Rules apply to the packages of the module whose xo.mod holds them (the main module, and each workspace module checked as one); a dependency’s rules are checked when it is built as a main module.
- A malformed
denyline, or a pattern that names no package, isXO1301on its line.xo mod tidyandxo getkeepdenylines. xo testchecks first, so a broken rule fails the tests too.xo query imports <package>andxo query deps <package>list theuselines (10.3).
[P1] gc lines tune the garbage collector of a program whose main
module this is (decision 0065); they are ignored in imported modules:
gc min_heap 64MiB // floor of the heap goal
gc percent 200 // GOGC: goal growth over the live heap, or `off`
gc memory_limit 1GiB // soft memory limit (GOMEMLIMIT)
Sizes take B, KiB, MiB, GiB, TiB (or KB, MB, GB, TB, powers
of 1000). Without gc lines a program’s heap goal is the live heap plus 100
percent of it (as Go’s GOGC=100), but never below a floor of 2 MiB per
processor (GOMAXPROCS), at least 16 MiB; there is no memory limit. The
environment always wins: GOGC set turns the floor off and sets the
percent, GOMEMLIMIT replaces memory_limit, XO_GC_MIN_HEAP replaces
min_heap (0 turns the floor off).
4.3 xo.lock
Written only by the toolchain. Maps every imported definition actually used to its content hash.
- [P1] Hash is per module version (source tree hash), like Go’s
go.sum:<path> <version> h1:<hash>for the tree of every selected version and<path> <version>/xo.mod h1:<hash>for the xo.mod of every version version selection read.xo getandxo mod tidywrite it; every command that loads the program recomputes the hashes from the module cache or vendor/ and compares them. A mismatch (a moved tag, an edited cache or vendor copy) isXO1304and nothing builds; a version with no entry isXO1305(runxo mod tidy). Malformed lines areXO1301. - [P1] Checksum log (decision 0124): with
XO_SUMDB=<name>+<key id>+<public key> [<URL>](defaultoff), the first fetch of every module version, from git or a proxy, is checked before it enters the module cache: the log’s record of the version (its two xo.lock lines) must equal the fetched tree’s hashes, the record must be in the log’s signed Merkle tree (inclusion proof), and that tree must extend every tree of the log this module cache saw before (consistency proof). A failure isXO1313and nothing is cached; a log that cannot be consulted, or malformed settings, isXO1314.XO_NOSUMDB=<patterns>(asXO_NOPROXY, 4.2) names private modules that are never looked up. - [P1] Definition hashes (decision 0125, step 5 of decision 0086):
every top level definition (function, method, type, constant) has a
content hash
d1:<base64 SHA-256>over its syntax tree with positions, comments, and doc comments dropped, its own name left out (functions and constants; a type’s name is part of it), local names erased (numbered by first binding:let,var, patterns, loops, closure parameters, type parameters, receivers, labels; the parameters of declared functions keep their names, which named arguments use), imports by path rather than by local name, and the definitions of the same module it references replaced by their hashes (mutually recursive ones hashed together).hashcovers that closure,ownthe definition alone.xo query hash <name>prints them (10.3);xo publishlists every definition, exported or not, whose hash differs from the previous release (changed, orchanged_viawhen only a definition it uses changed), next to the API comparison. The hashes are syntactic (no types): a method called on a value and other modules’ definitions are referenced by name. xo.lock does not record them; decision 0125 proposes how it would. - [P3] Hash is per definition: the normalized AST with local names erased
and referenced definitions replaced by their hashes. Consequences:
- Two versions of a dependency coexist without conflict. Types from different versions are the same type if and only if their hashes are equal.
- An upgrade that does not change a definition is a no-op for code using it.
- A name that does not resolve to a hash fails at once with the nearest real
names suggested (
XO0601). APIs cannot be hallucinated silently.
4.4 Go packages [P1]
Until Xo has its own ecosystem, Go packages can be used directly (decision 0041):
use go "regexp"
use go "github.com/google/uuid" as uuid
fn main(os: Os) ! {
let re = regexp.must_compile("[a-z]+")
os.stdio.println("${re.find_string("123abc")} ${uuid.new_string()}")
}
xo check,build,run, andtestgenerate bindings automatically into.xo/gobind/(or$XO_GOBIND_DIR);xo bind <path>does it explicitly. Third party module versions follow Go’s module resolution.Names are converted to Xo naming: functions and methods to
snake_case(Splitissplit,EncodeToStringisencode_to_string), types stayUpperCamel.Types at the boundary:
(T, error)becomesT ! go.Error; a pointer result that may be nil becomesT?; mutable Go objects become opaque handles; slices and maps are copied in and out; acontext.Contextparameter is supplied from the current task (deadline and cancellation); a panic becomes a fault.An Xo function passed to Go as a callback that Go calls during the Go call, on the calling task’s goroutine, runs as the calling task. A call from another goroutine (
sync.WaitGroup.go_, a timer) runs as a task of its own, a child of the calling task (canceled with it), with its own held mutexes (decision 0067); under the deterministic scheduler such a call waits for a turn like a spawned task and is recorded by--record(core 7.4, 11.3).Every call into Go performs the effect
go, whichpubfunctions must declare. Go code can reach files and the network without a capability, souses gomarks that authority escape in signatures.Exported names whose signatures cannot be mapped are skipped and listed at the top of the generated binding; using one is
XO1102. A package that cannot be bound isXO1101.C headers are the native backends’ counterpart (4.5).
Calls into Go take part in record and replay (11.3, decision 0093): each binding’s doc comment says how (
/// Replay: ...). A pure function can be markedreplay rerunin xo.mod, so a replay calls it again instead of answering it from the trace and the trace does not record it:replay rerun go github.com/acme/slug replay rerun go os getpid File.nameThe line names a Go import path and optionally the functions (
name) and methods (Type.method) it marks; without names it marks every function of the package. The pure std packages (strconv,strings,bytes,unicode,unicode/utf8,unicode/utf16,math,math/bits,regexp,regexp/syntax,path,html,net/url,encoding/hex,encoding/base64,encoding/base32) are marked already, except functions that take or return a handle of another package (regexp.Regexp.match_reader).
4.5 C headers [P2, native builds]
Native builds (--no-gc, 10.1) call C through bindings generated from a
header (decisions 0093 and 0116):
use c "tiny.h" link "tiny.c"
use c "zlib.h" link "z" uses none
fn main(os: Os) {
os.stdio.println("${tiny.tiny_add(2, 40)} ${zlib.crc32(0, b"hello")}")
}
use c "<header>"names a header relative to the module directory, or one on the C compiler’s include path (zlib.h).link "<x>", repeated as needed, names what to link: a C source, object, or archive relative to the module directory (tiny.c,libtiny.a), or a library name without/or extension (zlinks-lz).assets the local name; the default is the header’s base name in snake_case.- The checker binds the header with clang’s AST dump
(
xo bind --c <header>does it explicitly) into.xo/cbind/(or$XO_CBIND_DIR): an Xo declaration file and a C adapter that the native build compiles and links with the program. Function names become snake_case (zlibVersioniszlib_version); struct tags become UpperCamel types. - Types at the boundary (decision 0093), all copied, none pointing into Xo
memory after the call returns:
intisInt32,unsignedUInt32,longandlong longInt, their unsigned formsUInt64,short,char, and their unsigned forms the sized integers,floatFloat32,doubleFloat,_BoolBool, an enumInt32; aconst char *parameter is aStr(copied and NUL terminated) and aconst char *result aStr(copied; NULL is “”); a const byte pointer followed by an integer length parameter is oneStr(const char *) orBytesparameter; a struct of such scalars passed or returned by value is an Xo struct with the same fields, copied; a pointer to a struct the header declares but does not define is an opaque handle type (a result isT?, None for NULL). Handles are not comparable (2.9) and Xo never frees them: call the library’s free function. Anything else (output pointers, mutable buffers,char *results, pointers to defined structs, function pointers, arrays, variadic functions) is skipped and listed with the reason at the top of the binding; using a skipped name isXO1105. A header that cannot be bound isXO1104. - Every call performs the effect
ffi(6.1), whichpubfunctions declare. Ause cline may replace it with the effects a human vouches for,uses noneoruses fs.read, clock, as an effect ceiling of decision 0086 does for a dependency. use cis impossible on the Go backend:xo check,build,run, andtestwithout--no-gcreportXO1201on the line. (use gois likewiseXO1201on the native backends.)- A C crash is not recoverable: it ends the process (8).
5. Values, mutation, and memory [P1]
5.1 Value semantics
All builtin types and user structs, enums, and tuples have value semantics.
There are no pointers and no reference types other than the explicit handle
types (Chan, Mutex, Atomic, capability values, Buf by move).
Because values never alias mutably, data races are impossible except through
Mutex and Atomic, which are synchronized.
5.2 Mutation
- Only
varbindings,varparameters, andvarreceivers can be mutated. xs.push(v)on avar xs: List[T]rebindsxsto a new persistent list sharing structure with the old one. The same holds for every mutating collection method (put,remove,update,pop,add).- Field assignment
u.name = "b"on avar urebindsuto a modified copy. Nested paths work:st.users.put(k, v),st.config.port = 9090. - An assignment evaluates the indexes of its target, then the value, then
stores into the target (decision 0140): changes the value makes to the
target’s binding are kept, so
t.objs[o].kids = t.add_kids()keeps the objectsadd_kidspushed ontot.objs. - A call evaluates the place of a
varreceiver orvarargument, then every argument in order, then reads those places (decision 0142):t.get(t.add(v))on avar tsees the itemaddpushed, andxs.push(f())keeps whatfpushed ontoxs. - To change a value stored in a collection, use
update, which reads, mutates, and writes back in one call:let allowed = m.update(key, new_bucket(limit, now), fn(var b) { b.take(now) }) - Lost update check. Error
XO0520(“mutation ofbis never written back”) applies to a localvarwhose initializer copies an element out of a collection (m.get(k),m[k],xs[i],xs.get(i),first,last,find, optionally followed by??,?, or.or_err(...)?) when the binding is mutated and is not stored again after its last mutation. Storing means passing the whole value as a call argument (m.put(k, b),save(b)) or assigning it as a whole (m[k] = b). Reading or returning it does not count, because the collection still holds the old value. This catchesvar b = m.get(k) ?? new_bucket(); b.take(now)withoutm.put(k, b), and a PATCH handler that returns the edited copy but never stores it. For an intentional scratch copy, copy through a local first (let base = m.get(k)?; var scratch = base). Values derived withwithare not tracked, since views such as redacted copies are common. It also applies to any localvarthat is mutated after being stored into a collection (xs.push(v),m.put(k, v)) and then not stored again, since the stored copy does not see the change. Reads insidedefercount. Parameters, includingvarclosure parameters, are never checked. - No aliased
vararguments (decision 0111). One call may not pass the same variable, or two overlapping places, to twovarparameters (avarreceiver counts as one):both(xs, xs),one(p, p.x),p.add(p.y)with avarreceiver, andboth(g[i], g[j])areXO0503. Places overlap when they start at the same binding and one path of fields and indexes is a prefix of the other; two different field names, or two indexes that are different integer or string literals (g[0],g[1]), keep them apart. Copy one into a temporary first. With aliasing ruled out, passing by reference (the Go backend) and copying in and out (the native backends) give one result.
5.3 Move only buffers
Buf[T], StrBuilder, and BytesBuilder are mutable in place and move
only (affine):
var b = Buf[Int].with_capacity(1024)
b.push(1)
let xs = b.freeze() // moves b, returns an immutable List[Int] in O(1)
b.push(2) // error XO0510: use of moved value `b`
- Assigning, passing, sending on a channel, or capturing in a spawned task moves the buffer. The compiler tracks moves only for these types. There is no general borrow checker.
5.4 Shared mutable state
let cache = Mutex[Map[Str, User]].new({})
cache.with(fn(var m) { m.put(key, user) })
let n = cache.with(fn(m) { m.len() }) // read only access
let size = cache.with_read(fn(m) { m.len() }) // shared with other readers
let hits = Atomic[Int].new(0)
let now_hits = hits.add(1) // returns the new value
Mutex[T].with has the signature
fn (m: Mutex[T]) with[R, E, fx: Effects](f: fn(var T) -> R ! E uses fx) -> R ! E uses fx
- The closure’s return value (or error) is the result of
with. - Mutations to the
varparameter are written back when the closure returns successfully. If it returns an error or faults, the protected value is left unchanged. - A closure parameter declared without
vargets a read only view. - To keep changes and still report failure, return the outcome as the
closure’s success value and apply
?outside:let outcome = q.state.with(fn(var st) -> Result[Job, QueueErr] { st.expire_leases(now) // kept if !st.token_valid(id, token) { return Err(QueueErr.StaleToken) } Ok(st.ack(id)) }) outcome? - Warning
XO0804(split critical section): a value read inside onem.with(...), or derived from one, is used inside a laterm.with(...)on the same mutex, or a branch on it decides whether the laterwithruns. Another task may have changed the state in between (check then act). Do the check and the action in onewith. withcannot be called re entrantly on the same mutex within one task (faultXO-F004with both acquisition sites).m.with_read(f)(std.md 2.7) is shared read only access:fgets the value read only, readers run together, andwithexcludes them. Writers are preferred, so a reader must not wait for a reader that has not entered yet. A value read inwith_readand used in a laterwithgetsXO0804like one read inwith.
5.5 Memory management
Tracing GC, plus request scoped arenas (decision 0066; on by default, off
with --no-arenas). An arena is memory owned by one HTTP request served by
http.server (std.md 4.4) and reused by a later request once the handler has
returned and the response is written. The compiler and the runtime place in it
only memory that provably cannot outlive the request: the request’s header,
parameter, and query tables and its body when the handler uses the request
only through json, param, param_int, query, header, and its Str
fields (and not in a nested closure); the response built by http.json or
http.text that is directly the handler’s result; and the List made by
m.values(), m.keys(), m.entries(), or s.to_list() when it is only
consumed in its own expression. Everything else, including every value a
program stores, sends, captures, returns, or hands to Go, is ordinary garbage
collected memory, so arenas are invisible: a program prints the same with and
without them. A user written arena scope block remains [P2].
6. Effects and capabilities [P1 checking, P2 replay]
6.1 Effect labels
The builtin effects are:
| Effect | Sub effects | Authority |
|---|---|---|
net | net.dial, net.listen | Sockets, HTTP, DNS |
fs | fs.read, fs.write | Files and directories |
clock | Wall clock, timers, sleep | |
rand | Randomness (crypto and math) | |
env | Environment variables, args | |
proc | Spawn processes, signals, exit | |
stdio | Standard input, output, error | |
ffi | Calls into C through use c (4.5, native builds) |
A function with no uses clause is pure: it performs no capability
effects. It may still allocate, fault, be cancelled, log, and use
synchronized state (Mutex, Atomic, Chan).
Logging (std/log) is ambient: it needs no capability and is not an effect.
Log output goes to the sink configured in main (standard error by default).
It is recorded during record and replay like any other output.
Effect labels live in their own namespace: a parameter or module named fs or
net does not conflict with the effect fs or net.
6.2 The uses clause, effect variables, and subsumption
pub fn sync(src: Fs, out: Stdio) -> () ! SyncErr uses fs.read, stdio { ... }
- A function’s effects are the union of the effects of everything it calls.
pubfunctions and interface methods are not inferred: theirusesclause is the declared set, and no clause means pure. Non pub functions and closures have their effects inferred.- Performing an effect outside the declared set is error
XO0701, which shows the call chain that performs it. Declaring more is allowed (for interface stability) and noted byxo vet. - Function types carry effects. One effect:
fn(Int) -> Int uses clock. Several effects are parenthesized:fn(Int) -> Int uses (net, clock). - In a declaration clause the parentheses are optional:
uses net, clockanduses (net, clock)are the same. - Subsumption. A function value is accepted where a function type is
expected if its effects are a subset of the expected effects (
fs.readis a subset offs) and its error type converts to the expected one. A function that cannot fail is accepted where a failing one is expected. A pure function is accepted everywhere a function of the same shape is. - Effect variables let a higher order function pass through its callback’s
effects. Declare them in the type parameter list with the
Effectskind:
An effect variable is instantiated at each call from the argument’s effects, and may be empty.pub fn run_pool[T, R, E, fx: Effects]( jobs: List[T], workers: Int, f: fn(T) -> R ! E uses fx, ) -> Report[R, E] uses fx { ... } pub fn retry[T, E, fx: Effects](clock: Clock, f: fn() -> T ! E uses fx) -> T ! E uses (clock, fx) { ... } - Structs may take effect parameters (
struct Cache[K, V, fx: Effects]), and effect sets may be written as type arguments (Cache[Int, Item, net.dial]). - Effect arguments may be omitted. Writing
Cache[Int, Item]leaves the effect arguments to inference. Either all of a type’s effect arguments are written or all are omitted.- In a local binding they are inferred from the value.
- In parameter and receiver types they become implicit effect variables of
the function. These count as declared: the function may perform them
without naming them in
uses. - In result and error types they are inferred from the function body, so a
constructor
fn new_cache(...) -> Cache[Int, Item]returns the effects of the loader it was given. - In a struct field they become implicit effect parameters of the struct.
- A non pub function with a
usesclause is checked against it. Test blocks may perform any effect on theiros.
6.3 Capability values
Effects describe what a function may do. Capability values grant the authority
to do it. The only source of capabilities is main:
fn main(os: Os) ! {
let srv = http.server(os.net.listen(":8080")?, router(os.net))
srv.serve()?
}
Os has fields net: Net, fs: Fs, clock: Clock, rand: Rand, env: Env,
proc: Proc, stdio: Stdio, and tasks: task.Group (program lifetime task
group, core 7.1). Each capability type is a predeclared interface,
so test fakes and attenuated versions satisfy it. Their methods are in
spec/std.md.
os.fs is rooted at the filesystem root and accepts absolute paths
(/home/me/a, C:/data). Relative paths given to os.fs resolve against the
working directory. Capabilities can be attenuated. An attenuated capability
has the same type; operations outside its authority fail with a Denied
error. Inside a sub capability, paths are relative to its root:
let uploads = os.fs.sub("/var/app/uploads").read_only()
A library cannot open a file or a socket unless it is handed a capability. A dependency’s reach is visible in its signatures.
6.4 Tests and fakes
See section 9.2: every test block receives os: TestOs with fake capabilities.
6.5 Real-time functions [native enforcement]
const TAPS = 32
struct Filter { coeffs: List[Float], window: List[Float], pos: Int }
fn (var f: Filter) step(x: Float) -> Float uses realtime {
f.window[f.pos] = x
f.pos = (f.pos + 1) % TAPS
var acc = 0.0
for i in 0..TAPS {
acc += f.coeffs[i] * f.window[(f.pos + i) % TAPS]
}
acc
}
uses realtime on a function or method declaration marks it
real-time (decision 0133). realtime is written in the uses clause but
is a discipline, not an effect: it is not part of the effect set and
plays no part in subsumption. A real-time function has a declared effect
set like any function with a uses clause; the only effect it may declare
is ffi. realtime in a closure, a function type, an interface method,
or a test is XO0702. In a real-time function, including its contract
clauses:
- No allocation (
XO1401): no List, Map, or Set literal, string interpolation,+or+=on Str, List, or Bytes, slicing, closure, store into a Map, or prelude method outside the bounded set: scalar methods of Int, the sized integers, Float, Rune, Duration, and Time;len,is_empty,get,first,last,contains,index_of, andset(alsoxs[i] = v) on List;len,is_empty,get,contains,get_or_faulton Map and Set;is_some,is_none,ok,err,or_faulton Option and Result; the Atomic operations;lenon Str, Bytes, Chan, Buf, and the builders;contains,starts_with,ends_with,findon Str. The arguments offault(...)are exempt. - Bounded loops (
XO1402): every loop is aforover a range with constant ends or hasbound N(3.5).for (i, x) in xs.indexed()does not build the pairs and is allowed with a bound. - Known callees (
XO1403): calls go only to real-time functions, the bounded prelude methods, and functions of ause cmodule, never to a function value or an interface method. - No recursion (
XO1404): the calls among real-time functions form no cycle, so the stack depth is bounded. - No blocking (
XO1405): no channel operation (orforover a channel), Mutex,scope,select, task,dbg, capability call, or declared effect other thanffi.
Faults stay allowed (they are bounded checks); a fault ends the section.
Initialization is all code that is not real-time: it allocates
freely and prepares what real-time code writes in place, typically
Lists of a fixed length written by index (List.set is in place on a
List nothing else references, decision 0046).
Real-time sections [native builds]. A call from code that is not real-time to a real-time function is a real-time section. Inside it the native runtime:
- defers frees: a reference count that reaches zero puts the value on a
per-thread queue of 256 entries instead of freeing it (and what it
holds); the queue is freed when the section ends. A full queue frees
at once (counted in
XO_RTSTATS=1output asrt_overflow). - counts allocations; a section that allocated faults when it ends with
XO-F007(real-time function `f` allocated n times), which catches what the rules cannot see: a shared List copied on write, the box of a recursive enum, a defaulted field.
The Go backend checks the rules and the loop bounds; it has no sections.
7. Concurrency [P1 basic, P2 deterministic]
7.1 Scopes and tasks
let page = scope s {
let a = s.spawn(fn() { fetch_user(db, id) })
let b = s.spawn(fn() { fetch_orders(db, id) })
render(a.await?, b.await?)
}
- A scope is an expression; its value is the body’s value.
s.spawn(f)starts a foreground task and returns aTask[T, E]handle. The handle may be discarded.s.spawn_background(f)starts a background task (a daemon such as a cache evictor). Background tasks are cancelled automatically when the body finishes.t.awaitwaits for a task and yieldsT ! E.- Exit rules. When the body completes normally, the scope cancels its
background tasks and waits for all tasks to finish. When the body exits
early (
return,?,break, error, or fault), the scope cancels all tasks and waits for them to unwind. No task ever outlives its scope. - Child failure. If a task fails with an error that is not consumed by an
await, or faults, the scope cancels everything else and the error propagates out of the scope as if by?: it must convert to the enclosing function’s error type (errorXO0801otherwise). A task that fails only because it was cancelled does not count as a failure. s.cancel()cancels all tasks of the scope (not the body).- Task groups.
s.group()returns atask.Group, a handle that can be stored in a struct or passed to functions so code can start tasks later (g.spawn(f),g.spawn_background(f)). Tasks in a group belong to the scope that created it and never outlive it. A value of a type that contains aGroupcannot be returned from, or stored outside, the creating scope (errorXO0802).mainreceivesos.tasks, a group that lives for the whole program, for services such as caches that refresh in the background. Tasks started on a group must not fail with an error the creating scope cannot convert (XO0801); a failing group task fails that scope. When the group’s origin is not visible (it came from a field or parameter), a task started on it must not fail unless its handle is bound and awaited. - The escape rule applies to values derived from that scope’s
s.group().os.taskslives for the whole program and may be stored anywhere. - A task’s error is consumed only if its handle is bound with
letand awaited. - Handles from an enclosing scope may be awaited inside
task.with_timeout. - Closures passed to
spawncapture by value. Move only values are moved. - The scope tree is the context: deadlines, cancellation, request ids, and
trace spans flow down implicitly. There is no
ctxparameter. - Deadlines are set with
task.with_timeout(seespec/std.md), which runs a closure in a child scope and returnsT ! Timed[E].
7.2 Cancellation
- Cancellation is sticky: once a task is cancelled, every cancellation point it reaches reports cancellation.
- Blocking operations are cancellation points: effect calls, channel
operations,
await,clock.sleep,Mutex.withandMutex.with_readwhile waiting. - A blocking operation that already returns a
Resultreports cancellation as itsCancelederror variant (http.ClientErr.Canceled,FsErr.Canceled, …). Code may map it to its own error, for exampleFetchErr.Cancelled, but cannot continue doing blocking work. - A blocking operation that cannot fail (
clock.sleep,ch.send,ch.recv,t.awaitin a cancelled task) unwinds the task immediately, runningdeferblocks. clock.sleep_or_cancel(d) -> () ! ClockErris the sleep that reports cancellation as a value: when the task is cancelled before or during the sleep it returnsErr(ClockErr.Canceled)instead of unwinding, so code that must return its own error (a retry loop’sFetchErr.Cancelled) can. Cancellation stays sticky: the task’s next cancellation point reports it again. Inside deferred code it sleeps in full, likesleep(decision 0088).- Reading standard input (
stdio.read_line,read_all,lines) is a cancellation point also while it waits for input: a cancelled reader getsErr(IoErr.Canceled)at once, and no input is lost (a line that arrives later goes to the next read). task.is_canceled() -> Boolreports the current task’s state without blocking.deferblocks run shielded (3.5).
7.3 Channels
let ch = Chan[Job].new(cap: 64)
ch.send(job) // blocks when full
let j = ch.recv() // Job? : None when closed and drained
ch.close()
select {
j = jobs.recv() => handle(j)
results.send(r) => count += 1
after 5s => log.warn("idle")
}
ch.try_send(x) -> Boolandch.try_recv() -> T?never block.for x in ch { ... }receives untilchis closed and drained; each receive is a cancellation point.afterarms require theclockeffect.- Sending moves move only values.
7.4 Deterministic scheduling [P2]
Every xo test runs its tasks on a deterministic scheduler, seeded with
--seed=N (default 1): one task runs at a time, and at each scheduling point
the next task is chosen from the seed, so the same seed always produces the
same interleaving. Scheduling points are channel operations (including
select, try_*, and close), await, Mutex.with, Mutex.with_read,
Semaphore.with,
Once.get, spawn, Atomic operations, sleep, task.is_canceled(),
task.stop_requested(), and effect calls. Failing seeds are printed and can
be pinned with test "x" seed 8812.
xo test --explore=N reruns each concurrent test that passed under N further
seeds and reports the first failing seed. xo run --seed=N (or XO_SEED=N for
any built binary) uses the same scheduler with the real clock; interleavings
are then reproducible as long as timer and I/O timing does not decide them.
An Xo function that Go calls on a goroutine of its own (use go, for
example sync.WaitGroup.go_) runs as a scheduled task: Go’s goroutine waits
until the scheduler admits the call, which happens only at a scheduling
point after Go’s goroutines have settled, in a fixed order (by callback,
then arguments), so the seed decides how callbacks interleave. Once an Xo
function has crossed into Go, the return of every Go call is a scheduling
point. Go code woken by a Go timer or I/O of its own is not reproducible
(decision 0073).
The deterministic scheduler belongs to the Go backend. Native builds
(--backend=llvm and --backend=arm64, decision 0068) run tasks on the real
scheduler only, on a pool of threads: xo run --seed is refused with them and
XO_SEED has no effect, and a native program whose output depends on how its
tasks interleave may print differently from run to run. The rest of core 7
means the same on every backend.
7.5 Process signals and graceful shutdown
Shutdown has two phases, so programs can finish queued work without fighting sticky cancellation:
- Stop requested. The first SIGINT or SIGTERM cancels nothing. It sets
the program’s stop state:
task.stop_requested()returnstrueand the channeltask.stopping()is closed, so loops canselecton it. Servers started withservestop accepting new connections and finish in flight requests. Readers stop reading: a read of standard input that is blocked, or starts later, returnsErr(IoErr.Canceled)(decision 0088). Workers drain what is queued. - Cancel. When
main’s body has not returned after the grace period (default 30s, set withos.proc.set_grace(d)), or on a second signal,main’s root scope is cancelled as in 7.2: blocking calls unwind anddeferblocks run. A third signal exits immediately with status 130.
A program that ignores phase 1 behaves as before, only 30 seconds later; a
program that wants the old behavior calls os.proc.set_grace(0s).
fn reader(os: Os, queue: Chan[Str]) -> () ! IoErr {
defer queue.close() // workers drain the queue, then exit
while {
match os.stdio.read_line() {
Ok(Some(line)) => queue.send(line)
Ok(None) => return Ok(()) // end of input
Err(Canceled) => return Ok(()) // stop requested (or cancelled)
Err(e) => return Err(e)
}
}
Ok(())
}
In tests, os.proc.signal_after(d) delivers the first signal to a program run
by testing.run_main; os.proc.signal_after(d) called again delivers the
next one.
Under testing.run_main, a third signal makes run_main return 130 after
main has unwound (the test process itself does not exit), and requests
handled in memory by a fake network observe the stop state but are not
cancelled by it.
8. Faults [P1]
A fault is a bug, not an error: overflow, out of bounds, failed contract,
failed Int32(x) conversion, evaluated hole, explicit fault("msg").
fault has type Never.
- A fault terminates the current task. Its scope treats it like a failed child
and cancels siblings. A fault reaching
mainexits the process with status 70 and writes a crash bundle (section 11.2). task.isolate(f) -> T ! Isolated[E]runsfin a child scope and converts a fault into a value.std/httpisolates every request and returns 500.- Every fault message includes the evaluated values:
index 7 out of range for List of length 5 at users.xo:41:18. - The native backends unwind a fault the same way (decision 0085: deferred
code, scopes, Mutexes, and references are released on the way); they
print the fault without its frames, write no crash bundle, and
Fault.framesoftask.isolateis empty there.
There is no undefined behavior. Debug and release builds have identical
semantics; release builds may only disable contract checks that are explicitly
marked requires(debug) or ensures(debug).
xo build --releaseis a release build (decision 0099): the clauses marked(debug)are compiled out on every backend,requires(debug) c else Eincluded (the function no longer returns that error). Every other contract, every invariant, and every fault check stays. A release build fails while an xo.work workspace overrides a required module (XO1308, 4.2) and is the only build that accepts--error-args=false(11.1). Error frames are recorded in both.- Every other build is a debug build:
xo buildwithout--release,xo run,xo test(whose contract fuzzing checks the debug clauses too), andxo replay, which rebuilds in the mode recorded in the program’s build information.--ciis a gate, not a mode: it does not imply--release; a pipeline that ships the binary passes both.
9. Contracts and tests [P1]
9.1 Contracts
fn transfer(var from: Account, var to: Account, amt: Money) -> () ! TxErr
requires amt > 0
requires from.balance >= amt else TxErr.Insufficient
ensures from.balance + to.balance == old(from.balance + to.balance)
{ ... }
struct Account { id: Int, balance: Money } invariant balance >= 0
requires exprfaults on violation.requires expr else EreturnsErr(E)instead, which makes input validation declarative. A function may consist only of contracts and an empty body.ensuresmay referenceresult(the success value) andold(expr).invariantis checked after construction (including from defaults) and after everyvarmethod.- Contract failures print every sub expression’s value:
ensures failed: from.balance + to.balance == old(from.balance + to.balance) from.balance = 40 to.balance = 50 old(...) = 100 xo testfuzzes every function with contracts using generated inputs that satisfyrequires, and checksensures. Capability parameters receive seeded fakes. Contract clauses must be pure.
9.2 Tests
test "fetch missing user" {
let store = new_store()
expect(store.get(7)) == Err(LoadErr.NotFound(id: 7))
}
test "reverse twice" for xs: List[Int] {
expect(xs.reverse().reverse()) == xs
}
test "retries after 503" {
let calls = Atomic[Int].new(0)
os.net.handle("GET https://api.test/x", fn(_req) {
if calls.add(1) < 3 { http.empty(503) } else { http.text(200, "ok") }
})
let body = get_with_retry(os.net, os.clock, os.rand, "https://api.test/x")?
expect(body) == b"ok"
expect(os.clock.sleeps()) == [100ms, 200ms]
}
testblocks may live in any file. They are compiled only byxo test.- A test body has type
() ! Error, so?fails the test with the error. - Inside a test block, and anywhere in a
_test.xofile forexpectandtesting, three names are predeclared:os: TestOswith fake capabilities (in memory filesystem, virtual clock, seeded rand, network that fails unless a handler is registered, captured stdio, fake env and proc). Fakes have extra control methods such asos.clock.advance(d)andos.fs.fail_next(...); seestd/testinginspec/std.md. Each fake satisfies the matching capability interface.expect(x)followed by any comparison:expect(a) == b,expect(n) <= 3.expect(cond)alone asserts aBool.testing, thestd/testingmodule.
- The virtual clock auto advances: when every task in the test is blocked, time jumps to the next timer, so sleeps and timeouts finish instantly.
foron a test makes it a property test over generated values.- Failures found by fuzzing or property tests are shrunk to a minimal input.
A shrunk contract fuzz failure is written as a permanent test to
regressions_test.xoin the module itself (so it can call private functions), namedregression: <function> (seed N), but only if the module still type checks with it; otherwisexo testsays why and the seed reproduces the failure. Shrunk property test failures are reported with their seed and input. - Real capabilities require
test "name" uses net { ... }andxo test --real;osthen holds real capabilities for the listed effects.
10. Toolchain and diagnostics [P1]
10.1 Commands
| Command | Purpose |
|---|---|
xo build [--targets=all|os/arch,...] [--no-gc] [--tzdata=embedded|system] [--ci] [--release [--error-args=false]] | Build static binaries; --no-gc on a native backend without a garbage collector (below); --tzdata=system reads time zones from the host instead of embedding the ones the program uses (std.md 13); --ci fails before building on an unformatted file (10.5) or a dbg left in code (11.4); --release is a release build (8): requires(debug) and ensures(debug) compiled out; --release --error-args=false drops parameter values from error frames (11.1). Builds are debug builds otherwise, and xo run and xo test always are |
xo run | Build and run for the host |
xo check [--no-gc] [--fix] | Parse and type check all targets, no codegen; --no-gc adds the native build check (XO1201); --fix first applies the machine fixes (below) |
xo test [--seed=N] [--explore=N] [--cases=N] [--real] | Tests, contract fuzzing, property tests (--cases per property test and fuzzed function, default 100); deterministic scheduling. Doc comment examples are [P2] (1.2) |
xo dump <pid> | Task tree of a running program (11.4) |
xo mcp | Model Context Protocol server over stdio: check (with an optional fix), query, edit, explain, fmt, test, build tools returning compact JSON |
xo lsp | Language Server Protocol server over stdio for editors: diagnostics with quick fixes, hover, definition, references, rename, document symbols, semantic tokens, formatting |
xo serve | Resident checker on a unix socket; xo check and xo query use it automatically when it answers |
xo fmt | Canonical format, no options |
xo fix [--dry-run] [--from=draft-0.N] [--edition=YYYY] | Apply all machine fixes until none apply; --from migrates code written for an older draft (interpolation, prelude variants, let _ = discards, scope timeouts, Map index reads) |
xo vet | Advisory checks, never errors: checker warnings plus XO0207 (?? precedence trap), XO0208 (each ignore_err with its reason), XO0703 (declared effect never performed), XO0901 (dbg left in code), XO1103 (use of Go packages per module) |
xo query <kind> ... | Semantic queries (10.3): type, def, callers, effects, holes, api, imports, deps |
xo edit <op> ... | Semantic edits (10.4) |
xo replay, xo dump | Debugging (section 11); xo why and xo debug are [P2] and not implemented yet (11.3, 11.5) |
xo doc | Docs from doc comments |
xo get <path>[@version], xo mod tidy, xo vendor, xo mod clean, xo mod proxy serve | Dependencies (4.2, 4.3): require a module version, tidy xo.mod and xo.lock, copy dependencies into vendor/, empty the module cache, serve a module cache as a module proxy (decision 0126). Every command takes --offline and --workspace=off |
xo init <path>, xo publish [--check] | Start a module (xo.mod, xo.work gitignored); check a release against the previous tag (4.2) |
xo explain <code> | Long form explanation of a diagnostic |
xo bind [--json] <Go path>..., xo bind --c [--link x]... [--uses none|e,...] <header>... | Generate the bindings of Go packages (use go, 4.4) or C headers (use c, 4.5) into the project’s binding cache |
The commands that report diagnostics or results accept --json: parse,
check, build, test, fmt, fix, vet, query, edit (always
JSON), doc, explain, and bind. run, replay, dump, serve, and
version print the program’s or the tool’s own output. When stdout is not a
terminal, JSON is the default for query.
Check and fix. xo check is read only. xo check --fix first
applies every unambiguous machine fix, the same set xo fix applies
(without --from), round after round until none applies, reformats the
files it changed, and then reports the diagnostics that remain, with the
exit status of xo check on them. Its JSON adds applied, the list of
fixes made ({file, line, rule, title}, rule being the diagnostic
code); plain xo check --json has no applied field. This is the agent
loop’s check: an agent runs xo check --fix and then xo test, so a
diagnostic with an exact fix never costs a run (decision 0096). The MCP
check tool takes fix: true for the same.
Builds without a garbage collector. xo build --no-gc (also xo run,
xo test, and xo check) builds on a native backend whose memory is
reference counted instead of collected: arm64 on darwin/arm64 and
linux/arm64 hosts, LLVM otherwise; --backend=llvm or --backend=arm64
picks one, and --backend=go with --no-gc is a usage error. After type
checking and before any code generation, a check reports every construct,
std API, or effect the native backends do not support as error XO1201 at
its site, naming the feature and saying whether support is planned (build
without --no-gc until then) or impossible (code from a use go package
needs Go’s runtime). A program the check accepts builds; the check is
derived from the native code generator itself, so the two cannot disagree.
xo check --no-gc runs the same check without building (programs only: a
module without main has nothing to lower). The Go backend’s deterministic
scheduler (--seed of xo run, --explore) and recording (--record) are
not available on native builds (decision 0083). xo test --no-gc builds a
native test binary and runs each test in a process of its own against the
fakes of 9.2 (virtual clock, seeded Rand, fake environment, streams, and
network, MemFs, FakeProc, os.tasks) with the same report as the Go
backend; a fault fails only its test.
Property tests run there with the Go backend’s cases and shrunk inputs
(one process per case, decision 0120), --real gives real capabilities
for the declared effects (a test declaring clock, net, or proc runs
on the real clock there), contract fuzzing (9.1) is reported as skipped
(decision 0085), and error
traces are recorded natively but printed only in main’s error report
(decision 0104), and fault frames are not recorded natively.
Hybrid builds. xo build --hybrid (Go backend) compiles the hot leaf
functions of the main module (a loop over Int, Float, Bool, and
Lists of those or of structs of Int and Float fields, with no
allocation) to native assembly linked into the Go program; everything
else, and every program the native backends cannot lower, stays Go.
Behavior is the Go backend’s, faults included, except that fault frames
omit the assembly functions and Float results may differ in the last bit
where the Go backend fuses a multiply and an add (decision 0084). Loops
in assembly yield to the scheduler and the garbage collector as Go code
does (decision 0131).
10.2 Diagnostic format
{
"code": "XO0201",
"severity": "error",
"message": "result of `fetch_user` is not handled",
"span": {"file": "api/users.xo", "line": 18, "col": 3, "end_line": 18, "end_col": 24},
"notes": [{"message": "`fetch_user` can fail with LoadErr", "span": {"file": "users/fetch.xo", "line": 4, "col": 1}}],
"fixes": [
{"title": "propagate with ?", "edits": [{"span": {"file": "api/users.xo", "line": 18, "col": 24, "end_line": 18, "end_col": 24}, "replace": "?"}]}
]
}
- Codes are stable forever within an edition.
- Codes are grouped:
01xxlexical and naming,02xxerrors and results,03xxmatch,04xxtypes and generics,05xxmutation and moves,06xxresolution,07xxeffects,08xxconcurrency,09xxholes and contracts,10xxplatform,11xxGo interop,12xxnative builds without a garbage collector (XO1201: not available with--no-gc, planned or impossible; 10.1). Every code is listed in 10.6. - The compiler reports all errors it can, not just the first, and never
cascades: a bad expression has type
<error>and suppresses dependent diagnostics. - Habits from other languages (decision 0087). Code written with the
syntax or library names of Go, Rust, TypeScript, Python, Swift, or
Kotlin gets one diagnostic per habit that names the habit and the Xo
form, with a machine fix when the rewrite is unambiguous, and checking
continues as if the Xo form had been written:
- Syntax that the grammar would otherwise reject with a generic code
(
XO0160,XO0168,XO0172,XO0175) is errorXO0177(syntax from another language): for examplefunc,x := v,let mut,T: Hash + Eq,Vec<T>,[]T,var xor&xat a call site,c ? a : b,switch/case,when,elif,and/or/not,for i := 0; i < n; i++, Gofor k, v := range m(reported by the checker, which picks the Xo form for the type ofm),x++,throw,try f(),|x| e,(x) => e,lambda,x as T,A::B,impl,trait,): Tand Go’s bare result types,(T, error)results, backtick templates, f-strings, and/* */comments.xo explain XO0177lists them. - When a specific code already describes the error, it keeps that code
and gains the habit in a note and the fix:
XO0601for names from other languages (nil,null,this,len(x),append,print,String,Vec,int,.unwrap(),.length, camelCase methods, Python’st = 0declaring by assignment),XO0169(commas between match arms),XO0173(useafter declarations),XO0701(an effect argument left out of a parameter type, which stays an abstract effect variable),XO0408(TypeScript’sconst x = f()),XO0110(Kotlin’s"$name", literal text in Xo),XO0205(x?.foptional chaining in a function that does not return an optional),XO0403(a missingreason),XO0404(JSON for tuples),XO0101,XO0502, and the lexical codesXO0150(#comments,#[derive]),XO0152('text'), andXO0153(Swift’s\(x)). - A habit gets a fix only when one rewrite keeps the meaning; where the
Xo form needs a decision (
x!!needs a reason,try/catchneeds a Result handling choice,printneeds a capability in scope) the diagnostic names the form and has no fix.
- Syntax that the grammar would otherwise reject with a generic code
(
- A suggestion from edit distance (
did you mean) is a note; it is also a fix only when it is clear: a unique closest candidate at most half the name’s length away, for names of three or more characters. - A diagnostic’s first fix is the one
xo fixandxo check --fixapply (10.1); fixes whose edits overlap in one round wait for the next round.xo check --fix --jsonadds"applied": [{"file", "line", "rule", "title"}]next tomodulesanddiagnostics.
10.3 Queries
xo query type api/users.xo:18:7
xo query def http.server
xo query callers users.fetch_user
xo query effects api.get_user // with the call chain per effect
xo query holes
xo query api github.com/acme/billing // exported surface of a module
xo query imports example.com/notes/domain // its `use` lines
xo query deps example.com/notes/server // the same, transitively
xo query hash users.fetch_user // content hash of a definition (4.3)
hash (decision 0125) answers {list}, one entry per definition the
name resolves to (a name, Type.method, pkg.name, or <package path>.name of the module holding --dir): {id, package, name, kind, pub, hash, own, refs, file, line}. It needs no check run.
imports and deps (decision 0095) answer
{package, transitive, imports, packages}: each entry of imports is a
use line {from, path, pos, std, go, test, missing, denied}, where
denied is the XO1312 message when a deny rule forbids the line, and
packages lists the distinct paths reached. deps follows every
imported package of the program (not std or Go packages).
10.4 Semantic edits [P1: rename, add-field, change-sig]
xo edit rename users.fetch_user users.load_user
xo edit add-field User created: Time = Time.UNIX_EPOCH
xo edit change-sig users.load_user "(db: Db, id: UserId) -> User ! LoadErr"
Edits update every reference atomically and are rejected if the result does not type check.
- References are found through the checker’s resolved symbols, never by text search. An edit of a program that does not check clean is rejected before anything is computed.
renamerenames a function, method, or constant and every call and use as a value. The new name may repeat the target’s qualifier.add-fieldaddsname: Typeto the struct declaration andname: defaultto every struct literal of the type. The default is a migration value; it is not kept as a declared field default. Without= defaultthe edit is rejected when struct literals exist.change-sigreplaces the parameter list, result, and error type (not the clauses). Call arguments are matched to the new parameters by name; a parameter renamed in place keeps its position, and its uses in the body are renamed. New parameters with a default need no argument. A call that passes an argument for a removed parameter, or lacks one for a new parameter without a default, rejects the edit, listing those call sites. Arguments keep their order (and so their evaluation order); arguments that no longer match their position become named.- The result is a JSON summary:
{op, target, ok, written, reason, files, edits, diagnostics, sites}.diagnosticsholds the errors that rejected the edit;sitesthe call sites or literals that blocked it.--dry-runchecks the edit without writing.
10.5 Formatting
xo fmt produces one canonical layout from the AST. There are no options.
xo build fails on unformatted files in CI mode (--ci).
10.6 Diagnostic codes
Every code the toolchain reports, its default severity, and what reports
it: the lexer and parser (xo parse and every later command), the checker
(xo check), xo vet (advisory, never an error except XO0901 under
xo build --ci), the no GC check (--no-gc, 10.1), and module
resolution (modules: every command that loads a program reports these
on the xo.mod, xo.lock, or xo.work line, and so do xo get, xo mod tidy, and xo vendor; 4.2, 4.3), and xo publish (publish).
xo explain <code> prints the long form of each. The registry is
internal/diag/codes.go; a test keeps this table equal to it.
| Code | Severity | Reported by | Meaning |
|---|---|---|---|
XO0101 | error | checker | name breaks the enforced naming rules (core 1.5) |
XO0110 | error | checker | unused local binding or import (core 3.1) |
XO0150 | error | lexer | character that cannot start any token |
XO0151 | error | lexer | string, raw string, bytes, or triple quoted string not closed |
XO0152 | error | lexer | rune literal that is empty, unterminated, or holds more than one character |
XO0153 | error | lexer | unknown or malformed escape sequence |
XO0154 | error | lexer | bad digit, misplaced _, missing digits, or out of range integer |
XO0155 | error | lexer | unknown duration unit, fractional or non decimal duration |
XO0156 | error | lexer | ${ without a matching } in the same string |
XO0157 | error | lexer | ${} with no expression |
XO0158 | error | lexer | use not followed by a valid import path |
XO0160 | error | parser | a token that does not fit the grammar here |
XO0161 | error | parser | list element followed by a newline instead of a comma (core 1.3) |
XO0162 | error | parser | else must be on the line of the closing brace (core 1.3) |
XO0163 | error | parser | unparenthesized struct literal in a condition (core 1.4) |
XO0164 | error | parser | (, [, or { not closed before end of file |
XO0165 | error | parser | syntax that is not a pattern (core 3.5) |
XO0166 | error | parser | while cond used as an expression; only while { } is one |
XO0167 | error | parser | top level declaration (fn, struct, const, …) inside a block |
XO0168 | error | parser | statement (let, expression, …) at top level |
XO0169 | error | parser | match or select arms separated by , instead of newlines |
XO0170 | error | parser | several effects in a function type must be parenthesized (core 15) |
XO0171 | error | parser | .. used more than once or not last in pattern fields |
XO0172 | error | parser | function declaration without a body |
XO0173 | error | parser | use after the first declaration (core 15: File) |
XO0174 | error | parser | bare self parameter outside an interface method signature |
XO0175 | error | parser | two statements or arms on one line |
XO0176 | error | parser | pub before something that cannot be exported |
XO0177 | error | parser | syntax from another language (Go, Rust, TypeScript, Python, Swift, Kotlin); names the habit and the Xo form (decision 0087) |
XO0178 | error | parser | a line starts with a binary operator (||, &&, +, ==, …); end the previous line with it (core 1.3, decision 0136) |
XO0201 | error | checker | Result value is never used (core 2.6) |
XO0202 | error | checker | error type does not convert to the enclosing error type (core 2.6) |
XO0203 | error | checker | success of a Result typed T must be written Ok(x) (core 2.6) |
XO0204 | error | checker | ? on an Option in a function returning a Result (core 2.5) |
XO0205 | error | checker | ? where the enclosing function cannot return the failure (core 2.5, 2.6) |
XO0206 | error | checker | let _ = discards a Result; use .ignore_err("reason") (core 2.6) |
XO0207 | warning | vet | method chain on the right of ?? starts at a literal, so it applies only to the default (core 15 precedence) |
XO0208 | note | vet | .ignore_err("reason") discards an error; listed with its reason for review (core 2.6) |
XO0301 | error | checker | match does not cover every case (core 2.3) |
XO0302 | error | checker | pattern does not fit the type of the matched value (core 3.5) |
XO0303 | error | checker | refutable pattern where an irrefutable one is required (core 3.5) |
XO0304 | error | checker | alternatives of an or pattern bind different names or types (core 3.5) |
XO0305 | warning | checker | _ arm hides variants of an enum declared in this module (core 3.5) |
XO0401 | error | checker | value of the wrong type; only three implicit conversions exist (core 2.10) |
XO0402 | error | checker | type arguments cannot be inferred (core 2.7) |
XO0403 | error | checker | wrong arguments or fields: count, names, missing, or duplicates |
XO0404 | error | checker | type does not implement a required interface or constraint (core 2.4) |
XO0405 | error | checker | type cannot be inferred here; annotate it |
XO0406 | error | checker | operator, index, slice, or iteration not defined for the type |
XO0407 | error | checker | name used as the wrong kind of thing (a type as a value, a value that is not callable) |
XO0408 | error | checker | const initializer is not a compile time value (core 3.1) |
XO0409 | error | checker | invalid explicit conversion (core 2.1) |
XO0410 | error | checker | reading a Map by index; use get or get_or_fault (core 3.6) |
XO0420 | error | checker | constant std/time layout is invalid: unknown field, bad field form, or stray brace (std.md 13.3) |
XO0501 | error | checker | var method called on a non var binding (core 3.4) |
XO0502 | error | checker | assignment to an immutable binding or to a captured variable (core 5.2, 3.3) |
XO0503 | error | checker | one variable or overlapping places passed to two var parameters of one call (core 5.2, decision 0111) |
XO0510 | error | checker | use of a moved Buf, StrBuilder, or BytesBuilder (core 5.3) |
XO0520 | error | checker | mutation of a copied or stored collection element is never written back (core 5.2) |
XO0601 | error | checker | name does not resolve (core 4.3) |
XO0602 | error | checker | import path does not yield a valid local name (core 4.1) |
XO0603 | error | checker | name declared twice in one scope, or a predeclared name redefined (core 3.1, 1.4) |
XO0604 | error | checker | cyclic module imports (core 4.1) |
XO0605 | error | checker | name is not pub in its module, or the std type is an opaque handle (core 1.5) |
XO0606 | error | checker | method declared outside the module of its type (core 3.4) |
XO0607 | error | checker | break or continue outside a loop, or an unknown label (core 3.5) |
XO0701 | error | checker | effect performed outside the declared set (core 6.2) |
XO0702 | error | checker | unknown effect label (core 6.1) |
XO0703 | warning | vet | effect declared in uses is never performed (core 6.2) |
XO0801 | error | checker | child or group task error does not convert to the enclosing error type (core 7.1) |
XO0802 | error | checker | scope handle, or a value containing a task group, escapes its scope (core 7.1) |
XO0803 | error | checker | task started without a scope handle (core 7.1) |
XO0804 | warning | checker | value read in one Mutex.with is written back in a later one (core 5.4) |
XO0900 | warning | checker | typed hole report (core 3.7) |
XO0901 | warning | vet | dbg(...) left in code (core 11.4) |
XO1001 | error | reserved | std/sys/<os> used outside the matching target arm (core 12.2) |
XO1101 | error | checker | Go package cannot be bound (not found, does not build, main package, or binding error) |
XO1102 | error | checker | Go name exists but its signature does not map to Xo, so the binding skipped it |
XO1103 | note | vet | per module report of use go packages and the functions that perform go (decision 0041) |
XO1104 | error | checker | C header cannot be bound (not found, clang cannot read it, a link file is missing, or an unknown effect in its uses) |
XO1105 | error | checker | C function exists in the header but its signature does not map to Xo, so the binding skipped it |
XO1201 | error | no GC check, checker (use c without --no-gc) | construct, std API, or effect not available without a garbage collector (--no-gc); planned or impossible |
XO1301 | error | modules | malformed xo.mod require or deny line, xo.lock, or xo.work line, or a deny rule naming a package that does not exist (core 4.2, 4.3) |
XO1302 | error | modules | module version cannot be fetched from git or a module proxy (XO_PROXY): repository, tag, or version missing, or its xo.mod declares another path |
XO1303 | error | modules | module version is not in the module cache and the build is offline (XO_OFFLINE, –offline) |
XO1304 | error | modules | module tree does not match its xo.lock content hash (core 4.3) |
XO1305 | error | modules | xo.lock has no hash for a module version the build uses; run xo mod tidy |
XO1306 | error | checker | import of a package from a module that xo.mod does not require |
XO1307 | error | modules | vendor/ does not match xo.mod; run xo vendor |
XO1308 | error | build | release build (xo build --release, or --ci) while an xo.work workspace overrides required modules |
XO1309 | error | checker | call into a dependency performs an effect above its ceiling (require ... uses ... in xo.mod, use ... uses ... in xo.work) |
XO1310 | error | publish | xo publish: a minor or patch release removes or changes an export, or adds an effect to one |
XO1311 | error | publish | xo publish: the release cannot be made (not in a git repository, a subdirectory module path not ending in its directory, uncommitted changes, tag exists, version not above the last, wrong /vN path) |
XO1312 | error | checker | use of a package that a deny rule of xo.mod forbids (decision 0095) |
XO1313 | error | modules | a fetched module version disagrees with the checksum log (XO_SUMDB), or the log’s signature or proofs do not verify (4.3, decision 0124) |
XO1314 | error | modules | the checksum log cannot be consulted, or XO_PROXY, XO_SUMDB, XO_NOPROXY, or XO_NOSUMDB is malformed (4.2, decisions 0124, 0126) |
XO1401 | error | checker | allocation in a real-time function (literal, interpolation, + on Str or List, closure, growth method) |
XO1402 | error | checker | loop without a provable bound in a real-time function, or a bound N that is not a constant or is exceeded by a constant range |
XO1403 | error | checker | call in a real-time function of a function that is not real-time, a function value, or an interface method |
XO1404 | error | checker | recursion among real-time functions |
XO1405 | error | checker | blocking operation (channel, Mutex, scope, select, task) or capability effect in a real-time function |
XO1001 is reserved: no std/sys/<os> module exists yet (14), so it is
never reported.
Faults (8) that have a stable code print it in their message:
| Code | Fault |
|---|---|
XO-F001 | deadlock: every task is blocked on another task (11.4) |
XO-F004 | Mutex.with or with_read called re entrantly on a mutex the task holds (5.4) |
XO-F005 | a shielded defer ran longer than 5 seconds (3.5) |
XO-F006 | a loop started more iterations than its bound N (3.5) |
XO-F007 | a real-time section allocated (6.5, native builds) |
11. Debugging [P1 basics, P2 replay]
11.1 Error traces
Each ? that propagates an error appends a frame:
LoadErr.Db(msg: "timeout")
at users.fetch_user(id=42) users/fetch.xo:18
at api.get_profile(req=GET /u/42) api/profile.xo:7
- Frames record function, location, and parameters that implement
Debug, truncated to 64 bytes each.Secret[T]prints<redacted>. Capability parameters are left out. Frames are built only when an error propagates; the success path records nothing. - Frames are recorded in every build, debug and release (decision 0092).
A frame keeps a copy of each parameter value and produces its
Debugtext only when the trace is printed (the error report ofmain, a crash bundle, a test failure, a handler’s error log), formatting no more of a value than the 64 bytes it shows. The text is what it would have been at the?: values are immutable or copied on write, and the few whoseDebugtext can change in place (Buf,Atomic) are formatted when captured. xo build --release --error-args=falserecords the function and location only and prints(...)for the parameters:at users.fetch_user(...) users/fetch.xo:18. It is a usage error without--release.- [later] A last line naming the task and its scope
(
in task "handler#913" (scope api.serve)) is planned. - Native backends (
--no-gc, LLVM, arm64) record the same frames (decision 0104) and print them in main’s error report; test reports, handler logs, and crash bundles do not show them there yet (10.1).
11.2 Crash bundles
On a fault reaching main, an unhandled error from main, or an isolated
fault in a server, the runtime writes <name>-<time>.xocrash containing: the
error or fault with frames, the task tree, build id, source hashes, target, and
[P2] the effect trace for the failing request. Native builds (--no-gc)
write none yet (decision 0085).
11.3 Record and replay [P2]
All effect calls go through capability values, so the runtime can log each call and its result, plus scheduler decisions.
xo run --record=trace.xotrace(orXO_RECORD=trace.xotracefor any built binary) records a full run. Values of typeSecret[T]are recorded redacted;xo replaythen requires them from the environment and exits with status 3 naming the missing one.- Calls into Go packages (
use go, 4.4) are recorded at the boundary (decision 0093). A call whose arguments and results are all boundary values (scalars,Str,Bytes,Time,Duration, lists and maps of those,go.Errorwith its text and Go type) and that takes no callback is an effectgo.call(<name>, <argument hash>)with its results;xo replaychecks the name and the hash (a different argument diverges) and returns the recorded results without calling Go. A Go panic is recorded and raised again as the same fault. A call whose result, an argument, or the receiver is a Go handle, or that takes an Xo callback, runs again in the replay: a handle’s Go state exists only if the calls that make and change it run (sync.WaitGroup.waitmust wait for real). The trace marks such a call ("x": "handle"or"callback"), the replay checks that it comes at the same point, andxo replayreports it once per name:re executed foreign call `os.open`. Functions markedreplay rerun(4.4) run again and are not recorded. A trace written before decision 0093 (no"foreign"field in its header) runs every Go call again, as before. - Calls into C (
use c, 4.5) follow the same rule once native builds record (10.1: they do not yet): a call whose arguments and results are boundary values is answered from the trace asffi.call(<name>, <argument hash>), and one that passes or returns a C handle runs again. Each binding’s doc comment already says which applies (/// Replay: by valueor/// Replay: a C handle crosses). - An Xo callback that Go calls on a goroutine of its own is recorded (where the scheduler admitted it, with its arguments) and replayed at the same point, and a replay diverges when Go calls it with other arguments (decision 0073).
- In production, a per request ring buffer (
XO_RING=Nrequests) is kept and attached to crash bundles. xo replay trace.xotrace(or a.xocrash) re executes the program offline with identical effect results and interleavings.xo debug --replay trace.xotracesupports stepping backward.xo why <expr> --at file:linetraces where a value came from (the assignments, calls, and effect results that produced it) using the replay.
11.4 Live inspection
xo dump <pid>(or SIGQUIT) prints the task tree: each scope, its children, and what each task is blocked on, with locations. The program keeps running; the dump is also written to$XO_DUMP_DIR(or the temp dir) asxo-dump-<pid>.txt.- [P2] Deadlocks are detected by the runtime and reported as a wait cycle
with locations (fault
XO-F001, exit status 70, crash bundle). A task counts as blocked when it waits on another task (channel, mutex, semaphore,await, scope end,Once); waits on timers,after,task.stopping(), signals, and I/O do not. In test worlds the report is immediate; real runs report after 200 ms with no progress. A task inside a Go call (use go) counts as blocked only while it waits for its own Go callbacks running on goroutines of Go’s, those callbacks are blocked in turn, and every goroutine’s stack shows that nothing else can move (the Go call parked in asyncwait, no goroutine running, sleeping, or in I/O); the report shows the edge asblocked in Go call sync.(*WaitGroup).Wait at ... for callback 3. A Go call waiting on a Go channel, select, or timer is never reported, and nothing is reported while any Go runtime timer is pending (atime.AfterFuncmay release the wait; the check looks again after it fires); builds for targets other than darwin and linux never report it (decision 0073). Native builds (decision 0068), which have no timers, I/O, or signals, report at once when every task is blocked and none is queued, with the fault’s first line only (no location or list of blocked tasks yet). dbg(expr)prints the expression source and itsDebugvalue, returns the value, and is rejected byxo build --ci.
11.5 Debugger protocol
xo debug --json speaks a JSON line protocol (breakpoints, conditional
breakpoints, break on contract failure, eval, step, step back with replay).
xo debug --dap speaks the Debug Adapter Protocol for editors.
[P1] The generated Go code carries //line file.xo:N directives, so Delve,
Go stack traces, and pprof report Xo source locations.
11.6 Observability [later]
Not implemented yet: no spans exist, log records carry no span or request
id, and XO_OTEL_ENDPOINT is not read.
- Every
scopeand every effect call is a trace span.std/logis structured and attaches the current span and request id automatically. - OpenTelemetry export is enabled by setting
XO_OTEL_ENDPOINT.
12. Targets and platform code [P1]
12.1 Targets
xo build --targets=all builds every supported target from any host:
linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 wasi/wasm32.
Binaries are static and builds are reproducible: the same source in the
same directory, lockfile, and toolchain version produce byte identical
output (the binary records the absolute source directory, which crash
bundles and xo replay use, so a copy elsewhere differs in those bytes).
Each build writes an SBOM (<bin>.sbom.json). [later] No SBOM is
written yet.
12.2 Platform specific code
There are no build tags and no filename based platform selection.
target is a predeclared compile time constant with fields
os: TargetOs (Linux Darwin Windows Wasi) and arch: TargetArch
(Amd64 Arm64 Wasm32):
pub fn config_dir(env: Env) -> Str ! Error uses env {
match target.os {
Linux => env.get("XDG_CONFIG_HOME") ?? "${env.home()?}/.config"
Darwin => "${env.home()?}/Library/Application Support"
Windows => env.get("APPDATA").or_err("APPDATA not set")?
Wasi => "/config"
}
}
- A
matchontarget.osortarget.archis resolved at compile time; only the chosen arm is code generated. - Every arm is type checked for every target on every
xo checkandxo build, so a broken Windows branch fails on a macOS machine. A missing OS is a non exhaustive match error. - Platform specific APIs live in
std/sys/<os>and can only be referenced from inside the matching arm (errorXO1001otherwise). [later] Nostd/sys/<os>module exists yet, soXO1001is reserved (10.6). - The native backends build
targetas a constant of the build’s target, so only the chosen arm is code generated (decision 0107).
13. Editions and compatibility [P1]
xo.moddeclares anedition. The compiler supports every edition ever released. Modules on different editions link together.- A new edition may change syntax and defaults, never the meaning of code in older editions.
xo fix --edition=YYYYmigrates a module mechanically and must leave it compiling. An edition is not released until its rewriter is complete.- The standard library follows the same rule:
stdAPIs are never removed within an edition; deprecated APIs ship rewrites inxo fix.
14. Standard library
The prelude (methods on builtin types) and the standard library surface are
specified in spec/std.md. Modules:
| Module | Contents |
|---|---|
std/http | Router, server, client, requests, responses, middleware, error to status mapping |
std/json | Derive based encode and decode, Patch[T] for partial updates |
std/log | Structured, ambient logging bound to spans |
std/task | isolate, with_timeout, is_canceled, Semaphore |
std/time | Time zones, civil dates and times, layouts with named fields (arithmetic is in the prelude; std.md 13). Go backend only so far |
std/testing | Fake capability types, generators, captured logs |
std/sql | Database handles over drivers, in memory driver (draft) |
std/crypto | SHA-256, HMAC, constant time compare, random tokens, password hashing |
std/path | Pure helpers for / separated paths |
std/go | go.Error, the error of a Go call (4.4) |
std/trace, std/config, std/text, std/sys/<os> | Later drafts |
15. Grammar summary (informal EBNF)
Every comma separated list allows a trailing comma and may span lines. The
P1 parser (internal/parser) is the reference implementation of this grammar.
File = { UseDecl } { TopDecl } .
UseDecl = "use" ImportPath [ "as" ident ]
| "use" "go" string [ "as" ident ] // 4.4
| "use" "c" string { "link" string } [ "as" ident ]
[ "uses" ( "none" | Effect { "," Effect } ) ] . // 4.5
ImportPath = PathSeg { "/" PathSeg } . // std/http, github.com/acme/x
PathSeg = PathChar { PathChar } . // xo-lang, rate-limit, v2, example.com
PathChar = letter | digit | "_" | "-" | "." . // ASCII only; no empty, `.`, or `..` segment (XO0158)
TopDecl = { DocComment } [ "pub" ] ( FnDecl | StructDecl | EnumDecl | IfaceDecl
| TypeDecl | ConstDecl ) | TestDecl .
FnDecl = "fn" [ Receiver ] ident [ TypeParams ] Params [ "->" Type ] [ ErrSpec ]
{ FnClause } Block .
FnSig = "fn" ident [ TypeParams ] Params [ "->" Type ] [ ErrSpec ] { FnClause } .
Receiver = "(" [ "var" ] ident ":" Type ")" .
TypeParams = "[" TypeParam { "," TypeParam } "]" .
TypeParam = ident [ ":" ( Type | "Effects" ) ] .
Params = "(" [ Param { "," Param } ] ")" .
Param = [ "var" ] ( ident ":" Type [ "=" Expr ] | "self" ) . // self: interface methods only
ErrSpec = "!" [ Type ] .
FnClause = "uses" ( EffectList | "(" EffectList ")" )
| "requires" [ "(" "debug" ")" ] Expr [ "else" Expr ]
| "ensures" [ "(" "debug" ")" ] Expr .
EffectList = Effect { "," Effect } .
Effect = ident [ "." ident ] .
Type = BaseType { "?" } .
BaseType = QualName [ "[" TypeArg { "," TypeArg } "]" ]
| "(" ")" | "(" Type ")" | "(" Type "," [ Type { "," Type } ] ")"
| FnType .
TypeArg = Type | EffectList . // effect sets for fx parameters
FnType = "fn" "(" [ FnTParam { "," FnTParam } ] ")" [ "->" Type ] [ ErrSpec ]
[ "uses" ( Effect | "(" EffectList ")" ) ] .
FnTParam = [ "var" ] Type .
StructDecl = "struct" ident [ TypeParams ] "{" { Field } "}" [ Invariant ] [ Derive ] .
Field = { DocComment } [ "pub" ] ident ":" Type [ "=" Expr ] .
Invariant = "invariant" Expr .
Derive = "derive" QualName { "," QualName } .
EnumDecl = "enum" ident [ TypeParams ] "{" { Variant } "}" [ Derive ] .
Variant = { DocComment } ident [ "(" VField { "," VField } ")" ] .
VField = ident ":" Type | "from" Type .
IfaceDecl = "interface" ident [ TypeParams ] "{" { FnSig } "}" .
TypeDecl = "type" ident [ TypeParams ] ( "=" Type | "struct" "(" Type ")" ) .
ConstDecl = "const" ident [ ":" Type ] "=" Expr .
TestDecl = "test" string [ "for" Param { "," Param } ] [ "seed" int ]
[ "uses" EffectList ] Block .
Stmt = "let" Pattern [ ":" Type ] "=" Expr
| "var" ident [ ":" Type ] "=" Expr
| ConstDecl
| Place AssignOp Expr
| "defer" ( Stmt | Block ) | "return" [ Expr ]
| "break" [ ident ] | "continue" [ ident ]
| ForStmt | WhileStmt | Expr .
AssignOp = "=" | "+=" | "-=" | "*=" | "/=" | "%=" .
Place = ident { "." ident | "." int | "[" Expr "]" } .
ForStmt = [ ident ":" ] "for" Pattern "in" Expr [ "bound" Expr ] Block .
WhileStmt = [ ident ":" ] "while" [ ( Expr | "let" Pattern "=" Expr ) [ "bound" Expr ] ] Block .
Expr = IfExpr | MatchExpr | ScopeExpr | SelectExpr | Block | FnLit | Binary
| "while" Block | "???" . // infinite loop as an expression, type Never
Binary = Operand { BinOp Operand } .
Operand = Unary [ "is" Pattern ] . // `is` binds like `==`
FnLit = "fn" "(" [ LParam { "," LParam } ] ")" [ "->" Type ] [ ErrSpec ]
[ "uses" ( EffectList | "(" EffectList ")" ) ] Block .
LParam = [ "var" ] ident [ ":" Type ] .
IfExpr = "if" ( Expr | "let" Pattern "=" Expr ) Block [ "else" ( IfExpr | Block ) ] .
MatchExpr = "match" Expr "{" { Pattern [ "if" Expr ] "=>" ( Expr | Stmt ) } "}" .
ScopeExpr = "scope" ident Block .
SelectExpr = "select" "{" { SelectArm } "}" .
SelectArm = ( [ ident "=" ] Expr | "after" Expr ) "=>" ( Expr | Stmt ) .
Postfix = Primary { "." ident | "." int | Call | Index | "?" | "with" StructLit } .
Index = "[" ( Expr | [ Expr ] ( ".." | "..=" ) [ Expr ] ) "]" .
Call = [ "[" TypeArg { "," TypeArg } "]" ] "(" [ Arg { "," Arg } ] ")" .
Arg = [ ident ":" ] Expr .
Literal = int | float | duration | string | rawstring | bytes | rune | "true" | "false" .
Pattern = PatAlt { "|" PatAlt } .
PatAlt = "_" | ident | [ "-" ] Literal [ ( ".." | "..=" ) [ "-" ] Literal ]
| "(" ")" | "(" Pattern ")" | "(" Pattern "," [ Pattern { "," Pattern } ] ")"
| [ QualName "." ] ident [ "(" ( ".." | PatFields ) ")" ] // variant
| QualName "{" PatFields "}"
| "[" [ PatElem { "," PatElem } ] "]" . // ".." at most once
PatElem = Pattern | ".." .
PatFields = PatField { "," PatField } [ "," ".." ] .
PatField = [ ident ":" ] Pattern .
QualName = [ ident "." ] TypeName . // optional module prefix
TypeName = ident .
Operator precedence, highest first: postfix (. () [] ?), unary (- ! ~),
* / % << >> &, + - | ^, .. ..=, == != < <= > >= is, &&, ||, ??.
Parsing rules the grammar alone does not settle:
- Type arguments vs. indexing.
x[...]holds type arguments when every element looks like a type (UpperCamel,mod.UpperCamel,(, orfn) and either(follows orxis anUpperCameltype name; otherwise it is an index. Sojson.decode[User](b)andMutex[Int].new(0)take type arguments, whilexs[i]andm[Red]index. An effect argument (a lowercasenameorname.sub) also counts as a type element, except that a base spelled like a constant (UPPER_SNAKE) with only lowercase names in the brackets is an index:NAMES[i](decision 0143). {in expression position is an empty map when it is{}, a map literal when a:appears at depth zero before the first newline, and a block otherwise. A match arm=> {}is therefore an empty map; write()for unit.- Function types in return position take a trailing
usesthemselves:-> fn(A) -> B uses clock {gives the effect to the returned function. Write-> (fn(A) -> B) uses clock {to put it on the declaration. - In a function type, a
useslist with more than one effect must be parenthesized, because a following comma would otherwise be ambiguous. - Only
while { }(no condition) may appear in expression position. - Triple quoted strings support escapes and interpolation; a blank first and last line are dropped; the common indentation is measured on the source.
Appendix A: complete example
// api/main.xo
use std/http
use std/log
pub struct User {
pub id: Int
pub name: Str
pub email: Str?
} derive Json
pub enum ApiErr {
BadRequest(msg: Str)
NotFound(id: Int)
}
// http maps handler errors to responses through this interface.
fn (e: ApiErr) status() -> Int {
match e {
BadRequest(_) => 400
NotFound(_) => 404
}
}
fn (e: ApiErr) display() -> Str {
match e {
BadRequest(msg) => msg
NotFound(id) => "user ${id} not found"
}
}
struct Store {
users: Mutex[Map[Int, User]]
}
fn (s: Store) get(id: Int) -> User ! ApiErr
requires id > 0 else ApiErr.BadRequest(msg: "id must be positive")
{
s.users.with(fn(m) { m.get(id) }).or_err(ApiErr.NotFound(id: id))?
}
fn routes(store: Store) -> http.Router {
var r = http.router()
r.handle("GET /users/{id:Int}", fn(req) -> http.Response ! ApiErr {
let u = store.get(req.param_int("id"))?
http.json(200, u)
})
r
}
fn main(os: Os) ! {
let store = Store{users: Mutex.new({1: User{id: 1, name: "Ada", email: None}})}
let srv = http.server(os.net.listen(":8080")?, routes(store))
log.info("listening", {"addr": ":8080"})
srv.serve()?
}
test "missing user is 404" {
let store = Store{users: Mutex.new({})}
let resp = routes(store).call(http.new_request("GET", "/users/9"))
expect(resp.status) == 404
}
Appendix B: open questions
Tracked in knowledgebase/decisions/ once decided.
Method syntax: Go style receivers orDecided in 0090: receivers.implblocks.Decided in 0091: insertion order.Mapiteration order.Error frame capture cost in release builds.Decided in 0092: frames on in every build, captured lazily (implemented on the Go backend; the native backends record them too, decision 0104).HowDecided in 0019.httphandler errors map to status codes.FFI design and record/replay.Decided in 0093:use goand nativeuse c, foreign calls recorded at the boundary (use gorecording implemented;use cis implemented (4.5, decision 0116) without recording, since native builds do not record yet).Interpolation syntax.Decided in 0021:${x}.