Initiation

An initiation to zo.

For Andrea Le Saint And those who refuse the uniformity of software.

invisageable & the compilords First edition · 2026 · zo version 0.5.0

« Why accept slow compilers? Just make them faster. » — Jonathan Blow

COMPiLORDS©

install

A short setup before we open the first page. Two commands, two minutes.

« Simplicity is a prerequisite for reliability. » — Edsger W. Dijkstra

get the binary

curl --proto '=https' --tlsv1.2 -sSf https://zo.compilords.house/install.sh | sh

The script downloads and extracts the zo compiler into bin/zo and adds it to your PATH so zo is reachable from any shell.

verify

Confirm zo is reachable from your shell.

zo --version

Succesfully it will display zo x.x.x. The number depends on the latest release.

trouble?

Drop into the discord or open a GitHub issue — fastest path to a fix.

You’re ready. Turn the page.

prologue

[tsh-tsh]

This initiation is for the zo programming language.

What are we waiting for to improve our developer experience? Why does having to wait several seconds, or even minutes, to get feedback on the correctness of our program bother absolutely no one?

The quality of our current tools and infrastructure borders on mediocrity. Fortunately, some developers, resistant to this pervasive nonsense, perpetually continue to refine our ecosystem. To accept this mediocrity is to submit to the dictates of executive committees who force their products on us through waves of intensive marketing.

zo is not a revolutionary programming language: its concepts come from languages that have proven themselves. zo does not solve any new problem that another language hasn’t already solved. It is a different language, but one that aims to be familiar, so that people from diverse backgrounds can explore low-level programming through a high-level language.

Whether you are back-end, front-end, a hacker, curious, a nerd, a scientist, a Ph.D., or a creative, zo is just another field of possibilities among many. It’s up to you to see if you want to discover more.

Our convictions rest on simplicity, software harmony, and the developer experience. For this, with the compilords, we focused on the minuscule details that matter, but that everyone pretends not to see. The main idea remains to open new dimensions in which our thoughts transform into a series of zeros and ones, without ever sacrificing the joy.

JOiN THE DEVOLUTiON.

language design

keywords

zo has 53 keywords in total:

Namespacing
4 letters
pack
declares the current package
load
imports items into scope
Type Definitions
misc letters
abstract
declares a behavior contract
struct
declares a record with named fields
apply
attaches behavior to a type
enum
declares a tagged union
type
declares a type alias
Member Definitions
misc letters
fun
declares a function
ffi
declares a foreign function binding
val
declares a compile-time constant
imu
declares an immutable binding
mut
declares a mutable binding
fn
declares a closure
Control Flow
misc letters
continue
skips to the next iteration
return
returns a value from a function
match
pattern matches across arms
while
loops while a condition holds
break
exits the current loop
else
alternate branch of an if
when
ternary expression
loop
infinite loop
for
iterates over a range or collection
if
conditional branch
Concurrency
misc letters
supervise
declares a supervised task scope
nursery
declares a structured task scope
select
multiplexes channels or tasks
thread
marks an OS thread spawn (parser-synthetic)
spawn
launches a concurrent task
await
suspends until a task completes
Infixes
misc letters
and
...
as
casts a value to a type
is
type test (reserved)
Modifiers
misc letters
group
...
wasm
marks an item for WebAssembly
pub
marks an item as public
Qualifiers
4 letters
Self
refers to the current type
self
refers to the current instance
Values
misc letters
false
boolean false literal
true
boolean true literal
Integers
misc letters
uint
the default unsigned integer (32-bit)
int
the default signed integer (32-bit)
s16
16-bit signed integer
s32
32-bit signed integer
s64
64-bit signed integer
u16
16-bit unsigned integer
u32
32-bit unsigned integer
u64
64-bit unsigned integer
s8
8-bit signed integer
u8
8-bit unsigned integer
Floats
misc letters
float
the default floating-point (64-bit)
f32
32-bit floating-point
f64
64-bit floating-point
Primitives
misc letters
bytes
byte buffer
bool
boolean type
char
Unicode character
str
UTF-8 string
</>
template fragment type
Fn
function type

operators

unary

PrecedenceOperator
0 + - !

binary

Precedence Operator
1 ||
2 && .. ..=
3 == !=
4 < <= > >=
5 |
6 ^
7 &
8 << >>
9 + ++ -
10 * / %
12 .

assignments

Operator
= += -= *= /= %= &= |= ^= <<= >>= := ::=

others

Operator Name
-> arrow (return type)
=> fat arrow (match arm)
=:> template fat arrow (template-body closure)
|> pipe arrow (reserved)
:: path separator
? question
@ at

delimiters

Open Close Name
( ) parentheses
{ } braces
[ ] brackets
<> </> template fragment

punctuations

Symbol Name
, comma (list separator)
; semicolon (statement terminator)
: colon (type ascription)
_ underscore (wildcard pattern)
# hash (reserved)
$ dollar (reserved)
%% attribute marker
... ellipsis (spread / variadic)

introduction

This guide is your initiation to the zo programming language ecosystem. Read sequentially on your first pass, then skip around once you grasp the architectural patterns.

how to use this guide

Every lesson delivers a high-fidelity snapshot of a functional zo program. Pay close attention to the comments: documents comments (-!) leverage raw Markdown formatting to establish systemic guidelines, while line (--) and block (-* *-) markers isolate execution mechanics.

-! Sup? I'm a doc comment, I support markdown
-! format. What's good?
-- I'm a line comment, I don't care about markdown.
-*
  From my side I'm a block comment, I'm happy to help
  for details that matter.
*-

Every executable compilation unit inside zo must expose an explicit, non-colored entry block called main:

-- Wassup?! I'm `main`, a function.
-- Use me as a entry point with `fun` keyword.
fun main() {
  -- This program does nothing... yet.
}

-! ## the capstone.
-!
-!   - every programs must declare a `main` block
-!     to serve as the runtime launchpad.

hello

Let’s spin up your first interactive instance. We trigger text printing operations via an internal optimized wrapper.

-! Yo! Let's start with a simple program.
-! In this lesson, we learn how to print a message.

fun main() {
  -- We call our buddy `showln`.
  -- It tells the compiler to display the value
  -- passed as argument with a newline at the end.
  showln("hello, hacker");
}

-! ## the capstone.
-!
-!   - `showln` is an internal compiler builtin. No
-!     import or namespace matching required.

literals

numbers

Programming boils down to memory allocation layout and data mutations. Data arrives in primary primitives called literals. You do not need to memorize these constraints instantly, but you must respect their sizes.

All contextual code snippets assume code is running inside an active fun main() execution block.

integers

-! Let me introduce the gang members.
-!
-! ## the integer family.
-!
-!   signed:    s8, s16, s32 (int), s64
-!   unsigned:  u8, u16, u32 (uint), u64

-- Ya, I'm the `int` chief — a signed 32-bit integer
-- by default for any bare number you write.
-- I scale up to `s64` if you need more room.
42
-- I support large values natively — `600851475143` 
-- allocates down without complex object types.
600851475143

floats

-! And that's the rival clan.
-!
-! ## the float family.
-!
-!   f32: 32-bit
-!   f64: 64-bit (float)

-- Heyo, I'm `float` — a 64-bit double. Just add a 
-- `.` and you get me. 
14.0
3.14159
-- I support scientific notation natively. No
-- overhead, just quick compilation values.
1.0e10
2.5e-3

bases

-! Mask-on integer prefixes change internal notation
-! views.

0b11110000 -- Binary notation evaluates to 240
0o77       -- Octal notation evaluates to 63
0xff       -- Hexadecimal notation evaluates to 255

parse modifiers

-! A `#` prefix sets the display base. The digits stay
-! decimal; only how the value prints changes.

b#30 -- value 30, shown in binary
o#75 -- value 75, shown in octal
x#76 -- value 76, shown in hexadecimal

booleans

-- Wordup, we're `bool` — only `true` and `false`.
-- No "truthy" or "falsy" mind games here."
true
false

strings

-- Look out! I'm `str` — a string literal. I live in 
-- the binary's read-only data section hood, so I
-- cost nothing at runtime. Skuuuuuu!"
"JOiN THE DEVOLUTiON."

chars

-- Call me `char` — a single Unicode scalar wrapped
-- in single quotes.
'z'

bytes

-- Call me `bytes` — a multi-byte buffer wrapped in
-- backticks. Same layout as `str`, but without the
-- UTF-8 safety validation promise. Every raw byte 
-- is preserved.
`hello`
`¥orld`

variables

Data bindings demand structured tracking. zo enforces variable clarity using three dedicated allocation keywords: val for global or local compile-time constants, imu for unalterable execution parameters, and mut for active stack transformations.

constants

-- Hi, I'm `val` — your compile-time constant.
val VERSION: str = "1.0.0";
val MAX_HEALTH: int = 100;

locals

-- Hey, I'm `imu` — your immutable local. Set me 
-- once, read me many.
imu name: str = "johndoe";

-- And me, I'm `mut` — your mutable local. I can be
-- reassigned.
mut health: int = 22;
health = 50; -- Legal modification mutation.

shadowing

-- Variable shadowing isolates structural scopes.
-- Each statement allocates a fresh slot, shadowing
-- the predecessor safely.
imu x: int = 40;
imu x: int = x + 2; -- x now safely evaluates to 42.

interpolation

Drop variables into any string with {variable_name}. The compiler resolves each hole at compile time — no runtime parsing, no format functions.

imu name: str = "johndoe";
imu hp: int = 100;
imu attack: int = 15;

showln("hero: {name}, hp: {hp}");

Interpolation works in every string context — assignments, arguments, return values.

imu power: int = hp + attack;
imu status: str = "power level: {power}";

showln(status);

All scalar types resolve automatically: str, int, float, bool, char.

imu pi: float = 3.14;
imu active: bool = true;
imu label: str = "pi={pi}, ok={active}";

showln(label);

how it works

Interpolated strings compile into a single allocation that concatenates all segments. Each non-str variable converts to its string representation first, then everything merges in one pass.

showln("hp: {hp}");
-- Desugars to:
--   show("hp: ");
--   showln(hp);
imu msg: str = "hp: {hp}";
-- Compiles to:
--   to_str(hp) → "100"
--   multi_concat("hp: ", "100") → "hp: 100"

Direct output through showln skips the heap entirely — each segment writes straight to the file descriptor Assigned strings allocate once regardless of how many {} holes they contain.

-! ## the capstone.
-!
-!   - `{variable}` inside any `"string"` resolves the variable.
-!   - `\{` produces a literal brace, not interpolation.
-!   - direct output (`showln`) allocates nothing.
-!   - assigned strings allocate once, not per segment.
-!   - all scalar types (`str`, `int`, `float`, `bool`, `char`) supported.

operators

Operators are how you transform values. zo keeps them small and predictable — the same five arithmetic operators you’ve used everywhere, plus reassignment and a handful of shorthands.

arithmetic

imu power: int = hp + attack; -- + - * / %
mut current_hp: int = 100;
current_hp -= 25; -- += -= *= /=

strings

All strings adhere to valid UTF-8 formats, supporting raw character arrays, unicode hex variants, layout escapes, and structural symbols seamlessly.

-- Custom strings are completely immutable. The
-- internal data bytes never change.
"\e[32mHello!\v\e[34mWorld\e[0m\n"
"\x48\x65\x6c\x6c\x6f"          -- hex char
"\u{e9}\u{e8}\u{ea}"            -- latin char
"\u{1F600} \u{1F680} \u{1F4A9}" -- decoded emoji
"🙈🙉🙊"                        -- raw unicode literals

concatenation

The ++ operator acts as your layout welding torch. Merging literals fuses values immediately inside the binary compilation phase, yielding zero performance penalties at execution time.

imu greeting: str = "hello";
imu name: str = "johndoe";
imu full: str = greeting ++ ", " ++ name ++ "!";

-- Direct character index extraction evaluates
-- efficiently.
showln(greeting[0]); -- Evaluates to 'h'
-! ## the capstone.
-!
-!   - `str` is immutable.
-!   - `++` concatenates.
-!   - `s[i]` for string indexing.

tuples

A tuple organizes items into a fixed-length, ordered sequence. Unlike arrays, a single tuple can house distinct data types within the same memory footprint.

imu point: (int, str, int) = (100, "john", 3);

-- Extract parameters via positional indices.
showln(point.0);

-- Deconstruct the memory layer instantly via
-- structured binding patterns.
imu (x, y, z): (int, int, int) = point; -- Structured destructuring binding

-- Declare structural type aliases for fast shape
-- replication.
type Point = (int, int); -- Type shaping alias

arrays

Arrays host homogeneous components where every single block matches a identical data type. They come in static and dynamic variants.

static array

The notation format [N]T fuses the length constraint straight into the data classification layer. The memory block is determined at compile-time and guarantees safe bounds-checking verification parameters during constant array access tasks.

imu nums: [3]int = [10, 20, 30];
imu zeros: [5]int = [0...]; -- [0, 0, 0, 0, 0]
imu grid: [2][3]int = [[1, 2, 3], [4, 5, 6]];

-- Unpack items instantly using assignment sequences.
imu [a, b, c]: [int, int, int] = nums;

dynamic array

The designation []T offloads length calculations to execution headers rather than structural types. Coupling arrays with a mut binding permits array extensions.

mut arr: []int = [];
arr.push(10);

imu last: int = arr.pop(); -- Safely extracts 10

-- The `[value...count]` expression triggers explicit
-- array expansion routines.
imu sevens: []int = [7...4]; -- [7, 7, 7, 7]

blocks

functions

-- I'm a function accepting two arguments of type
-- int and returning a value of type int.
fun sum(a: int, b: int) -> int {
  a + b -- implicit return
}

-- Then you can call me, like this:
sum(39, 3);

closures

-- closure:block.
imu square: Fn(int) -> int = fn(x: int) -> int {
  return x * x;
};

-- closure:line.
imu square: Fn(int) -> int = fn(x: int) -> int => x * x;


-- Then you can call me, like this:
square(7);

structures

Custom structures package operations into meaningful domain boundaries. The struct keyword models complex custom fields, while the apply keyword assigns functional behavior logic to those types.

struct

A field can declare a default value with =. Fields without one are set when you construct the value.

struct Point {
  x: int,
  y: int,
}

struct Rect {
  x: int = 10,
  y: int = 20,
  w: int = 100,
  h: int = 200,
}

struct Counter {
  x: int = 0,
}

methods

Use the apply statement block to bind custom functions to a target structure definition.

apply Counter {
  -- Static instantiation function block.
  fun new() -> Self {
    Self { x = 0 }
  }

  -- Mutable state tracking modifier function.
  fun incr(mut self) {
    self.x += 1;
  }
}

enums

Enums manage disjoint data states safely, supporting direct discriminants along with tuple-wrapped inline data tracking blocks.

-- ...
enum Foo {
  Bar,
  Oof(str), -- Structural data tuple binding
  Rab = 42, -- Assigned explicit discriminant value
}

-- Instance consumption example.
imu foo: Foo = Foo::Bar;
imu foo: Foo = Foo::Oof("What's crackin'?");

abstracts

Abstracts establish zo’s architecture for ad-hoc polymorphism. You define a structural contract once, then declare custom implementation tracks across varying types via explicit implementation mappings: apply Abstract for TargetType.

abstract Display {
  fun display(self) -> str;
}

struct Point {
  x: int,
  y: int,
}

apply Display for Point {
  fun display(self) -> str {
    return self.x.to_str() ++ ", " ++ self.y.to_str();
  }
}

using abstracts as parameters.

Functions that accept elements under abstract contract bounds choose between three compilation techniques balancing raw execution speed against binary sizing limits.

form 1 — implicit-mono.

fun render(item: Display) -> str {
  item.display()
}

The compiler manages the heavy lifting under the hood: it automatically generates a hidden type variable, extracts parameters straight from the call site, and generates a dedicated, static monomorphized copy of the function block per unique type. Zero runtime cost, zero vtable tracking lookups. Violating limits triggers error diagnostics immediately.

form 2 — explicit-mono.

fun render<$T: Display>(item: $T) -> str {
  item.display()
}

This follows the exact same performance-optimal static compilation track as Form 1. Use this explicit syntax strategy whenever you must reuse the type constraint identifier across signature bounds—such as enforcing matched input types or coordinating return paths.

form 3 — dynamic dispatch.

fun render(item: any Display) -> str {
  item.display()
}

Prepend the type parameter with any to box the instances safely behind a uniform vtable layout pointer. The compiler generates exactly one execution block in the final binary, executing code paths via runtime lookup addresses. This trade-off incurs slight call overhead but permits heterogeneous data grouping inside shared array vectors.

mut widgets: []any Drawable = [];
widgets.push(Button { label = "ok" });
widgets.push(Slider { value = 42 });

for w := widgets {
  showln(w.draw());
}
Engineering RequirementOptimal Architectural Choice
Maximum dispatch velocity; isolated single types per call site.item: Abstract (Form 1)
Shared type bounds enforcement across arguments or return paths.<$T: Abstract>(item: $T) (Form 2)
Mixed collection handling; minimal compiled binary space footprint.item: any Abstract (Form 3)

type alias

-- ...
type Bar = int;

-- ...
imu z: Bar = 42;
group type Idx = int
  and Pair = (int, int)
;

generics

Generics are zo’s form of parametric polymorphism — one body of code that works across many types via type parameters (<$T>). The other form, ad-hoc polymorphism, is covered by abstracts.

type inference

  • hindley milner.

control flow

if else

-- same type inside
if 1 == 2 {
  false
} else if 2 == 3 {
  false;
} else if 3 == 4 {
  false
} else {
  true
}

ternary

-- only as expression
when true ? 1 : 2;

pattern matching

-- ...
match 5 {
  10 => check(false),
  _ => check(true), -- wildcard
}

-- ...
match "z" ++ "o" {
  "ivs" => showln(false),
  "zo" => showln(true),
  _ => showln("default"),
}

jumps (terminators)

for i := 1..10 {
  if i == 3 { continue; }
  if i == 7 { break; }
  
  show(i);
}

-- 12456

loops

while loop

-- while:block
mut z: int = 0;
while z < 1_000_000_000 {
  z += 1;
}

-- while:line
mut z: int = 0;
while z < 1_000_000_000 => z += 1;

for loop

-- for:block.
for x := 0..3 {
  showln("{x}");
}

-- for:block:mut.
for mut x := 0..3 {
  x += 1
}

-- for:line.
for x := 0..3 => showln("{x}");

-- for:line:mut
-- mutable iterator, body reassigns it.
for mut n := 0..3 => n += 1;

infinite loop

mut x: int = 0;
loop {
  if x == 1_000_000 {
    showln(x);
    break;
  }

  x += 1;
}

concurrency

The zo runtime ignores standard state-machine async/await transforms entirely, eliminating function coloring bugs across your system. Execution runs inside native runtime-managed green threads tracking execution scope blocks called nurseries. Blocking a task triggers immediate context frame swaps inside the scheduler.

nursery

A nursery container sets strict lexical boundaries for concurrent task Lifecycles. The execution block cannot exit until every spawned green thread unwinds completely.

fun worker(id: int, tx: Tx<int>) {
  showln("worker: {id}");
  tx.send(id * 10);
}

fun main() {
  imu (tx, rx) := channel();

  -- The nursery handles concurrent task tracking
  -- smechanics cleanly.
  nursery {
    spawn worker(1, tx);
    spawn worker(2, tx);
  } -- exical boundary block: execution holds here
  --  until both tasks complete.

  imu res1 := rx.recv();
  imu res2 := rx.recv();
  showln("collected results: {res1}, {res2}");
}
  • True Stackful Green Threads: Every concurrent execution task allocates a lightweight runtime execution stack. Deep nested calls compile natively without restructuring code into complex async state loops.
  • Unified Runtime Scheduler: The internal task coordinator monitors state mutations directly. Invoking rx.recv() on an empty channel yields execution, swapping out active thread contexts immediately.
  • Structural Cancellation: Nurseries form distinct isolation islands. Triggering task cancellations propagates downstream through children stacks because the underlying runtime owns the stack handles.

supervise

select

Coordinate communication states across multiple channel references using the non-blocking select block format:

select {
  rx1 => fn(value: int) => showln("chan1: {value}"),
  rx2 => fn(value: int) => showln("chan2: {value}"),
}

thread

await

tests

zsx

The compiler builds an inline UI interface engine called zsx (zo Syntax Extension). It compiles interface markup nodes into static target rendering definitions at build-time, completely freeing the stack from heavy application bundles or runtime framework assets.

fun user_account(username: str, score: int) {
  imu view: </> ::= <div class="card">
    <h2>User: {username}</h2>
    <p>Current Score: {score}</p>
  </div>;
}

@events

@click, @input, event handlers.

components

style

Target style behaviors directly inside your source file using scoped lexical style assignment scopes ($: {}). The declarations remain completely isolated to the compilation package module boundary, guaranteeing style isolation.

-- Isolated module styles stay bound to local markup
-- components.
$: {
  card {
    background: #161b22;
    border-radius: 4px;
    padding: 16px;
  }
  button {
    background-color: #ff79c6;
    color: #0d1117;
    font-weight: bold;
  }
}

-- Use the public prefix modifier to pass declaration
-- metrics globally.
pub $: {}

directives

#render — … #html — …

foreign function interfaces

zo calls C-ABI libraries directly. You declare the foreign function once, tell the linker where its symbol lives, and call it like any zo function — no wrapper layer, no runtime cost.

declaring a foreign function

A pub ffi declaration names a function that lives in a C library. It has no body; the symbol resolves at link time.

-- A declaration ends in `;` — no body.
pub ffi sqrt(x: f64) -> f64;

-- A void function omits the return.
pub ffi close_window();

zo drives the call straight from this signature: it places arguments in registers per the platform ABI, narrows or widens scalars, and passes structs by value. Adding a function costs one line.

linking a library

A #link block tells the linker which dylib owns the symbols in the file. One block covers every pub ffi in the same pack.

#link {
  macos: "@executable_path/libzo_provider_sqlite.dylib",
  linux: "@executable_path/libzo_provider_sqlite.so",
}

pub ffi zo_sqlite_open(path: CStr) -> int;
pub ffi zo_sqlite_close(handle: int);

C strings cross the boundary as CStr (from core::c), never str — a zo str carries a length header that a C function would misread.

calling a library

Foreign libraries ship as opt-in providers. Load one and call it:

load core::c::*;
load provider::sqlite::*;

fun main() {
  imu db: int = zo_sqlite_open(CStr::new("scores.db"));
  zo_sqlite_exec(db, CStr::new("CREATE TABLE s (score int)"));
  zo_sqlite_close(db);
}

the binding generator

Hand-writing a pub ffi line per function — and keeping each one in sync with the library — is the tedious part. zo-binder writes those declarations for you, from one of two sources.

from a rust library

Wrap any Rust crate in a small shim that exports plain C functions:

#[unsafe(no_mangle)]
pub extern "C" fn undo_stack_new() -> i64 { /* ... */ }

Then generate the bindings:

just bind undoredo

zo-binder reads the shim’s signatures and writes provider/undoredo/undoredo.zo — the #link block plus one pub ffi per function, ready to load.

from a c header

For a C library, feed zo-binder the header’s machine-readable API. raylib emits one through its rlparser tool:

zo-binder --json raylib_api.json --lib raylib \
  --macos /opt/homebrew/lib/libraylib.dylib \
  --linux /usr/lib/x86_64-linux-gnu/libraylib.so

zo-binder maps each C type to its zo equivalent, generates the structs, renames InitWindow to init_window, and skips what it cannot map — callbacks, variadics — reporting each one.

The generated file is committed and reviewed like any other source. Nothing runs during zo run: you regenerate bindings deliberately, the way you would run a formatter.

core

options

imu some: Option = Option::Some("...");
imu none: Option = Option::None;

results

imu pass: Result = Result::Pass("value");
imu fail: Result = Result::Fail("error");

errors

the ? operator

  • short-circuit Result inside a Result-returning function
  • desugaring: expr?match expr { Pass(v) => v, Fail(e) => return Fail(e) }

error propagation

  • chaining: read_file(p)?.parse()?.validate()?
  • composing helpers that bubble up domain errors

errors vs panics

  • Result for expected failure modes (file not found, parse error)
  • panics for bugs (invariant broken, indexing past length)
  • never use Result to signal logic errors; never panic on user input

ranges

for i := 0..5 {            -- iteration
  showln(i);               -- 0 1 2 3 4
}

imu slice: []int = xs[2..5];   -- slicing

collection types

arrays

imu scores: []int = [1, 2, 3, 4, 5];
imu empty: []int = [];

scores.sum();        -- 15
empty.sum();         -- 0

scores.contains(3);  -- true
empty.contains(5);   -- false

scores.find(3);      -- 2
empty.find(99);      -- -1

scores.min_of()      -- 1
scores.max_of()      -- 5
empty.min_of();      -- 0

vectors

mut numbers: Vec<int> = Vec::new();

numbers.len();       -- 0

numbers.is_empty();  -- true

numbers.push(10);
numbers.push(20);
numbers.push(30);

numbers.get(0);      -- Option::Some(10)
numbers.get(99);     -- Option::None

numbers.set(1, 42);  -- set in-bounds.
!numbers.set(7, 0);  -- set out-of-bounds returns false.

numbers.pop();       -- Option::Some(30)

numbers.remove(1);   -- Option::Some(42)

sets

mut ids: HashSet<int> = HashSet::new();

ids.is_empty();    -- true

ids.insert(10);    -- true  (new key)
ids.insert(20);
ids.insert(10);    -- false (already present)

ids.contains(10);  -- true
ids.contains(99);  -- false

ids.remove(20);    -- true
ids.len();         -- 1

maps

mut counts: HashMap<str, int> = HashMap::new();

counts.is_empty();         -- true

counts.insert("a", 1);
counts.insert("b", 2);

counts.get("a");           -- Option::Some(1)
counts.get("z");           -- Option::None

counts.contains_key("b");  -- true

counts.remove("a");        -- Option::Some(1)
counts.len();              -- 1

file system

imu path: str = "/path/to/file";

match write_file(path, "hi") {
  Result::Pass(_) => {},
  Result::Fail(_) => showln("write-err"),
}

match read_file(path) {
  Result::Pass(text) => showln(text),
  Result::Fail(_) => showln("read-err"),
}

exists(path);                  -- true
remove_file(path);             -- true

imu names: []str = read_dir("/some/dir");

directories

load core::io;

io::is_dir("/some/dir");           -- true

io::copy("a.txt", "b.txt");        -- Result::Pass(bytes copied)

io::remove_dir("/empty/dir");      -- Result::Pass(0)
io::remove_dir_all("/whole/tree"); -- recursive teardown

Each returns Result::Fail(errno) on failure.

terminal

load core::io;

-- fd 0 stdin, 1 stdout, 2 stderr.
when io::isatty(1) ? showln("interactive") : showln("piped");

environment

load core::env;

env::current_dir();             -- "/work/dir"
env::set_current_dir("/tmp");   -- true
env::temp_dir();                -- the OS temp directory

env::get("HOME");               -- "/Users/me" ("" on miss)
env::set("KEY", "value");       -- true
env::remove("KEY");             -- true

imu all: []str = env::vars();   -- ["KEY=VALUE", ...]

command-line

load core::cli;
load core::io;

imu app: Cli = Cli::new("greet", "say hello")
  .flag("v", "verbose", "print more")
  .option("n", "name", "who to greet");

imu parsed: Parsed = app.parse(io::args());

parsed.has("verbose");          -- true when -v / --verbose given
parsed.value("name");           -- Option::Some("zo")
parsed.positionals();           -- bare arguments, in order
  • --name=zo, --name zo, and -n zo all parse the same
  • unknown dashed tokens fall through to positionals
  • app.help() renders usage text from the registered specs

module system

pack

pack say {
  fun hello() {
    showln("hello, modular world");
  }
}

load

load core::math::pow_i;
load core::math::(pow_i, abs);

error messages

When a program does not compile, zo tells you what went wrong, where, and how to fix it. Pick the shape of that report with --format: a colored snippet for you, or structured data for a tool.

fun main() {
  imu s: str = "hello" ++ 42;
}

By default the compiler renders a human snippet to stderr — the offending line, a caret under each span, and the conflicting types in color.

zo build greeting.zo
[E0304] Error • Type mismatch
   ╭─[ greeting.zo:2:25 ]

 2 │   imu s: str = "hello" ++ 42;
   │                ───┬───    ─┬
   │                   ╰─────────── conflicts with this type `str`
   │                            ╰── incompatible type `int` here

warnings

Not every diagnostic stops the build. Warnings point at code that compiles but breaks a convention — an unused variable, unreachable code, or a name that does not follow zo’s naming rules:

  • struct, enum, type, and generic names are PascalCase.
  • val constants are SCREAMING_SNAKE_CASE.
  • everything else — imu/mut bindings, fun names and arguments, struct fields, abstract functions — is snake_case.

Each naming warning carries the convention-correct rename as its help, so the fix is always one copy-paste away:

[E0355] Warning • Name is not snake_case
   ╭─[ counter.zo:2:7 ]

 2 │   imu MyCount := 1;
   │       ───┬───
   │          ╰───── expected a snake_case name

   │ Help • rename it to `my_count`

A leading underscore opts a binding out (_unused), and digits never need a separator (r0, grid2, MAX2 are all fine). The program builds and runs regardless — warnings inform, errors stop.

machine formats

An agent reads text differently than you do — it never skims and it is never overwhelmed by length. So zo offers two machine formats that carry the full diagnostic, not a terse summary. Both stream to stdout, leaving stderr for you.

--format=json emits one JSON object per diagnostic, one per line (NDJSON), flushed as each error is found.

zo build greeting.zo --format=json
{"$schema":1,"id":"type-mismatch","code":"E0304","severity":"error","phase":"analyzer","message":"Type mismatch","fixes":[],"notes":["The types of both operands must be compatible"],"snippet":{"before":["fun main() {"],"lines":["  imu s: str = \"hello\" ++ 42;"],"after":["}"]},"span":{"file":"greeting.zo","byte_start":35,"byte_end":37,"line_start":2,"line_end":2,"col_start":25,"col_end":27},"secondary":{"file":"greeting.zo","byte_start":24,"byte_end":31,"line_start":2,"line_end":2,"col_start":14,"col_end":21},"primary_type":"int","secondary_type":"str"}

--format=xml emits one well-formed <diagnostics> document. The tag boundaries read as explicit structure — clean to drop straight into a prompt.

zo build greeting.zo --format=xml
<diagnostics schema="1">
  <diagnostic id="type-mismatch" code="E0304" severity="error" phase="analyzer">
    <message>Type mismatch</message>
    <fixes/>
    <notes>
      <note>The types of both operands must be compatible</note>
    </notes>
    <snippet>
      <before>
        <line>fun main() {</line>
      </before>
      <lines>
        <line>  imu s: str = "hello" ++ 42;</line>
      </lines>
      <after>
        <line>}</line>
      </after>
    </snippet>
    <span file="greeting.zo" byte_start="35" byte_end="37" line_start="2" line_end="2" col_start="25" col_end="27"/>
    <secondary file="greeting.zo" byte_start="24" byte_end="31" line_start="2" line_end="2" col_start="14" col_end="21"/>
    <primary_type>int</primary_type>
    <secondary_type>str</secondary_type>
  </diagnostic>
</diagnostics>

The two machine formats are isomorphic: the same fields under the same names. A JSON key maps 1:1 onto the XML element or attribute of the same name, so a tool that reads one reads the other.

the schema

Every diagnostic carries a stable identity and the data needed to act on it without re-parsing your source.

  • id — a frozen, kebab-case name (type-mismatch). Match on this, not the prose.
  • code — the display alias (E0304), derived from id.
  • severityerror or warning.
  • phase — where it surfaced: tokenizer, parser, analyzer, codegen, runtime.
  • message — the one-line headline.
  • span — the primary location: file, byte offsets, and 1-indexed line/col (columns count characters, so é advances one).
  • secondary — the conflicting location, when a diagnostic carries two spans.
  • fixes — machine-applicable edits, always an array. Each fix names a kind (insert / replace / delete), the replacement text, a description, and the exact span to edit. A tool auto-applying picks the first.
  • notes — attached context, always an array.
  • snippet — the source lines around the span (before / lines / after). Tune the radius with --snippet-context N; 0 turns it off.

fixes and notes are always present — empty rather than absent — so a consumer never needs a presence check. The same source over the same input renders byte-identical output, so a tool can diff two builds.

-! ## the capstone.
-!
-!   - default `--format=human` paints a colored snippet to stderr.
-!   - `--format=json` streams one NDJSON object per diagnostic to stdout.
-!   - `--format=xml` emits one well-formed document to stdout.
-!   - both machine formats share one frozen, isomorphic schema.
-!   - match on the stable `id`, never on the prose `message`.
-!   - `fixes` carry exact edits; `--snippet-context N` sets the source radius.

epilogue

Your initiation is complete. Now that you have mastered the fundamentals of the zo language — it is up to you to get creative and show the world your talent. We have put together a collection of programs for you to browse, sample, replicate, and modify however you like.

Click the following link to access them: @how-to

TRiLU!

reachout

echo -n 'dGhlQGNvbXBpbG9yZHMuaG91c2U=' | base64 --decode

For humans: faq.

For Ai agents: llms.txt (curated index) and llms-full.txt (full docs).

Privacy: No cookies, no ads, no tracking. It's like you were never here.