Xo is experimental. The source code is not public yet; it will be soon. Read the release notes
Menu · Standard library

Docs

Xo Standard Library and Prelude

Status: draft 0.8 (2026-10-09; the same draft number as spec/core.md, decision 0089) Companion to spec/core.md.

Signatures are written in Xo syntax. [var] before a receiver means the method mutates and needs a var binding (core 3.4). Effects are listed with uses. Everything here is part of the 2026 edition compatibility promise once the edition is released.

0. Conventions

  • Capabilities and fakes are handles. Copying a Net, Fs, Clock, … or a test fake shares the same underlying resource. They can be captured in closures and stored in structs.
  • Every error type implements Display. So any std error converts to Error at a ? site.
  • Every error type returned by a blocking operation has a Canceled variant (core 7.2).
  • Functions that cannot fail have no !. Lookups that may miss return T?.
  • Braces are literal in plain strings (interpolation is ${x}), so route patterns are ordinary strings: "GET /users/{id}".
  • Fields of std structs are pub: they can be read and the structs can be constructed (useful for testing pure functions).

1. Prelude interfaces

Available everywhere without use.

interface Display { fn display(self) -> Str }
interface Error   { fn display(self) -> Str }   // any Display type is an Error
interface Debug   { fn debug(self) -> Str }     // implemented for every type

Eq, Hash, and Ord are builtin interfaces with no user written methods. Eq and Hash are automatic when all fields implement them (handles and function values never do, so a value holding one is not comparable: core 2.9, decision 0112); Ord comes from derive Ord (field order, then variant order). Builtin scalar types, Str, Duration, and Time are Ord. Float is Ord with a total order in which NaN sorts after every other value (all NaNs are equal in the order) and -0.0 and 0.0 are equal: -Inf < ... < -0.0 = 0.0 < ... < +Inf < NaN (decision 0053). This order drives <, <=, >, >= on Float (core 2.1), sort and sort_by (stable, so -0.0 and 0.0 keep their input order and NaNs end up last), derived Ord on types with Float fields, T: Ord generic code, and min, max, clamp. == on Float is IEEE (NaN differs from itself), not this order.

Int, the sized integers, Float, Bool, Rune, Str, Duration, Time, and every std error type implement Display, and so does every type with derive Display: e.display() is callable on each of them (the same text as "${e}"), as well as through a T: Display bound or an Error value. Bytes does not (use to_str()). Json is implemented by derive Json and by builtin types (section 5). SqlRow is implemented only by derive SqlRow (section 10.4).

Every Ord type has a.min(b), a.max(b), a.clamp(lo, hi). a.min(b) is b when b < a and otherwise a; a.max(b) is b when b > a and otherwise a: on a tie the receiver is kept ((-0.0).min(0.0) is -0.0, (0.0).min(-0.0) is 0.0). Under the Float order x.min(NaN) is x and x.max(NaN) is NaN. a.clamp(lo, hi) is a.max(lo).min(hi).

When a value is passed where an interface type is expected (for example a Map[Str, Debug] literal), it is converted to that interface implicitly (core 2.10).


2. Prelude methods on builtin types

2.1 Numbers

Int.parse(s: Str) -> Int?
Float.parse(s: Str) -> Float?
Int(x: Float) -> Int                 // truncates toward zero; faults on NaN or out of range
Float(x: Int) -> Float
Int32(x: Int) -> Int32               // faults out of range; same for every sized type
Int32.try(x: Int) -> Int32?
Int(r: Rune) -> Int                  // the code point; every integer type (core 2.1)
Rune(n: Int) -> Rune                 // faults unless n is a Unicode scalar value
Rune.try(n: Int) -> Rune?

fn (n: Int) abs() -> Int
fn (n: Int) pow(e: Int) -> Int
fn (n: Int) wrapping_add(m: Int) -> Int      // also wrapping_sub, wrapping_mul
fn (n: Int) saturating_add(m: Int) -> Int    // also saturating_sub, saturating_mul
fn (n: Int) checked_add(m: Int) -> Int?      // also checked_sub, checked_mul, checked_div

fn (f: Float) floor() -> Float               // also ceil, round, trunc, abs, sqrt
fn (f: Float) is_nan() -> Bool

A conversion is a call on the type name, Float(n), not an associated function: there is no Float.from(n). XO0601 for an unknown associated function of a numeric type called with one numeric argument (Float.from(n), Int.from(x), Int64.new(n)) carries the note convert with Float(x) and, as its first fix, the edit to Float(n).

2.2 Str, Rune, Bytes

Str is UTF-8. + concatenates Str and Bytes. Bytes holds any bytes; build one piece by piece with BytesBuilder (2.7), whose appends are amortized O(1) where out = out + more copies out each time.

fn (s: Str) len() -> Int                     // bytes
fn (s: Str) rune_count() -> Int
fn (s: Str) is_empty() -> Bool
fn (s: Str) contains(sub: Str) -> Bool
fn (s: Str) starts_with(p: Str) -> Bool
fn (s: Str) ends_with(p: Str) -> Bool
fn (s: Str) find(sub: Str) -> Int?           // byte offset
fn (s: Str) split(sep: Str) -> List[Str]
fn (s: Str) lines() -> List[Str]             // splits on \n, strips \r
fn (s: Str) trim() -> Str                    // also trim_start, trim_end
fn (s: Str) trim_prefix(p: Str) -> Str       // also trim_suffix
fn (s: Str) to_lower() -> Str                // also to_upper (Unicode aware)
fn (s: Str) replace(old: Str, new: Str) -> Str
fn (s: Str) repeat(n: Int) -> Str
fn (s: Str) runes() -> List[Rune]
fn (s: Str) bytes() -> Bytes

fn (b: Bytes) len() -> Int                   // b[i] is the byte at i, a UInt8
fn (b: Bytes) is_empty() -> Bool             // `for x in b` yields each byte (core 3.5)
fn (b: Bytes) to_str() -> Str?               // None if not valid UTF-8
fn (b: Bytes) to_hex() -> Str                // lowercase
fn (b: Bytes) to_base64() -> Str             // standard encoding, padded
Bytes.from_hex(s: Str) -> Bytes?
Bytes.from_base64(s: Str) -> Bytes?
fn (b: Bytes) rune_count() -> Int            // an invalid byte counts as one rune
fn (b: Bytes) decode_rune(i: Int) -> (Rune, Int)  // the rune at byte i and its size
fn (b: Bytes) quote() -> Str                 // also on Str: a quoted literal

fn (r: Rune) is_digit() -> Bool              // also is_letter, is_space, to_lower, to_upper
fn (r: Rune) is_print() -> Bool
fn (r: Rune) quote() -> Str                  // 'a', '\n', '\u2028'

Unicode and quoting follow Go’s unicode, unicode/utf8, and strconv packages exactly (decision 0079), so text a Go program prints can be reproduced:

  • is_letter is a letter (category L), is_digit a decimal digit (Nd), is_space white space (\t \n \v \f \r, space, U+0085, U+00A0, and category Zs, plus U+2028 and U+2029), is_print strconv.IsPrint (a letter, mark, number, punctuation, symbol, or the ASCII space).
  • decode_rune(i) reads the UTF-8 sequence at byte i: (U+FFFD, 1) for an invalid or short sequence, (U+FFFD, 0) at i == len(), and a fault when i is negative or past the end.
  • r.quote() is strconv.QuoteRune: single quotes, \' and \\ escaped, printable runes as they are, \a \b \f \n \r \t \v, then \xHH below U+0080, \uHHHH, or \UHHHHHHHH.
  • s.quote() and b.quote() are strconv.Quote: double quotes, the same escapes with \" in place of \', and every byte of an invalid UTF-8 sequence as \xHH.

Slicing (s[a..b]) is in core 3.6.

2.3 List[T]

fn (xs: List[T]) len() -> Int
fn (xs: List[T]) is_empty() -> Bool
fn (xs: List[T]) get(i: Int) -> T?
fn (xs: List[T]) first() -> T?               // also last
fn (xs: List[T]) contains(x: T) -> Bool      // T: Eq
fn (xs: List[T]) index_of(x: T) -> Int?      // T: Eq
fn (xs: List[T]) indexed() -> List[(Int, T)]
fn (xs: List[T]) map[U](f: fn(T) -> U) -> List[U]
fn (xs: List[T]) filter(f: fn(T) -> Bool) -> List[T]
fn (xs: List[T]) fold[A](init: A, f: fn(A, T) -> A) -> A
fn (xs: List[T]) find(f: fn(T) -> Bool) -> T?
fn (xs: List[T]) any(f: fn(T) -> Bool) -> Bool   // also all
fn (xs: List[T]) sort() -> List[T]           // T: Ord, stable
fn (xs: List[T]) sort_by[K: Ord](key: fn(T) -> K) -> List[T]
fn (xs: List[T]) reverse() -> List[T]
fn (xs: List[T]) to_set() -> Set[T]
(0..n).to_list()                             // Range[Int] to List[Int]
fn (xs: List[Str]) join(sep: Str) -> Str
xs + ys                                      // concatenation

fn ([var] xs: List[T]) push(x: T)
fn ([var] xs: List[T]) pop() -> T?           // removes the last element
fn ([var] xs: List[T]) insert(i: Int, x: T)
fn ([var] xs: List[T]) remove_at(i: Int) -> T
fn ([var] xs: List[T]) set(i: Int, x: T)     // also xs[i] = x on a var list

Higher order methods (map, filter, …) pass through the callback’s effects and errors through effect variables (core 6.2); map with a failing callback returns List[U] ! E.

2.4 Map[K, V] and Set[T]

Insertion ordered. put on an existing key keeps its position.

fn (m: Map[K, V]) len() -> Int
fn (m: Map[K, V]) is_empty() -> Bool
fn (m: Map[K, V]) get(k: K) -> V?
fn (m: Map[K, V]) get_or_fault(k: K, reason: Str) -> V   // faults with the key and reason when missing
fn (m: Map[K, V]) contains(k: K) -> Bool
fn (m: Map[K, V]) keys() -> List[K]
fn (m: Map[K, V]) values() -> List[V]
fn (m: Map[K, V]) entries() -> List[(K, V)]
fn (m: Map[K, V]) filter(f: fn(K, V) -> Bool) -> Map[K, V]

fn ([var] m: Map[K, V]) put(k: K, v: V)
fn ([var] m: Map[K, V]) remove(k: K) -> V?
fn ([var] m: Map[K, V]) update[R](k: K, init: V, f: fn(var V) -> R) -> R
    // reads k (or init if missing), runs f, stores the result, returns f's value

fn (s: Set[T]) len() -> Int
fn (s: Set[T]) contains(x: T) -> Bool
fn (s: Set[T]) union(o: Set[T]) -> Set[T]    // also intersect, difference
fn (s: Set[T]) to_list() -> List[T]
fn (s: Set[T]) is_empty() -> Bool
fn (s: Set[T]) any(f: fn(T) -> Bool) -> Bool   // also all
fn (s: Set[T]) filter(f: fn(T) -> Bool) -> Set[T]
fn (s: Set[T]) map[U](f: fn(T) -> U) -> Set[U]
fn ([var] s: Set[T]) add(x: T)
fn ([var] s: Set[T]) remove(x: T) -> Bool

Iterating a var collection while mutating it iterates the value as it was when the loop started.

2.5 Option[T] and Result[T, E]

fn (o: T?) is_some() -> Bool                 // also is_none
fn (o: T?) or_err[E](e: E) -> Result[T, E]
fn (o: T?) map[U](f: fn(T) -> U) -> U?
fn (o: T?) and_then[U](f: fn(T) -> U?) -> U?
fn (o: T?) or_fault(msg: Str) -> T           // faults on None
o ?? default                                 // T

fn (r: Result[T, E]) is_ok() -> Bool         // also is_err
fn (r: Result[T, E]) map[U](f: fn(T) -> U) -> Result[U, E]
fn (r: Result[T, E]) map_err[F](f: fn(E) -> F) -> Result[T, F]
fn (r: Result[T, E]) ok() -> T?
fn (r: Result[T, E]) err() -> E?
fn (r: Result[T, E]) or_fault(msg: Str) -> T // faults on Err, message includes the error
fn (r: Result[T, E]) ignore_err(reason: Str) -> T?
    // the only way to discard an error (core 2.6); logs the error and reason at debug level

2.6 Duration and Time

Operators are in core 2.1. Duration displays as 1.5s, 250ms; Time displays as RFC 3339 in UTC.

Duration.from_secs(s: Float) -> Duration      // rounds to the nearest nanosecond
Duration.from_secs_int(s: Int) -> Duration    // the same as `1s * s`; faults on overflow
Duration.from_millis(ms: Int) -> Duration
Duration.from_nanos(ns: Int) -> Duration
Duration.parse(s: Str) -> Duration ! DurationErr
fn (d: Duration) seconds() -> Float
fn (d: Duration) millis() -> Int             // truncated
fn (d: Duration) nanos() -> Int
fn (d: Duration) scale(f: Float) -> Duration // rounds to the nearest nanosecond
fn (d: Duration) ceil_seconds() -> Int
fn (d: Duration) abs() -> Duration           // durations may be negative

struct DurationErr { input: Str }  // displays as `invalid duration "1x": expected numbers with units ...`

Time.UNIX_EPOCH: Time
Time.from_unix(secs: Int) -> Time
Time.from_unix_millis(ms: Int) -> Time
Time.parse_rfc3339(s: Str) -> Time?
fn (t: Time) unix() -> Int
fn (t: Time) unix_millis() -> Int
fn (t: Time) format_rfc3339() -> Str

Duration.parse accepts Go’s time.ParseDuration syntax after trimming white space: an optional sign, then one or more decimal numbers (with an optional fraction) each followed by a unit ns, us (or µs), ms, s, m, h: "1.5s", "-2h45m", "300ms". "0" alone is zero. A number without a unit, an unknown unit, or a value past about 292 years is Err(DurationErr{input: s}). It is the form JSON decoding of a Duration accepts, and it reads back what Duration displays (decision 0088). Unlike Int.parse, it returns a Result: the error says what a duration looks like.

2.7 Concurrency and state types

Chan[T].new(cap: Int = 0) -> Chan[T]
fn (c: Chan[T]) send(x: T)                   // blocks; cancellation point
fn (c: Chan[T]) recv() -> T?                 // None when closed and drained
fn (c: Chan[T]) try_send(x: T) -> Bool         // false if full or closed; never blocks
fn (c: Chan[T]) try_recv() -> T?             // None if empty; never blocks
fn (c: Chan[T]) close()
fn (c: Chan[T]) len() -> Int

Mutex[T].new(v: T) -> Mutex[T]
fn (m: Mutex[T]) with[R, E, fx: Effects](f: fn(var T) -> R ! E uses fx) -> R ! E uses fx
fn (m: Mutex[T]) with_read[R, E, fx: Effects](f: fn(T) -> R ! E uses fx) -> R ! E uses fx

Atomic[T].new(v: T) -> Atomic[T]             // T is Int or Bool
fn (a: Atomic[T]) get() -> T
fn (a: Atomic[T]) set(v: T)
fn (a: Atomic[Int]) add(n: Int) -> Int       // returns the new value
fn (a: Atomic[Int]) max(n: Int) -> Int       // stores the larger value, returns it
fn (a: Atomic[T]) swap(v: T) -> T
fn (a: Atomic[T]) compare_and_swap(old: T, new: T) -> Bool

fn (t: Task[T, E]) await() -> T ! E          // written `t.await`

Buf[T].new() -> Buf[T]                       // also with_capacity(n)
fn ([var] b: Buf[T]) push(x: T)
fn (b: Buf[T]) len() -> Int
fn (b: Buf[T]) freeze() -> List[T]           // moves b
StrBuilder.new() -> StrBuilder
fn ([var] b: StrBuilder) write(s: Str)
fn (b: StrBuilder) freeze() -> Str           // moves b
BytesBuilder.new() -> BytesBuilder           // also with_capacity(n)
fn ([var] b: BytesBuilder) write(data: Bytes)
fn ([var] b: BytesBuilder) write_byte(x: UInt8)
fn ([var] b: BytesBuilder) write_str(s: Str)  // its UTF-8 bytes
fn ([var] b: BytesBuilder) write_rune(r: Rune) // its UTF-8 encoding
fn (b: BytesBuilder) len() -> Int            // also on StrBuilder
fn (b: BytesBuilder) freeze() -> Bytes       // moves b

Secret[T].new(v: T) -> Secret[T]
fn (s: Secret[T]) reveal() -> T

Mutex.with_read (decision 0063) is shared read only access: many readers or one writer. f gets the value read only (mutating it is an error, and its parameter cannot be declared var); nothing is written back. Readers run at the same time as each other; with waits until no reader is inside, and readers wait while a writer is inside.

  • Writers are preferred: while a with waits, a new with_read waits behind it, so a stream of readers cannot starve a writer. So a reader must not wait (on a channel, a task, or another lock) for a reader that has not entered yet: with a writer queued in between that is a deadlock (XO-F001, reported with the readers that hold the mutex).
  • In a real run the order among waiters is not fixed (as for with, decision 0047). Under xo test and --seed it is first come, first served, with the readers at the head of the queue entering together; readers inside together interleave at scheduling points like any tasks, and --explore enumerates those interleavings.
  • Waiting is a cancellation point; a canceled waiter leaves the queue (readers queued behind a canceled writer enter at once).
  • Calling with or with_read on a mutex the task already holds in either mode faults (XO-F004).
  • A value read in with_read and used in a later with on the same mutex gets the split critical section warning XO0804 (core 5.4).
struct Stats {
  hits: Int
  last: Str
}

fn record(m: Mutex[Stats], path: Str) {
  m.with(fn(var s) {
    s.hits += 1
    s.last = path
  })
}

fn report(m: Mutex[Stats]) -> Str {
  m.with_read(fn(s) { "${s.hits} hits, last ${s.last}" })
}

3. Capabilities

Predeclared interfaces. main(os: Os) receives the real ones; tests receive fakes (section 8).

3.1 Fs (uses fs.read / fs.write)

Paths use / on every OS. os.fs is rooted at the filesystem root, accepts absolute paths (/home/me, C:/data), and resolves relative paths against the working directory. In a sub capability, paths are relative to its root, "." and "/" both mean the root, and a path that escapes it (..) fails with Denied.

enum FsErr {
  NotFound(path: Str)
  Exists(path: Str)
  NotEmpty(path: Str)
  NotDir(path: Str)
  IsDir(path: Str)
  Denied(path: Str)
  Io(path: Str, msg: Str)
  Canceled
}

struct FileInfo {
  name: Str              // last path segment
  path: Str              // relative to the capability root
  is_dir: Bool
  size: Int
  modified: Time
}

fn (f: Fs) sub(dir: Str) -> Fs                               // lazy: dir need not exist yet
fn (f: Fs) cwd() -> Fs                                       // sub capability at the working directory
fn (f: Fs) read_only() -> Fs
fn (f: Fs) write_only() -> Fs                // reads fail with Denied
fn (f: Fs) case_sensitive() -> Bool                          // of the underlying filesystem
fn (f: Fs) stat(path: Str) -> FileInfo? ! FsErr  uses fs.read // None when missing
fn (f: Fs) exists(path: Str) -> Bool ! FsErr     uses fs.read
fn (f: Fs) read(path: Str) -> Bytes ! FsErr      uses fs.read
fn (f: Fs) read_text(path: Str) -> Str ! FsErr   uses fs.read
fn (f: Fs) read_dir(dir: Str) -> List[FileInfo] ! FsErr uses fs.read   // sorted by name
fn (f: Fs) walk(dir: Str) -> List[FileInfo] ! FsErr     uses fs.read
    // recursive, parents before children, siblings by name; excludes `dir` itself;
    // fails with NotFound if `dir` does not exist;
    // paths are relative to the capability root with no "./" prefix
fn (f: Fs) write(path: Str, data: Bytes) -> () ! FsErr  uses fs.write  // create or truncate
fn (f: Fs) create_temp(dir: Str, prefix: Str) -> Str ! FsErr uses fs.write
    // returns the new file's path, relative to the capability root
fn (f: Fs) rename(from: Str, to: Str) -> () ! FsErr     uses fs.write  // atomic, replaces a file
fn (f: Fs) remove(path: Str) -> () ! FsErr              uses fs.write  // file or empty dir
fn (f: Fs) remove_all(path: Str) -> () ! FsErr          uses fs.write
fn (f: Fs) create_dir_all(dir: Str) -> () ! FsErr       uses fs.write
fn (f: Fs) set_modified(path: Str, t: Time) -> () ! FsErr uses fs.write

Streaming file handles are planned for a later draft.

3.2 Net (uses net.dial / net.listen)

enum NetErr { InvalidAddr(msg: Str), Refused, Timeout, Io(msg: Str), Canceled }

fn (n: Net) listen(addr: Str) -> Listener ! NetErr uses net.listen
fn (n: Net) dial(addr: Str) -> Conn ! NetErr       uses net.dial
fn (l: Listener) addr() -> Str                     // "host:port" actually bound
fn (l: Listener) accept() -> Conn ! NetErr         uses net.listen
fn (l: Listener) close()
fn (c: Conn) read(max: Int) -> Bytes? ! NetErr     uses net   // None at the end of the stream
fn (c: Conn) write(data: Bytes) -> () ! NetErr     uses net   // writes every byte
fn (c: Conn) close()

TCP connections (decision 0071): accept waits for the next connection; read returns at most max bytes (at most 1 MiB; fewer when fewer are there) and None at the end of the stream; write returns when every byte is written. All three are cancellation points that report cancellation as Canceled (core 7.2). close is idempotent and makes a waiting accept, read, or write on the same listener or connection fail with Io. A refused dial is Refused.

addr reports the port the system chose for listen(":0"). An unspecified host (:0, 0.0.0.0, [::]) is reported as the loopback address (127.0.0.1, ::1), so the result can be dialed locally: "http://${l.addr()}/health". The fake network of tests gives 127.0.0.1:49152, 127.0.0.1:49153, … for :0. Under record and replay (core 11.3) the recorded address is returned (decision 0055).

Most code uses std/http instead of raw connections.

3.3 Clock (uses clock)

fn (c: Clock) now() -> Time
enum ClockErr { Canceled }

fn (c: Clock) sleep(d: Duration)             // cancellation point; unwinds when cancelled
fn (c: Clock) sleep_or_cancel(d: Duration) -> () ! ClockErr
    // like sleep, but cancellation is Err(Canceled) instead of an unwind
fn (c: Clock) since(t: Time) -> Duration

sleep_or_cancel lets a function return cancellation as its own error (core 7.2, decision 0088):

fn backoff(clock: Clock, d: Duration) -> () ! FetchErr uses clock {
  match clock.sleep_or_cancel(d) {
    Ok(_) => Ok(())
    Err(Canceled) => Err(FetchErr.Cancelled)
  }
}

3.4 Rand (uses rand)

Cryptographically secure for real capabilities, seeded and deterministic in tests.

fn (r: Rand) int(lo: Int, hi: Int) -> Int    // in [lo, hi)
fn (r: Rand) float() -> Float                // in [0, 1)
fn (r: Rand) bool() -> Bool
fn (r: Rand) bytes(n: Int) -> Bytes
fn (r: Rand) shuffle[T](xs: List[T]) -> List[T]

Native builds (--no-gc, decision 0083) draw from the operating system’s generator (getentropy, getrandom); shuffle draws one int per step as on the Go backend, so a test’s seeded shuffle is the same (decision 0107).

3.5 Env (uses env)

enum EnvErr { Missing(name: Str) }

fn (e: Env) get(name: Str) -> Str?
fn (e: Env) vars() -> Map[Str, Str]
fn (e: Env) args() -> List[Str]              // without the program name
fn (e: Env) program() -> Str
fn (e: Env) home() -> Str ! EnvErr

3.6 Stdio (uses stdio)

enum IoErr { Closed, Io(msg: Str), Canceled }

fn (s: Stdio) print(text: Str)               // write errors are ignored
fn (s: Stdio) println(text: Str)
fn (s: Stdio) eprint(text: Str)
fn (s: Stdio) eprintln(text: Str)
fn (s: Stdio) write(data: Bytes) -> () ! IoErr
fn (s: Stdio) read_line() -> Str? ! IoErr    // None at end of input
fn (s: Stdio) read_all() -> Str ! IoErr
fn (s: Stdio) lines() -> List[Str] ! IoErr   // reads all input

The reads of standard input are cancellation points while they wait: a cancelled task, and every reader once a stop is requested (core 7.5), gets Err(IoErr.Canceled) at once. Input read after a reader gave up is kept for the next read (decision 0088). The native backends read standard input the same way (decision 0102); a test binary’s standard input is empty there.

3.7 Proc (uses proc)

struct ProcOutput { code: Int, stdout: Bytes, stderr: Bytes }
enum ProcErr { NotFound(cmd: Str), Io(msg: Str), Canceled }

fn (p: Proc) exit(code: Int) -> Never        // exits immediately; defer blocks do not run
fn (p: Proc) set_grace(d: Duration)          // time between stop requested and cancel (default 30s)
fn (p: Proc) run(cmd: Str, args: List[Str]) -> ProcOutput ! ProcErr

The native backends run commands with posix_spawn, reading the output on the event loop; cancelling the task kills the command (decision 0107).

Returning Err from main prints the error with its frames to standard error and exits with status 1. Prefer that over exit for failures.


4. std/http

4.1 Routing

pub fn router() -> Router
fn ([var] r: Router) handle[E: Error, fx: Effects](pattern: Str, h: fn(Request) -> Response ! E uses fx) uses fx
fn ([var] r: Router) wrap[E: Error, fx: Effects](m: fn(Request, Next) -> Response ! E uses fx) uses fx
fn ([var] r: Router) mount(prefix: Str, sub: Router)   // sub's routes under prefix, behind sub's middleware
fn (r: Router) call(req: Request) -> Response      // in process dispatch, for tests
type Next = fn(Request) -> Response
  • Registering a handler or middleware counts as performing its effects, so the function that builds the router declares them. This is where capabilities are captured.
  • Pattern: "[METHOD ]/path". Segments: literal, {name} (any segment), {name:Int} (must parse as Int, else 400 before the handler runs), {name...} (rest of the path). Without a method, any method matches. plain strings: "GET /users/{id:Int}".
  • Unmatched path: 404. Path matched with another method: 405.
  • Middleware run in registration order, outermost first, and see the final response after error mapping.
  • Middleware may fail: Err(e) from a middleware is mapped to a response exactly like a handler’s (section 4.2), and the middleware registered before it see that response. A middleware that cannot fail has E = Never. Next itself cannot fail: it returns the mapped response of everything inside.
  • Scoped middleware: r.mount(prefix, sub) adds every route of sub to r with prefix (a path, segments as in patterns) in front of its pattern. A mounted route runs r’s middleware, then sub’s, then the handler. A request no route of sub matches never reaches sub’s middleware (it is a 404 or 405 of r). The routes are copied (Router is a value): registering on sub after mount does not change r. To guard one route, mount a router holding only that route. mount has no effects of its own; they were counted where sub registered them.
  • Router, Next, call, and serve carry no effects in their types: effects are accounted where handlers and middleware are registered, which is where their capabilities are captured.
// public routes on r, authenticated routes on api, no path comparison
fn routes(db: sql.Db) -> http.Router uses net.dial {
  var api = http.router()
  api.wrap(fn(req, next) -> http.Response ! AuthErr {
    let user = authenticate(db, req)?
    next(req.with_value(user_key(), user))
  })
  api.handle("GET /notes", fn(req) { list(db, req.value(user_key()).or_fault("set by auth")) })
  var r = http.router()
  r.handle("GET /health", fn(_req) { http.text(200, "ok") })
  r.mount("/api", api)
  r
}

4.2 Error to status mapping

interface StatusError {
  fn status(self) -> Int
  fn display(self) -> Str
}

When a handler returns Err(e):

  • if e implements StatusError, the response is e.status() with JSON body {"error": e.display()};
  • otherwise the response is 500 with {"error": "internal error"} and the error is logged with its frames.

A fault in a handler is isolated: 500, logged, crash bundle written. On the native backends (--no-gc, decisions 0074, 0083, and 0085) it is isolated the same way (the handler unwinds, the server keeps serving) but no crash bundle is written, and the error of a 500 is logged without frames. Streamed bodies (http.stream) are sent natively as net/http sends them.

4.3 Requests and responses

struct Request {
  method: Str
  path: Str
  url: Url
  headers: Map[Str, Str]     // canonical header keys, e.g. "Retry-After"
  body: Bytes
  remote_ip: Str
}
fn (r: Request) param(name: Str) -> Str             // faults if the pattern has no such segment
fn (r: Request) param_int(name: Str) -> Int         // segment must be declared {name:Int}
fn (r: Request) query(name: Str) -> Str?
fn (r: Request) header(name: Str) -> Str?           // case insensitive
fn (r: Request) json[T: Json]() -> T ! json.DecodeErr
fn (r: Request) with_header(name: Str, value: Str) -> Request

struct Key[T] {}                                   // handle
pub fn key[T](name: Str) -> Key[T]
fn (k: Key[T]) name() -> Str
fn (r: Request) with_value[T](k: Key[T], v: T) -> Request
fn (r: Request) value[T](k: Key[T]) -> T?

pub fn new_request(method: Str, url: Str, headers: Map[Str, Str] = {}, body: Bytes = b"") -> Request
    // remote_ip is "127.0.0.1"; for tests and clients

struct Response {
  status: Int
  headers: Map[Str, Str]
  body: Bytes
}
pub fn json[T: Json](status: Int, v: T) -> Response   // sets Content-Type
pub fn text(status: Int, s: Str) -> Response
pub fn empty(status: Int) -> Response
pub fn stream[fx: Effects](status: Int, f: fn(BodyWriter) -> () ! IoErr uses fx) -> Response uses fx
    // the body is produced by `f` while the response is sent (for large exports)
fn (w: BodyWriter) write(data: Bytes) -> () ! IoErr
fn (w: BodyWriter) flush() -> () ! IoErr
fn (r: Response) with_header(name: Str, value: Str) -> Response
fn (r: Response) header(name: Str) -> Str?          // case insensitive
fn (r: Response) text() -> Str?                     // the body as text; None if not valid UTF-8
fn (r: Response) decode_json[T: Json]() -> T ! json.DecodeErr

text and decode_json read the whole body, collecting a streamed one first; text is r.body.to_str() for a body that is not streamed. In tests: expect(resp.text()) == Some("ok"), or resp.text() ?? "".

Typed request values (decision 0055) let a middleware hand a value to the handlers behind it without encoding it in a header. with_value returns a copy of the request carrying v under k; value returns the latest value set under k, or None. A key is identified by its name and its type T: http.key[User]("user") called twice gives the same key, and http.key[Int]("user") is a different one, so a value is always read back at the type it was stored with. Values are local to the process: they are not headers, are never sent by a client or read from the network, take no part in ==, hash, or debug of a Request, and a fresh request (new_request, or one received by serve) has none. So a client cannot forge one. Declare a key once as a function or pass it around:

fn user_key() -> http.Key[User] { http.key("user") }

fn me(req: http.Request) -> http.Response ! ApiErr {
  let user = req.value(user_key()).or_err(ApiErr.Unauthorized)?
  http.json(200, user)
}

with_header returns a copy with the header set (canonical key), like Response.with_header.

4.4 Server

pub fn server(l: Listener, r: Router) -> Server
fn (s: Server) serve() -> () ! HttpErr uses net.listen
fn (s: Server) addr() -> Str                       // the listener's addr()
fn (s: Server) with_tls(certs: List[TlsCert]) -> Server   // HTTPS (4.6)
enum HttpErr { Io(msg: Str), Canceled }

serve runs until its scope is cancelled, then stops accepting, waits for in flight requests, and returns Ok(()). Each request runs as an isolated task with its own span and request id. Its memory may come from a request scoped arena (core 5.5, decision 0066), which no program can observe. A response with a body (not 1xx, 204, or 304) that is not a stream is sent with a Content-Length header unless the handler set one; only streamed bodies use chunked transfer encoding. To run a server alongside background work:

scope s {
  s.spawn_background(fn() { run_evictor(limiter, os.clock) })
  srv.serve()?
}

4.5 Client

pub fn client(net: Net, timeout: Duration = 30s) -> Client
fn (c: Client) get(url: Str) -> Response ! ClientErr uses net.dial
fn (c: Client) send(req: Request) -> Response ! ClientErr uses net.dial
fn (c: Client) post_json[T: Json](url: Str, v: T) -> Response ! ClientErr uses net.dial
fn (c: Client) with_roots(pem: Bytes) -> Client ! TlsErr   // 4.6

enum ClientErr {
  InvalidUrl(msg: Str)
  Connect(msg: Str)
  Timeout
  Protocol(msg: Str)
  Canceled
}

pub fn parse_url(s: Str) -> Url ! UrlErr
struct Url { scheme: Str, host: Str, port: Int?, path: Str, query: Map[Str, Str] }
struct UrlErr { msg: Str }

Any HTTP status is a successful Response; only transport failures are ClientErr. The client does not retry. It follows up to 10 redirects for GET and HEAD. parse_url accepts absolute RFC 3986 URLs with an http or https scheme and a host; anything else is a UrlErr. An https URL is requested over TLS (4.6).

4.6 TLS (HTTPS)

struct TlsCert {}                                  // handle: a certificate chain with its private key
pub fn tls_cert(cert_pem: Bytes, key_pem: Bytes) -> TlsCert ! TlsErr
struct TlsErr { msg: Str }
fn (s: Server) with_tls(certs: List[TlsCert]) -> Server
fn (c: Client) with_roots(pem: Bytes) -> Client ! TlsErr
fn serve_https(os: Os, r: http.Router) -> () ! Error uses (fs.read, net.listen) {
  let cert = http.tls_cert(os.fs.read("cert.pem")?, os.fs.read("key.pem")?)?
  http.server(os.net.listen(":8443")?, r).with_tls([cert]).serve()?
}
  • tls_cert takes a PEM certificate chain (leaf first) and the PEM private key of its leaf (PKCS #1, PKCS #8, or SEC 1; RSA or ECDSA), and checks that they match. Its errors are Go’s tls.X509KeyPair texts, for example tls: private key does not match public key. Reading the files is the caller’s (with Fs), so no capability is hidden in the call.
  • with_tls returns the server speaking HTTPS on its listener: TLS 1.2 and 1.3 with Go’s default cipher suites and ECDHE, certs[0] unless the client’s server name (SNI) is one of another certificate’s DNS names (wildcards in the leftmost label). An empty list makes serve fail with Io("http: with_tls needs a certificate"). Everything else in 4.4 holds (shutdown, the arena, Content-Length); a client that sends plain HTTP gets 400 with the body Client sent an HTTP request to an HTTPS server. and the connection closes. Failed handshakes are not logged. Client certificates are not requested.
  • HTTP/2 (decision 0121): over TLS a server offers h2 and http/1.1 by ALPN and prefers h2, and the client offers both; plain http is always HTTP/1.1 (no h2c). Nothing in the API changes, and every backend behaves as net/http’s HTTP/2 does: a request from Go’s client (or Xo’s) carries User-Agent: Go-http-client/2.0 and no Connection field, several Cookie fields arrive joined with ; , and a response’s Connection field is not sent (close ends the connection after its streams). Each request runs as a task of its own, many at once on a connection. Limits per connection: 250 concurrent streams, a header list of 1048896 bytes counting 32 per field (a larger one is answered 431), 1 MiB flow control windows and frames; a client that resets streams faster than their handlers end is disconnected. A fault in a streamed body resets its stream, so the client gets Protocol("stream error: stream ID 3; INTERNAL_ERROR; received from peer") (a failure while a response body is read has no method and URL in its text, as on Go). When serve stops, streams in flight finish. The client keeps an HTTP/2 connection for later requests to the same host, as Go’s Transport does (stream ids go up on it).
  • The client verifies an https server’s certificate chain and its host name (an IP address against the certificate’s IP addresses) against the system’s roots: macOS’s trust settings, the CA bundle and directories of Linux (SSL_CERT_FILE and SSL_CERT_DIR override them there). with_roots returns a client that trusts the certificates of pem instead (for a private CA or a test certificate); TlsErr("tls: no certificates found in PEM input") when it has none. The TLS handshake must finish within 10 seconds and within the client’s timeout, else Timeout.
  • TLS failures are ClientErrs with Go’s texts: a certificate that does not verify is Protocol("Get \"https://...\": tls: failed to verify certificate: x509: ...") (certificate signed by unknown authority, certificate is valid for a, b, not c, certificate has expired or is not yet valid: ...), a server answering plain HTTP is Protocol("...: http: server gave HTTP response to HTTPS client"), and an alert of the server (no shared cipher suite, a required client certificate) is Connect("remote error: tls: handshake failure").
  • Tests: FakeNet (8.1) and in memory listeners serve https URLs and with_tls servers in process, without TLS.
  • The native backends (--no-gc, decision 0105) implement this with Mbed TLS: the same behavior and texts, except that Ed25519 certificate keys are not accepted (tls_cert fails, a server presenting one fails verification), the key exchange never uses the post quantum X25519MLKEM768 group, and on macOS the system’s roots are checked by the Security framework through the same calls Go makes, so its texts match there too.

5. std/json

pub fn encode[T: Json](v: T) -> Bytes
pub fn encode_str[T: Json](v: T) -> Str
pub fn decode[T: Json](data: Bytes) -> T ! DecodeErr
pub fn decode_str[T: Json](s: Str) -> T ! DecodeErr

struct DecodeErr { path: Str, msg: Str }    // displays as "at $.user.email: expected string"

enum Patch[T] { Absent, Null, Value(v: T) } derive Json
fn (p: Patch[T]) apply_to(current: T) -> T    // Value(v) gives v; Absent and Null keep current
fn (p: Patch[T]) apply_opt(current: T?) -> T? // Absent keeps, Null clears, Value(v) sets

derive Json rules:

XoJSON
structobject, keys are field names
T? fieldmissing key or null decodes to None; None encodes as null
field with a defaultmissing key decodes to the default
other missing fieldDecodeErr
Patch[T] fieldmissing key is Absent, null is Null, value is Value(v)
unknown keysignored
Int, Float, Bool, Strnumber, number, boolean, string
List[T], Set[T]array
Map[Str, V]object
enum unit variantstring "Variant"
enum variant with fieldsobject {"Variant": {fields}}
tuple (A, B, ...)array [a, b, ...]; decodes only from an array of exactly that length (decision 0088)
()null
Durationstring "1.5s" (any Duration.parse form decodes)
TimeRFC 3339 string
Bytesbase64 string

Builtin types listed above implement Json, so http.json(200, {"msg": "hi"}) works without a struct.


6. std/log

Ambient: no capability, not an effect (core 6.1).

pub fn debug(msg: Str, fields: Map[Str, Debug] = {})   // also info, warn, error
pub fn configure(out: Stdio, level: Level = Level.Info, format: Format = Format.Text) uses stdio
enum Level { Debug, Info, Warn, Error }
enum Format { Text, Json }
struct Record { level: Level, msg: Str, fields: Map[Str, Str] }   // as captured in tests (8.6)
log.info("listening", {"addr": addr, "port": port})

Every record includes the time. [later] The current span and the request id (core 11.6) are not attached yet. Without configure, logs go to standard error as text at Info.

Under xo test (decision 0057) each test has its own log sink, starting at Info with no output configured. Records that would go to standard error are buffered instead and printed under the test’s result only when the test fails (or with xo test -v), like t.Log in Go; --json adds them as a log array to the test’s line. log.configure(os.stdio, ...) in a test (or in a main run by testing.run_main) sends records to the fake standard error instead, as in a program. Either way every record that passes the level is also kept for testing.logs() (section 8.6). Nothing logged in a test reaches the real standard error.


7. std/task

enum Timed[E] { TimedOut, Failed(from E) } derive Display
enum Isolated[E] { Faulted(info: Fault), Failed(from E) } derive Display
struct Fault { msg: Str, location: Str, frames: List[Str] } derive Display

pub fn with_timeout[T, E, fx: Effects](clock: Clock, d: Duration, f: fn() -> T ! E uses fx) -> T ! Timed[E]
    uses (clock, fx)
    // runs f in a child scope; on expiry cancels it, waits for it to unwind, returns Err(TimedOut)

pub fn isolate[T, E, fx: Effects](f: fn() -> T ! E uses fx) -> T ! Isolated[E] uses fx

pub fn is_canceled() -> Bool
pub fn stop_requested() -> Bool          // the first shutdown signal arrived (core 7.5)
pub fn stopping() -> Chan[()]            // closed when a stop is requested

pub fn map_concurrent[T, R, E, fx: Effects](items: List[T], limit: Int, f: fn(T) -> R ! E uses fx)
    -> List[Result[R, E]] uses fx
    // runs f on every item with at most `limit` at once; results are in input order;
    // one failure does not stop the others (use with_timeout for a deadline)

struct Group {}                              // handle; see core 7.1
fn (g: Group) spawn[T, E, fx: Effects](f: fn() -> T ! E uses fx) -> Task[T, E] uses fx
fn (g: Group) spawn_background[E, fx: Effects](f: fn() -> () ! E uses fx) uses fx

pub fn map_concurrent_until[T, R, E, fx: Effects](clock: Clock, d: Duration, items: List[T], limit: Int,
    f: fn(T) -> R ! E uses fx) -> List[Result[R, Timed[E]]] uses (clock, fx)
    // like map_concurrent, but items not finished after d are cancelled and give
    // Err(TimedOut); finished results are kept, in input order

pub fn once[T, E]() -> Once[T, E]           // Once is a handle: copies share it
fn (o: Once[T, E]) get[fx: Effects](f: fn() -> T ! E uses fx) -> T ! E uses fx
    // the first caller runs f; concurrent and later callers wait for and share its
    // result. If the running caller is cancelled before f finishes, the next
    // waiter runs f instead. A failed result is shared too; use a new Once to retry.
    // A fault in f is shared with every waiter as well.

pub fn semaphore(permits: Int) -> Semaphore  // Semaphore is a handle: copies share permits
fn (s: Semaphore) with[R, E, fx: Effects](f: fn() -> R ! E uses fx) -> R ! E uses fx
    // waits for a permit (cancellation point), runs f, releases the permit

When f cannot fail, E is Never and the Failed variant can be left out of a match (core 2.3).


8. std/testing

os, expect, and testing are predeclared in test blocks; expect and testing are also available anywhere in _test.xo files (core 9.2).

Native test binaries (xo test --no-gc, decision 0083) give the same fakes: the same virtual clock (from 2026-01-01T00:00:00Z), the same seeded Rand values, in memory listeners on ports from 49152, FakeNet handlers and failures, captured streams and log records, MemFs (decision 0108), set_input and reading standard input, FakeProc’s run and on_run (decision 0107), and os.tasks (canceled when the test ends, decision 0109). os.proc.exit ends the test (it passes), and inside run_main unwinds main back to it. testing.gen and property tests make the Go backend’s values for a seed, and a failing property test reports the same case and shrunk input (decision 0120). With xo test --real a test gets real capabilities for the effects it declares (with clock, net, or proc its clock is the real one).

struct TestOs {
  net: FakeNet
  fs: MemFs
  clock: FakeClock
  rand: Rand          // seeded from the test seed
  env: FakeEnv
  stdio: FakeStdio
  proc: FakeProc
  tasks: task.Group   // lives for the test; cancelled when the test ends
}

Each fake satisfies its capability interface and adds control methods.

8.1 FakeNet

fn (n: FakeNet) handle(pattern: Str, h: fn(http.Request) -> http.Response)
    // pattern: "[METHOD ]<absolute url pattern>", e.g. "GET https://api.test/users/{id}"
fn (n: FakeNet) fail(pattern: Str, err: http.ClientErr)       // every matching request, until clear_failures
fn (n: FakeNet) fail_next(pattern: Str, err: http.ClientErr)  // the next matching request only
fn (n: FakeNet) clear_failures()
fn (n: FakeNet) requests() -> List[http.Request]     // every request seen, including failed ones
  • http.client(os.net) calls registered handlers in process. Unhandled requests fail with ClientErr.Connect("no fake handler for GET ...").
  • os.net.listen(":0") gives an in memory listener, so a real http.server can be tested end to end with http.client(os.net). Its addr() is 127.0.0.1:49152, then :49153, … A request to a listener whose serve has not started yet waits until it starts, like a connection in a real socket’s backlog (a test that never serves it reports a deadlock naming the wait); after serve returns, requests fail with ClientErr.Connect.

8.2 FakeClock

Virtual time starting at 2026-01-01T00:00:00Z. When every task in the test is blocked, time jumps to the next timer. Timers due at the same instant fire in the order they were scheduled. Time does not move while cancelled tasks unwind. advance(d) fires every timer due within d, in order, and lets the woken tasks run before returning.

fn (c: FakeClock) advance(d: Duration)
fn (c: FakeClock) set(t: Time)
fn (c: FakeClock) sleeps() -> List[Duration]          // every sleep requested, in order
// FakeClock also has sleep_or_cancel (3.3), on virtual time

8.3 MemFs

In memory, case sensitive by default. write creates missing parent directories. write sets the modified time to the virtual clock’s now; rename keeps the source’s modified time. Paths in fail_next and files are relative to the test’s os.fs root; fail_next matches an operation if any of its path arguments equals the given path (for rename, either the source or the target).

enum FsOp { Read, Write, Rename, Remove, CreateDir, ReadDir }
fn (f: MemFs) fail_next(op: FsOp, path: Str, err: FsErr)   // next matching op fails once; `*` matches any run of characters within a segment
fn (f: MemFs) set_case_sensitive(on: Bool)
fn (f: MemFs) files() -> Map[Str, Bytes]                  // every file, by path

8.4 FakeEnv, FakeStdio, FakeProc

fn (e: FakeEnv) set(name: Str, value: Str)
fn (e: FakeEnv) set_args(args: List[Str])
fn (s: FakeStdio) set_input(text: Str)
fn (s: FakeStdio) output() -> Str                     // everything printed to stdout
fn (s: FakeStdio) errors() -> Str                     // everything printed to stderr
fn (p: FakeProc) exit_code() -> Int?                  // set when exit was called
fn (p: FakeProc) signal_after(d: Duration)            // next run_main gets SIGTERM after d (virtual time)
fn (p: FakeProc) on_run(cmd: Str, out: ProcOutput)

In a test, os.proc.exit(code) records the code and unwinds the code under test back to the test block, which continues.

pub fn run_main[fx: Effects](main: fn(Os) -> () ! Error uses fx, os: TestOs, args: List[Str] = []) -> Int
    uses fx
    // runs a program's main against fakes and returns its exit status
    // (0, 1 for an error returned from main, or the code passed to exit)

Std enums are named with their module (testing.FsOp.Rename); the prefix may be omitted when the expected type is known (fail_next(Rename, ...)).

8.5 Generators

Property tests (test "x" for v: T) generate values for any type built from builtin types, structs, and enums. testing.gen[T](seed: Int) -> T produces one value directly.

8.6 Captured logs

pub fn logs() -> List[log.Record]     // records logged so far in this test, oldest first

A record’s fields hold each value’s text form, in the order given. Only records at or above the sink’s level are kept (Info unless the test calls log.configure). Each test, and each case of a property test, starts with none; tasks of the test and a main run by run_main log to the same list. Outside a test, logs() is empty.

test "a failed purge is logged" {
  purge_once(broken_db(), os.clock)
  let recs = testing.logs()
  expect(recs.map(fn(r) { r.msg })) == ["purge failed"]
  expect(recs[0].level) == log.Level.Warn
}

9. std/path

Pure helpers for / separated paths, the form every Fs capability uses on every OS (section 3.1).

pub fn join(a: Str, b: Str) -> Str   // joins and cleans
pub fn dir(p: Str) -> Str            // all but the last element, "." if none
pub fn base(p: Str) -> Str           // the last element
pub fn ext(p: Str) -> Str            // extension with the dot, or ""
pub fn clean(p: Str) -> Str          // removes `.`, `..`, repeated `/`

10. std/sql

Status: draft, implemented with an in memory driver only (2026-10-08). Real drivers (Postgres, SQLite, …) are Go packages bound with use go and registered with rt.RegisterSqlDriver; none ships yet. Native builds (--no-gc, decision 0083) run the same in memory driver, ported to Xo, with the same results and error texts; there sql.open always reports an unknown driver (no Go drivers), max_open does not bound the in memory driver (it runs one statement at a time), and a Row’s Debug text is its fields.

10.1 Opening

struct Db {}                                 // handle: copies share the database

pub fn open(net: Net, driver: Str, dsn: Str,
            max_open: Int = 10, max_idle: Int = 2, max_lifetime: Duration = 30m)
    -> Db ! SqlErr uses net.dial
pub fn memory(schema: Str = "") -> Db ! SqlErr         // no capability, no effect
// std/testing: fake_db(schema: Str) -> Db ! SqlErr    // same as memory(schema)

A network driver is opened from the Net capability, so a function that is not given a Net (or an already opened Db) cannot reach a database. The in memory driver performs no I/O and needs no capability; it is what tests use. schema is a ; separated list of statements run on the fresh database.

Pooling: max_open bounds the operations and open transactions in flight on a Db; a task over the limit waits (a cancellation point, it gets Canceled). max_idle and max_lifetime are passed to the driver (the in memory driver ignores them). max_open below 1 is Param.

Every operation on a Db or Tx declares uses net.dial, because the Db may be network backed. Declaring more than the in memory driver performs is allowed (core 6.2), so the same code runs against both.

10.2 Parameters and values

enum SqlValue { Null, Integer(n: Int), Text(s: Str), Real(f: Float), Flag(b: Bool), At(t: Time) }
pub fn null() -> SqlValue
pub fn int(n: Int) -> SqlValue       // also str, float, bool, time

Parameters are written $1, $2, … and passed as a List[SqlValue] ([sql.str(name), sql.int(age)]). Decision: a closed enum rather than List[Display], because the driver needs the type of each value (an Int and its text look the same through Display), and the five variants cover the column types every driver has. The count must equal the highest placeholder number, else Param. Values are never interpolated into the statement by the library; build the statement text from constants.

10.3 Queries

fn (d: Db) query(q: Str, args: List[SqlValue] = []) -> List[Row] ! SqlErr uses net.dial
fn (d: Db) query_one(q: Str, args: List[SqlValue] = []) -> Row ! SqlErr uses net.dial   // NoRows if empty; extra rows ignored
fn (d: Db) exec(q: Str, args: List[SqlValue] = []) -> ExecResult ! SqlErr uses net.dial
fn (d: Db) ping() -> () ! SqlErr uses net.dial
fn (d: Db) close() -> () ! SqlErr               // later operations fail with Closed; idempotent

struct ExecResult { rows_affected: Int, last_insert_id: Int }   // id is 0 when the driver has none

query runs a statement that returns rows, exec one that does not. A write with a RETURNING clause returns rows, so it goes through query (or query_one); through exec its rows are dropped.

10.4 Rows

struct Row {}
fn (r: Row) columns() -> List[Str]
fn (r: Row) is_null(name: Str) -> Bool ! SqlErr
fn (r: Row) int(name: Str) -> Int ! SqlErr       // also str, float, bool, time
fn (r: Row) int_opt(name: Str) -> Int? ! SqlErr  // also str_opt, float_opt, bool_opt, time_opt

Accessors read by column name (the first column of that name). A missing column is NoColumn; a value of another type, or NULL read by a non optional accessor, is Mismatch(column, msg). float also accepts an integer. The _opt forms map NULL to None.

Rows into structs (decision 0061):

interface SqlRow {}                              // prelude marker; only `derive SqlRow` implements it
fn (r: Row) decode[T: SqlRow]() -> T ! SqlErr
fn (d: Db) query_as[T: SqlRow](q: Str, args: List[SqlValue] = []) -> List[T] ! SqlErr uses net.dial
fn (d: Db) query_one_as[T: SqlRow](q: Str, args: List[SqlValue] = []) -> T ! SqlErr uses net.dial
fn (t: Tx) query_as[T: SqlRow](q: Str, args: List[SqlValue] = []) -> List[T] ! SqlErr uses net.dial
fn (t: Tx) query_one_as[T: SqlRow](q: Str, args: List[SqlValue] = []) -> T ! SqlErr uses net.dial

derive SqlRow is allowed on a struct without type parameters whose fields are Int, Float, Str, Bool, Time, or an Option of one of them (else XO0404 at the derive). decode reads each field from the column of the same name with the rules of the accessors above: a missing column is NoColumn(name), a value of another type is Mismatch(column, msg), NULL is None for an Option field and Mismatch for any other. Fields are read in declaration order and the first failure is returned. Columns without a field are ignored, so SELECT * works with a struct naming some of the columns. Field defaults do not apply: every field needs its column. query_as is query followed by decode of every row; query_one_as is query_one followed by decode (NoRows when there is no row).

struct Note {
  id: Int
  owner: Int
  body: Str
  expires: Time
  tag: Str?
} derive SqlRow, Json

fn notes(db: sql.Db, owner: Int) -> List[Note] ! sql.SqlErr uses net.dial {
  db.query_as[Note]("SELECT * FROM notes WHERE owner = $1 ORDER BY id", [sql.int(owner)])
}

fn add(db: sql.Db, owner: Int, body: Str, at: Time) -> Note ! sql.SqlErr uses net.dial {
  db.query_one_as[Note]("INSERT INTO notes (owner, body, expires) VALUES ($1, $2, $3) RETURNING *",
    [sql.int(owner), sql.str(body), sql.time(at)])
}

10.5 Transactions

fn (d: Db) begin() -> Tx ! SqlErr uses net.dial
fn (t: Tx) query(q: Str, args: List[SqlValue] = []) -> List[Row] ! SqlErr uses net.dial
fn (t: Tx) query_one(q: Str, args: List[SqlValue] = []) -> Row ! SqlErr uses net.dial
fn (t: Tx) exec(q: Str, args: List[SqlValue] = []) -> ExecResult ! SqlErr uses net.dial
fn (t: Tx) commit() -> () ! SqlErr uses net.dial
fn (t: Tx) rollback() -> () ! SqlErr uses net.dial

A transaction holds one pool permit from begin until commit or rollback; a driver that is a single writer (the in memory one) also allows one open transaction at a time, later begin calls wait. Commit a second time, or use a finished Tx, and you get TxDone. rollback on a finished Tx does nothing and succeeds, so the cleanup pattern is:

let tx = db.begin()?
defer { tx.rollback().ignore_err("nothing to undo if the commit ran") }
tx.exec(...)?
tx.commit()?

defer blocks are shielded (core 3.5), so the rollback still runs when the task is cancelled or the function returns an error. A statement that fails inside a transaction leaves the transaction usable and its earlier work intact. A Tx is a handle: copies share the transaction.

10.6 Errors and the in memory driver

enum SqlErr {
  Syntax(msg: Str), Constraint(msg: Str), Param(msg: Str), NoTable(name: Str),
  NoRows, NoColumn(name: Str), Mismatch(column: Str, msg: Str),
  Conn(msg: Str), Driver(msg: Str), TxDone, Closed, Canceled
} derive Display

Conn is a lost or refused connection, Driver any other driver failure (including an unknown driver name), Constraint a key, uniqueness, NOT NULL, or type violation.

The in memory driver (memory, testing.fake_db) supports: CREATE TABLE [IF NOT EXISTS] (types INT/INTEGER/BIGINT, TEXT/VARCHAR, REAL/FLOAT, BOOL, TIMESTAMP, SERIAL; PRIMARY KEY on a single integer column auto-assigns keys; NOT NULL, UNIQUE, DEFAULT literal), DROP TABLE, INSERT ... VALUES (several tuples), SELECT of *, expressions with AS, or COUNT(*) from one table with WHERE, ORDER BY (several keys, ASC/DESC), LIMIT/OFFSET, UPDATE ... SET ... WHERE, and DELETE ... WHERE. INSERT, UPDATE, and DELETE take RETURNING with the items of SELECT (*, expressions, AS; not COUNT(*)), yielding the inserted rows, the updated rows with their new values, or the deleted rows, in table order; generated keys and defaults are filled in. Expressions: columns, literals, $n, + - * /, = != <> < <= > >=, AND, OR, NOT, IS [NOT] NULL, [NOT] IN (...), [NOT] LIKE. No joins, grouping, or subqueries. Reads inside a transaction see its own writes; reads outside see the last commit. A write through query that fails changes nothing, like one through exec. Results of a Db are not recorded by record and replay (core 11.3) yet.


11. std/crypto

Status: draft (2026-10-08, decisions 0056 and 0059). Go standard library underneath, plus Argon2id and BLAKE2b vendored from golang.org/x/crypto into the runtime (no module dependency). Native builds (--no-gc, decision 0083) compute the same results in C (SHA-256, HMAC, PBKDF2, BLAKE2b, Argon2), with random bytes from the operating system (getentropy on macOS, getrandom on Linux).

pub fn sha256(data: Bytes) -> Bytes                     // 32 bytes
pub fn hmac_sha256(key: Bytes, msg: Bytes) -> Bytes     // 32 bytes
pub fn equal(a: Bytes, b: Bytes) -> Bool                // constant time in the contents
pub fn random_bytes(r: Rand, n: Int) -> Bytes uses rand // faults when n < 0
pub fn token(r: Rand, n: Int = 32) -> Str uses rand     // n random bytes, unpadded URL safe base64
pub fn hash_password_argon2id(r: Rand, password: Str, memory_kib: Int = 19456,
                              iterations: Int = 2, parallelism: Int = 1) -> Str uses rand
pub fn hash_password(r: Rand, password: Str, iterations: Int = 600000) -> Str uses rand
pub fn verify_password(password: Str, hash: Str) -> Bool
  • Hashing and comparison are pure: no capability, no effect.
  • Randomness comes only from the Rand capability (section 3.4), as everywhere else: cryptographically secure in programs, seeded from the test seed in tests, so a test that creates tokens or salts is reproducible. A function that makes tokens takes a Rand and declares uses rand.
  • equal takes time that depends on the lengths but not on the contents; compare digests of equal length (for example two sha256 results) when the length is secret too.
  • hash_password_argon2id is the recommended password hash: Argon2id (RFC 9106, version 0x13) with a 16 byte salt from r and a 32 byte key, encoded in the PHC string format $argon2id$v=19$m=<memory_kib>,t=<iterations>,p=<parallelism>$<salt>$<key> (salt and key in unpadded standard base64), which other Argon2 libraries read. The defaults, 19456 KiB (19 MiB), 2 passes, 1 lane, are the OWASP minimum for Argon2id (Password Storage Cheat Sheet); raise memory_kib first when the host allows. One hash at the defaults takes about 20 ms on an Apple M series core. It faults when parallelism is outside 1..=255, memory_kib outside 8 * parallelism..=4194304 (4 GiB), or iterations outside 1..=1000. Tests may pass small values (memory_kib: 64, iterations: 1).
  • hash_password is PBKDF2-HMAC-SHA256 with a 16 byte salt from r and a 32 byte key, encoded as pbkdf2-sha256$<iterations>$<salt>$<key> (salt and key in unpadded standard base64). It faults when iterations is outside 1..=100000000. The default, 600000, follows the OWASP 2023 guidance for PBKDF2-HMAC-SHA256; tests may pass a small count. PBKDF2 stays supported for existing hashes and for hosts that need a FIPS approved function; it is not memory hard, so prefer Argon2id for new code.
  • verify_password tells the formats apart by prefix ($argon2id$ or pbkdf2-sha256$), recomputes the key with the stored salt and parameters, and compares in constant time. A malformed hash, another Argon2 variant or version, or parameters outside the bounds above is false, never an error. To move users to Argon2id, rehash on the next successful login when the stored hash starts with pbkdf2-sha256$.

Storing API tokens: tokens from token have 256 bits of entropy, so a plain sha256 digest is enough (no salt or stretching needed) and can be looked up by value; compare the digest found with equal:

fn issue(db: sql.Db, r: Rand, user: Int) -> Str ! sql.SqlErr uses (rand, net.dial) {
  let t = crypto.token(r)
  db.exec("INSERT INTO tokens (user_id, digest) VALUES ($1, $2)",
    [sql.int(user), sql.str(crypto.sha256(t.bytes()).to_hex())])?
  t
}

Passwords chosen by people have little entropy: store hash_password_argon2id(os.rand, pw) and check with verify_password.


12. std/go

Values that cross the Go interop boundary (core 4.4, decision 0041). Bindings written by xo bind import this module; user code needs use std/go only to name the type, for example in ! go.Error.

struct Error {}                      // handle; implements Display, so it converts to Error at `?`
fn (e: Error) display() -> Str       // the Go error's Error() text, captured at the boundary
fn (e: Error) message() -> Str       // the same text
fn (e: Error) go_type() -> Str       // the Go dynamic type, for example "*url.Error"

Calling these performs no Go call (no go effect). The native backends do not build use go code at all (XO1201, impossible).


13. std/time

Status: Accepted (decision 0094, 2026-10-09; drafted by the spec audit, bench/results/spec-audit-2026-10-09.md). Implemented on the Go backend (2026-10-09): std/time.xo, std/rt/timezone.go, timecivil.go, timefmt.go; the compile time layout check is internal/check/timelayout.go. The native backends build all of it (2026-10-09: std/native/time.xo over std/llrt/xo_time.c, the same zone data per use, output identical to the Go backend’s; decision 0094 “Native implementation”; decision 0089 makes native a preview at 1.0). Points the API text below leaves open are settled in 13.5. Instants, durations, Unix seconds and milliseconds, and RFC 3339 in UTC stay in the prelude (2.6).

Principles:

  • Time stays an instant with no zone. A zone is a separate value, and a civil (wall clock) reading is a separate type, so code never compares a zoned and an unzoned value by accident.
  • Zone data comes with the program, embedded per use, so loading a zone by name is pure, needs no capability, and gives the same answer on every OS and in tests. utc() and fixed need no data; a zone name that is a constant embeds only that zone (about 1 to 3 KB); a name known only at run time embeds the whole IANA database (about 400 KB compressed) and xo build prints a note naming the call that caused it. --tzdata=system reads the host’s database instead (smaller binaries, host dependent answers). Only the machine’s own zone needs authority: local takes Env (TZ, then the system setting), so a function that depends on where it runs says so.
  • Layouts are written with named, lowercase fields, not Go’s reference date, not C’s % codes, and not the letter patterns of Java, C#, and Swift, where MM (month) and mm (minute) differ only in case: "{year}-{month}-{day} {hour}:{minute}:{second}". Common formats are constants.
  • Parsing returns a Result whose error names the position and the field expected, like json.DecodeErr.
  • Daylight saving gaps and overlaps are explicit: converting a civil time that does not exist or exists twice is an error unless the caller picks a rule.

13.1 Zones

struct Zone {}                                   // handle; Eq, Display (its name)
enum ZoneErr { Unknown(name: Str), NoLocal(msg: Str) }
fn (e: ZoneErr) display() -> Str                 // unknown time zone "Mars/Olympus"

pub fn utc() -> Zone
pub fn zone(name: Str) -> Zone ! ZoneErr         // IANA name: "Europe/Berlin"; pure (embedded data)
pub fn fixed(name: Str, offset: Duration) -> Zone   // "UTC+2" style; faults past 24h
pub fn local(env: Env) -> Zone ! ZoneErr uses env   // TZ, then the system zone

fn (z: Zone) name() -> Str
fn (z: Zone) offset_at(t: Time) -> Duration      // offset from UTC at that instant
fn (z: Zone) abbreviation_at(t: Time) -> Str     // "CET", "CEST"

In tests os.env is a fake, so local(os.env) is utc() unless the test sets TZ.

13.2 Civil time

struct Date { year: Int, month: Int, day: Int } derive Ord, Json
struct ClockTime { hour: Int, minute: Int, second: Int, nanos: Int } derive Ord, Json
struct Civil { date: Date, time: ClockTime, offset: Duration, zone: Str } derive Json
enum Weekday { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday } derive Display, Ord, Json
enum Ambiguity { Earlier, Later, Reject }
enum CivilErr { Invalid(msg: Str), Skipped(at: Str), Repeated(at: Str) }
fn (e: CivilErr) display() -> Str                // invalid civil time: month 13 out of range 1 to 12

pub fn civil(t: Time, z: Zone) -> Civil
pub fn instant(date: Date, time: ClockTime, z: Zone, ambiguity: Ambiguity = Ambiguity.Reject) -> Time ! CivilErr
pub fn date(year: Int, month: Int, day: Int) -> Date ! CivilErr
fn (d: Date) weekday() -> Weekday
fn (d: Date) add_days(n: Int) -> Date
fn (d: Date) add_months(n: Int) -> Date          // clamps the day: Jan 31 + 1 month is Feb 28 or 29
fn (d: Date) days_until(o: Date) -> Int
fn (d: Date) display() -> Str                    // "2026-10-09"

instant with Reject returns Skipped for a time inside a spring forward gap and Repeated for one inside a fall back overlap; Earlier and Later pick the first or second instant (for a gap, the instant the offset before or after the change gives). Arithmetic on Time stays exact (t + 24h is always 24 hours); calendar arithmetic is on Date.

13.3 Formatting and parsing

const RFC3339: Str = "{year}-{month}-{day}T{hour}:{minute}:{second}{frac}{offset}"
const RFC1123: Str = "{weekday:short}, {day} {month:short} {year} {hour}:{minute}:{second} {zone}"   // HTTP dates
const DATE: Str = "{year}-{month}-{day}"
const TIME: Str = "{hour}:{minute}:{second}"

struct ParseErr { input: Str, layout: Str, at: Int, msg: Str }
fn (e: ParseErr) display() -> Str                // at byte 5 of "2026-1x-09": expected 2 digit month
struct LayoutErr { layout: Str, msg: Str }
fn (e: LayoutErr) display() -> Str               // bad layout "{yr}": unknown field {yr}

pub fn format(t: Time, z: Zone, layout: Str) -> Str             // faults on a bad layout (constant layouts are checked at compile time)
pub fn parse(s: Str, layout: Str, z: Zone) -> Time ! ParseErr   // z applies when the text has no offset
pub fn check_layout(layout: Str) -> () ! LayoutErr

Fields, one spelling each: {year}; {month} 01 to 12, {month:1} without padding, {month:short} Jan, {month:name} January; {day} 01 to 31, {day:1}; {weekday:short} Mon, {weekday:name} Monday; {hour} 00 to 23, {hour12} 01 to 12 with {ampm} AM or PM; {minute}; {second}; {frac} a fraction with as many digits as needed (none when zero), {frac:3} exactly three (1 to 9); {offset} Z or +02:00, {offset:compact} +0200; {zone} the zone abbreviation. A literal brace is written {lbrace} or {rbrace}. A layout that is a constant is checked at compile time: an unknown field is an error with a did you mean fix. Letter patterns and % codes from other languages ({yyyy}, {MM}, %Y) are the habit error XO0177 with a machine fix to the named form (decision 0087). Names are English only; locales are out of scope. Parsing accepts exactly the layout (no guessing between formats); a two digit year is not offered.

13.4 Decided points

  • Zone data is embedded per use (above), with --tzdata=system as the opt in alternative.
  • format with a bad layout built at run time faults, like a bad index; check_layout returns a Result for code that builds layouts from input.
  • Native backends carry the same embedded data in C; the Go backend uses Go’s time package with the per use data. Implementation order: Go backend, checker rules, native runtime, cross backend diff tests.

13.5 Implementation notes (2026-10-09)

Settled while implementing; examples are in the goldens internal/gen/testdata/golden/time_* and internal/check/testdata/golden/time_layout.

  • Zone data. The zone data is the lib/time/zoneinfo.zip of the Go toolchain that builds the program (XO_ZONEINFO names another file). time.zone("UTC") needs no data. A constant name is a string literal, a const, or a + of them; a constant name the database does not have gets a build note and returns ZoneErr.Unknown at run time. time.zone used as a function value counts as a name known only at run time. Names are case sensitive. Measured on darwin/arm64 (2026-10-09): a program using only utc() is 7,968,146 bytes, one constant zone (Europe/Berlin) 7,968,994 (+848), a run time name 8,382,498 (+414 KB, the database), the same with --tzdata=system 7,969,666.
  • local(env). TZ from the Env (a leading : is dropped; empty or UTC is utc()); a name that is not embedded is looked up in the host database, since local is host dependent by nature. Without TZ, a fake Env gives utc() and the real one the system zone, named by the /etc/localtime link when there is one (Local otherwise).
  • Civil time. Civil.zone is the zone’s name (Europe/Berlin), not the abbreviation (abbreviation_at gives that). date accepts years 1 to 9999; Time covers about 1678 to 2262, and instant outside it is CivilErr.Invalid. Date methods on a Date built with a struct literal and out of range fields normalize it (month 13 is January of the next year). For a gap, Earlier uses the offset before the change and Later the one after: 02:30 on 2026-03-08 in New York is 03:30 EDT with Earlier and 01:30 EST with Later (Python’s fold=0 and 1). Error display text, one sentence each, no trailing period: ZoneErr.Unknown is unknown time zone "Mars/Olympus", NoLocal is cannot determine the local time zone: <msg>; CivilErr.Invalid is invalid civil time: <msg> (month 13 out of range 1 to 12), Skipped is 2026-03-08T02:30:00 America/New_York does not exist (skipped by a daylight saving change), Repeated is 2026-11-01T01:30:00 America/New_York happens twice (repeated by a daylight saving change); ParseErr is at byte 5 of "2026-1x-09": expected 2 digit month and LayoutErr is bad layout "{yaer}": unknown field {yaer}. The payload fields stay readable for code that matches on the error.
  • Layouts. {frac} and {frac:N} include the leading . (so {frac} prints nothing for a whole second). {offset} prints Z for a zero offset, {offset:compact} prints +0000. {hour12} needs {ampm} in the same layout (a layout error otherwise). {weekday} alone is not a field. LayoutErr displays as bad layout "{yaer}": unknown field {yaer}. A constant layout error is XO0420 (did you mean fix for a misspelled field); a layout written with Java, C#, or Swift letters ({yyyy}, yyyy-MM-dd), strftime codes (%Y-%m-%d), or Go’s reference date (2006-01-02) is XO0177 with a fix to the named fields when every piece has one (%y, a two digit year, has none).
  • Parsing. Numeric fields have exactly their width ({day:1} and {month:1} take one or two digits). Fields missing from the layout default to 1970-01-01 and 00:00:00. A parsed weekday must match the date. Without {offset}, {zone} picks the instant whose abbreviation in z matches (UTC and GMT also mean +00:00), and a civil time in a gap or overlap with neither is a ParseErr (no silent choice).
  • HTTP dates. RFC1123 formatted in utc() ends in UTC, while HTTP requires GMT: write the zone literally, "{weekday:short}, {day} {month:short} {year} {hour}:{minute}:{second} GMT" with utc(). Parsing with RFC1123 accepts GMT.