The NOVA (Natively Optimized Versatile Architecture) Tutorial
A guided walk from zero to a deployed full-stack application in NOVA. Every snippet compiles with the current nova binary. Each section explains not just what the syntax is but why the design is the way it is, what goes wrong when you misuse a feature, and how everything connects. If something doesn't work, that's a bug — file it.
Every code block on this page is real, compile-verified NOVA. Nothing is pseudocode or aspirational syntax.
How to read this: Every code example shows expected output in comments. DO boxes show the correct pattern. DON'T boxes show the mistake and explain why it breaks. When a feature differs from Python, Go, Rust, or JavaScript, the comparison is explicit.
1. Install
What is this? NOVA ships as a single self-contained installer per platform: the compiler, runtime, standard library, and a matched clang/LLVM toolchain arrive together. There is nothing to separately install first — no package manager, no Visual Studio Build Tools, no apt install llvm clang. NOVA compiles through LLVM (Low-Level Virtual Machine) — the same compiler backend used by C, Rust, and Swift, which is why it achieves C-level performance — but you never have to go find or configure it yourself.
# Linux / macOS curl -fsSL novachan.org/install.sh | sh # Windows (PowerShell) iwr novachan.org/install.ps1 -useb | iex
Prefer not to pipe a script into your shell, or need a specific version? See all platform downloads for direct archive links and checksums. The installer places everything under ~/.nova (or the platform-appropriate equivalent) and sets NOVA_HOME for you — the rest of this chapter's manual NOVA_HOME/alias steps are for the build-from-source path below, not something a normal install needs.
DO: Run nova toolchain status after installing to confirm the bundled clang was found (it should report "bundled toolchain", not "system PATH"). DON'T: Assume you need a separately-installed clang/LLVM anymore — that requirement is exactly what the installer exists to remove.
Building from source (contributing to the compiler)
The installer above is the right choice for using NOVA. If you are instead working on the compiler itself — the scenario this section originally covered — you build from a clone and manage NOVA_HOME yourself. The compiler is self-hosted: the NOVA compiler is written in NOVA and compiles itself, currently producing a binary named gen3_test.exe under nova-compiler/test_programs/ once bootstrapped. You still need a system clang for this path (it is what compiles the compiler's own bootstrap step) — the bundled-toolchain resolution the installer sets up is for compiling your NOVA programs, not for bootstrapping the compiler from source.
For day-to-day use from a source checkout, alias the built binary so you can type nova instead of the full path:
# Linux/macOS — add to ~/.bashrc or ~/.zshrc alias nova='/path/to/nova-compiler/gen3_test' # Windows PowerShell — add to $PROFILE function nova { & "C:\path\to\nova-compiler\gen3_test.exe" @args }
Setting NOVA_HOME
NOVA resolves standard library modules — including Forge — by looking in $NOVA_HOME/lib/. Set this environment variable to the directory that contains the lib/ folder:
# Linux/macOS export NOVA_HOME=/path/to/nova-compiler # Windows PowerShell $env:NOVA_HOME = "C:\path\to\nova-compiler"
DO: Set NOVA_HOME to the directory that contains the lib/ folder (e.g., nova-compiler/), not to lib/ itself.
DON'T: Leave NOVA_HOME unset and wonder why import forge fails — the error message will tell you the path it looked in, which makes the fix obvious.
Starting a new project
You do not have to hand-create files to start a NOVA project. nova new <name> scaffolds a working project skeleton — directory layout, an entry file, and a nova.toml manifest — so the very first thing you run already compiles. This matters more than it sounds: starting from a blank file forces you to remember the entry-point convention, the test-file naming convention, and the manifest format before you have written a single line of logic. A scaffolded start defers all of that until you actually need it.
# default skeleton is a REST API project nova new blog # or pick a shape explicitly nova new blog --api # REST API skeleton — same as the default nova new blog --microservice # health checks + graceful shutdown wired in nova new blog --frontend # browser-targeted skeleton nova new blog --fullstack # API + frontend in one project nova new blog --lib # library skeleton — exported modules, no entry point cd blog nova run main.nova
blog/nova.toml
blog/main.nova
blog/tests/
Hello from blog!
If you already have a directory and just want the manifest — for example, you are adding NOVA to a folder that already has files you want to keep — use nova init instead. It writes only nova.toml into the current directory and touches nothing else:
mkdir price-checker && cd price-checker nova init
DO: Use nova new for a brand-new project — it gives you the layout the rest of the toolchain expects (tests/ for nova test, nova.toml for nova get/nova install). DON'T: Reach for nova new when you are dropping NOVA into a folder that already has files — nova init adds just the manifest and leaves everything else alone.
nova setup — the one-time build-speed unlock
The first time NOVA compiles anything on a fresh machine, every build re-links against the C runtime from scratch — a roughly 6.5 second tax on every single nova run, even for a one-line print("hello"). nova setup pre-compiles the runtime into a cached object file once, up front, so every later build links against that cache instead of rebuilding the runtime each time. This is a startup step, not an optional optimization — run it immediately after installing NOVA, before your first nova run:
nova setup
Runtime cache written to $NOVA_HOME/.cache/runtime.o
Future builds will link against this cache (~170ms vs ~6500ms per build)
The difference is not marginal — it is the gap between NOVA feeling like a compiled language with Go's iteration speed, and NOVA feeling like it recompiles a C project from scratch on every run. nova setup is a one-time, per-machine step; run it again only after an update changes the runtime itself.
DO: Run nova setup once, immediately after install, before your first nova run. DON'T: Skip it and judge NOVA's compile speed from that first, uncached build — every build after nova setup is roughly 38× faster.
What happens when you run nova
The NOVA compiler is not an interpreter — it is a full ahead-of-time compiler. When you type nova run hello.nova, here is what happens behind the scenes:
- Lexing — the compiler reads your
.novafile and breaks it into tokens (keywords, identifiers, operators, literals) - Parsing — tokens become an AST (Abstract Syntax Tree) representing the structure of your program
- Type inference — the HM (Hindley-Milner) engine walks the AST, infers every type without you writing annotations, checks for type errors, and resolves function calls
- IR (Intermediate Representation) generation — the typed AST is lowered to NOVA's IR, a simplified instruction set that is easier to optimize
- LLVM codegen — the IR is translated to LLVM IR, the same format used by C and Rust compilers
- Compilation — LLVM compiles the IR to native machine code (
.exeon Windows, ELF on Linux) - Execution — the compiled binary runs directly on your CPU with no interpreter, no JIT (Just-In-Time) warmup, no VM
nova run and nova build — same pipeline, different destinations
Every earlier example told you to save a file and run nova run hello.nova — but run is only one of several subcommands built on the exact compile pipeline listed above (lex → parse → infer → IR → LLVM → codegen). What changes between them is the optimization level applied at the LLVM step, and whether the compiler executes the result or just leaves it on disk. nova run defaults to -O0 (no optimization) and executes the binary immediately after building it — the right choice while you are actively editing, since skipping LLVM's optimization passes is what keeps the edit-compile-run loop fast. nova build defaults to -O2 (full optimization) and does not run anything — it produces an executable and stops, because a binary you deploy and run many times is worth extra compile time up front for faster generated code.
# fast iteration loop — compiles in milliseconds, executes immediately nova run calc.nova # production build — takes longer to compile, produces a faster binary, does not run it nova build calc.nova ./calc
42
calc.nova prints the same 42 either way — only the compiled artifact and its execution speed differ. Vs Rust: this is the same split as cargo run (debug profile, unoptimized, runs immediately) vs cargo build --release (optimized, binary only) — NOVA just gives each job its own command with a sensible default, instead of one command plus a flag you have to remember to add.
DO: Use nova run while actively writing and re-running code — the whole point of -O0 is a fast loop, not a fast binary. DON'T: Ship or benchmark a binary produced by nova run's default — always build what you deploy with nova build (-O2), which can be several times faster at runtime.
nova compile and nova emit — inspecting the pipeline without a binary
Sometimes you want to see what the compiler produced without paying for a full link — to debug a codegen issue, to feed the output into another LLVM-based tool, or to sanity-check a specific target. nova compile stops the pipeline right after LLVM IR generation and writes the result to a .ll file next to your source — it never invokes clang, so there is no linking and no executable. nova emit is for looking at that output directly instead of saving it: by default it prints the LLVM IR to stdout, and with --asm it asks LLVM to lower that IR all the way to native assembly text — still without linking into a runnable binary. --target <triple> works with either command and points it at a target other than your host machine.
nova compile calc.nova # writes calc.ll — no executable produced nova emit calc.nova # prints the LLVM IR itself to stdout nova emit calc.nova --asm # prints native assembly instead of LLVM IR
entry:
%0 = call i64 @nova_rt_print_i64(i64 42)
ret i64 0
}
(Abridged — the real output carries full type and TBAA metadata, per the Compilation pipeline spec.) Reach for nova compile when you want the artifact on disk; reach for nova emit when you just want to look at it once, in the terminal, with no leftover file to clean up.
DO: Use nova emit --asm when you specifically need to reason about generated machine code — for example, confirming a hot loop actually vectorized. DON'T: Expect either command to catch runtime bugs — both only prove the program lowers to valid IR/assembly; only nova run or nova test exercises the logic itself.
nova wasm — compiling to WebAssembly
NOVA's LLVM backend targets more than your host CPU. nova wasm compiles a .nova file straight to a runnable WebAssembly bundle: a .wasm binary plus two JavaScript glue files — <name>.run.cjs (a runnable entry point) and _wasm_runtime.cjs (the JS-side runtime support the WASM output depends on, for the host calls a WASM sandbox cannot make on its own). This is what lets the same NOVA source that runs as a native binary on your machine also run inside Node.js or a browser's WASM sandbox — no rewrite, no separate WASM-specific language subset.
nova wasm calc.nova
nova wasm calc.nova -o dist/calc -O1 # custom output path, one step of optimization$ node calc.run.cjs
42
The -O0/-O1 options work the same way they do for run/build, just with a lower ceiling: WASM optimization stops at -O1 rather than -O2, reflecting that a WASM build's goal is portability into a JS host, not squeezing out the last cycle of native performance.
DO: Use -o to name the output bundle when building more than one file to WASM in the same directory — otherwise each build's .wasm/.run.cjs pair overwrites the last one. DON'T: Assume every builtin behaves identically under WASM — the sandbox has no direct filesystem or raw socket access, so I/O-heavy code needs the JS host to broker those calls through _wasm_runtime.cjs.
nova eval — evaluating one expression without a file
Sometimes you just want to know what an expression evaluates to — how a builtin behaves on a specific input, whether an operator does what you expect — without creating a throwaway .nova file and running the whole pipeline. nova eval "<expr>" skips LLVM entirely: it tree-walks (directly interprets) a single expression and prints the result. This is the same job python -c "..." or node -e "..." do for their languages — a scratchpad for one-line questions, not a way to run programs.
nova eval "2 + 3 * 4" nova eval "len([1, 2, 3, 4, 5])" nova eval "upper(\"nova\")"
5
NOVA
DO: Reach for nova eval to answer a quick "what does this expression actually return" question — faster than opening an editor for a one-liner. DON'T: Use it to judge NOVA's performance — the tree-walking interpreter behind eval skips the entire LLVM pipeline, so it is intentionally not representative of compiled-binary speed.
Build option flags
These flags apply across nova run, nova build, and nova compile (partially to emit/wasm, as noted) — each one overrides that command's default, not just restates it:
| Flag | Applies to | Meaning |
|---|---|---|
-O0 | run / build / compile | No optimization — fastest compile, slowest generated code |
-O2 | run / build / compile | Full optimization — slowest compile, C-level generated code |
-o <path> | run / build / compile / wasm | Output file path |
--target <triple> | build / compile / emit | Cross-compilation target |
--old | run / build / compile | Use the legacy non-IR compiler backend |
nova build server.nova -O0 -o debug_server # fast compile, unoptimized — for local debugging nova build server.nova -O2 -o server # slow compile, optimized — what you ship nova run server.nova -O2 # benchmark the optimized path while still developing
server (built with -O2)
DO: Pass -o explicitly whenever you build more than one variant of the same source file — otherwise the second build silently overwrites the first binary. DON'T: Reach for --old for everyday work — it exists to triage a suspected regression in the current IR-based backend against the legacy one, not as a routine build mode.
Editor setup
NOVA has a VS Code extension in the nova-vscode/ directory. It provides syntax highlighting, goto definition (Ctrl+Click — works for functions, methods, structs, enums, traits, fields, and across modules), and diagnostics. Copy nova-vscode/ into your extensions directory and reload.
Cross-compiling for other platforms
What is this? Because NOVA compiles through LLVM, the compiler is not tied to producing code for the machine it happens to be running on. LLVM's whole value as a backend is that one frontend (NOVA's typed IR) can target any platform LLVM knows an instruction-selection and calling-convention story for — you just have to tell it which one. NOVA exposes this directly as a --target flag on nova build, nova compile, and nova emit. The exact same .nova source file, unmodified, produces a correct binary for a completely different OS and CPU architecture than the one doing the compiling.
This is not a hypothetical convenience. A solo developer running Windows who needs to ship a Linux container for a cloud host, or who wants an Apple Silicon build for a Mac release without owning a Mac, hits this on day one — and without --target, NOVA would be exactly as platform-locked as any compiler that only emits code for its own host.
The three commands differ in how far they carry the cross-compiled output, which matters because linking for a foreign target usually requires that target's own linker and libraries — tools you likely don't have installed:
nova build file.nova --target <t>— compiles and links a runnable executable for<t>. This only succeeds if a suitable cross-linker/toolchain for that target is available on your machine.nova compile file.nova --target <t>— stops after LLVM IR generation and writes a.llfile for<t>. No linker involved, so this always succeeds regardless of what toolchains you have installed.nova emit file.nova --target <t>— prints the generated LLVM IR straight to stdout (add--asmfor native assembly instead of IR). This is the fastest way to inspectwhat NOVA generates for a target you cannot actually run — e.g. checking the ARM64 codegen for a struct-heavy function from an x86 machine, with no ARM toolchain anywhere in the picture.
nova build myapp.nova --target linux # cross-compile for Linux nova build myapp.nova --target wasm # compile to WebAssembly nova emit myapp.nova --target macos-arm64 # inspect ARM64 IR
The recognized short names and the LLVM triple each one expands to:
--target value | LLVM triple | Platform |
|---|---|---|
native / windows / win | x86_64-pc-windows-msvc | Windows x64 (default on Windows) |
linux / linux-x64 | x86_64-unknown-linux-gnu | Linux x64 |
linux-arm64 / linux-aarch64 | aarch64-unknown-linux-gnu | Linux ARM64 |
macos / darwin / macos-x64 | x86_64-apple-darwin | macOS Intel |
macos-arm64 / darwin-arm64 | aarch64-apple-darwin | macOS Apple Silicon |
wasm / wasm32 | wasm32-unknown-unknown | WebAssembly |
If you pass a value that isn't one of the short names above, NOVA doesn't reject it — it forwards the string verbatim to LLVM as a raw target triple. This is the escape hatch for anything the short-name table doesn't cover: nova compile file.nova --target riscv64-unknown-linux-gnu works today even though riscv64 has no short alias, because the string reaches LLVM exactly as typed.
DO: Omit --target entirely when you just want a binary for the machine you're compiling on — NOVA already defaults to your host's triple, so no flag is needed for same-platform builds. Reach for a raw LLVM triple (--target aarch64-unknown-linux-musl, etc.) when you need a variant — like a musl libc target for a minimal container image — that has no short alias.
DON'T: Assume --target native means "whatever platform I'm on right now." Despite the name, it is a fixed alias for x86_64-pc-windows-msvc — identical to writing --target windows — not a host-detecting keyword. If your intent really is "this machine's own platform," omit --target rather than writing native. Also don't expect a typo like --target liunx to be caught with a friendly NOVA error — an unrecognized short name is passed straight through as a literal (and here, invalid) LLVM triple, so the failure surfaces later, from LLVM, not from NOVA's own flag parsing.
Targeting WebAssembly
What is this? wasm / wasm32 selects the wasm32-unknown-unknown LLVM triple — the same target used to run C, Rust, and Go code inside a browser tab or a WASM edge runtime. It slots into --target exactly like linux or macos-arm64 above: no special syntax, no separate compiler, the same nova build / nova compile / nova emit commands you already use for native builds.
nova compile myapp.nova --target wasm # .ll for the wasm32 triple — no linker required nova emit myapp.nova --target wasm32 # inspect the generated wasm32 IR directly
Selecting --target wasm on build/compile/emit controls exactly one thing: which LLVM triple your .nova source is lowered against. It is deliberately narrow, and it is a different tool from the dedicated nova wasm command (see the CLI reference) — nova wasm is the full packaging pipeline that produces a ready-to-run bundle (the .wasm module plus its JS host glue) in one step. Use --target wasm when you're driving build/compile/emit yourself and only need the wasm32 codegen; use nova wasm when you want something you can hand to a browser or a WASM host without assembling the glue code yourself.
DO: Use nova compile myapp.nova --target wasm or nova emit myapp.nova --target wasm --asm when you just want to confirm your code lowers cleanly for wasm32 — e.g. checking that a function doesn't depend on something host-OS-specific — without needing any WASM tooling installed locally. DON'T: Expect nova build myapp.nova --target wasm alone to hand you something you can drop into a <script> tag and run. Getting an actually browser-runnable artifact is the job of the separate nova wasm bundler command, not the general-purpose --target flag.
A correctness fix worth knowing about if you're targeting wasm: earlier builds of the WASM target never ran a program's top-level initialization step — module-level let bindings and similar setup code that runs before main() — because --no-entry WASM builds call only the exported main function directly, and that initialization lived somewhere --no-entry never reached. This is fixed: WASM builds now run the same initialization sequence native builds do, in the same order, before main executes.
Verified on four platforms, not just cross-compiled
What this means: the target table above says NOVA can produce a binary for Linux x64, Linux ARM64, and macOS from a Windows machine. Separately — and this is the part worth being precise about — NOVA's full regression suite (3,590 tests), run in both of its memory-tracking modes, plus the self-hosting bootstrap check (the compiler compiling itself to a byte-identical output, twice in a row) all pass natively on Windows, Linux x64, Linux ARM64, and macOS. Cross-compilation support and "we tested it for real on that platform" are two different claims, and both are true here — but we're telling you which is which rather than letting one imply the other.
Why this took real work, and what it actually found: testing the same compiler and runtime on four platforms surfaces bugs that testing on one platform structurally cannot — because the bug depends on something that only differs between platforms. That happened here, repeatedly, and the honest version of "we support macOS and Linux" includes what had to be fixed to make that true:
- Signed integer overflow was undefined behavior, not wrapping. NOVA's language spec says integer arithmetic wraps on overflow — but one runtime code path relied on C's
+/*directly, which is undefined on signed overflow in C itself. On most platforms the generated code happened to wrap anyway; on macOS, the optimizer used that undefined behavior differently, producing an intermittent, hard-to-reproduce wrong answer in a cryptographic routine. Single-platform testing had no way to catch this, because the bug wasn't in the logic — it was in which platform's compiler exploited the UB. - A data race in address-range lookup produced silently wrong answers on macOS specifically. Two threads could read and update the runtime's module address-range table without synchronization; macOS's memory layout made the race far more likely to actually manifest than it was on Windows or Linux, where the same unsynchronized code had been quietly getting lucky.
- String literals were identified by memory address, and an integer could collide with one. If a plain integer happened to equal the address of a literal string elsewhere in the binary, the runtime could mistake one for the other. Fixed by identifying literals by content and relocating them into the arena — a class of bug that address-based sniffing can never fully rule out, only reduce the odds of hitting.
- TLS didn't exist at all on macOS or Linux. The runtime's TLS upgrade function has three implementations — Windows' native SChannel, an OpenSSL-backed path, and a stub that silently returns failure when neither is compiled in. The POSIX build was shipping with neither real backend wired in, so every
https://request and everytls_upgrade()call failed on those platforms. Now building the OpenSSL backend into POSIX builds. - macOS's own assembler needed different object-file directives than Linux's. NOVA emits a small amount of hand-written assembly (used by the stack-overflow containment mechanism); Linux's ELF format and macOS's Mach-O format disagree on symbol prefixing and on directives like
.type/.sizethat ELF requires and Mach-O rejects.
None of this shows up as a feature — it shows up as "the tests pass on that platform now, and didn't before." That's the point: cross-platform correctness in a systems language is earned by finding and closing exactly these kinds of gaps, not by assuming LLVM's portability makes the runtime portable for free.
What the editor extension talks to: nova lsp
The VS Code extension above is a thin client — the actual intelligence behind goto-definition and diagnostics lives in a separate process the extension launches for you automatically, speaking the Language Server Protocol (LSP), the same open protocol behind rust-analyzer and gopls. nova lsp (equivalently nova --lsp) starts that server directly. You only need to run it by hand if you use an LSP-capable editor other than the bundled VS Code extension — Neovim, Sublime Text, Emacs with eglot — where you point the editor's language-server setting at this command instead of installing a separate plugin.
nova lsp
# equivalently:
nova --lspDO: Point any LSP-capable editor's language-server setting at nova lsp if you are not using the VS Code extension — the protocol is generic, the extension is just one client among many. DON'T: Run nova lsp from a terminal expecting normal interactive output — it speaks newline-delimited JSON-RPC over stdio and is meant to be launched and owned by an editor, not typed into directly.
nova repl — interactive experiments
For exploration that spans more than one expression — trying a few builtins in sequence, building up a small data structure by hand to see how it prints, checking how a match arm evaluates a specific value — nova repl starts a read-eval-print loop: it reads one line, evaluates it, prints the result, and (unlike nova eval) keeps every variable you defined available for the next line. This is the same idea as Python's interactive shell or plain node with no file argument.
nova repl
> x = 10
> y = 20
> x + y
30
> upper("nova")
NOVADO: Use nova repl when a question needs more than one expression to answer — accumulating state across several lines is exactly what nova eval cannot do. DON'T: Build anything you intend to keep inside the REPL session — nothing typed there is saved to a file; once something is worth keeping, move it into a .nova file.
2. Hello, world
What is this? Your first NOVA program. It demonstrates that NOVA requires zero ceremony — no imports, no class wrappers, no semicolons, no braces. One line does one thing.
print("Hello, world!")
Save as hello.nova and run: nova run hello.nova
Line-by-line: print is a built-in function available everywhere with no import. It takes any value, converts it to a string, writes it to stdout, and adds a newline. Compare: Python print("..."), Go fmt.Println("...") (needs import), Rust println!("...") (macro syntax), C printf("...\n") (needs #include). NOVA is the simplest.
String interpolation
name = "Alice" age = 30 print("Hello, {name}! You are {age} years old.") // Output: Hello, Alice! You are 30 years old. x = 7 items = ["a", "b", "c"] print("x squared is {x * x}") // x squared is 49 print("There are {len(items)} items") // There are 3 items
x squared is 49
There are 3 items
Line-by-line: {name} inside a double-quoted string is replaced by the variable's value. Any expression works: {x * x}, {len(items)}. This is always active — no f"..." prefix needed (unlike Python). To get a literal brace, escape it: \{.
DON'T: Write {variable} in a string and expect literal braces — NOVA will try to interpolate it. Use \{ and \} for literal braces. This is the #1 surprise for Python programmers who are used to needing the f prefix.
Format specifiers — controlling how a value prints
Plain {expr} interpolation calls the value's default string conversion. When you need to control the result of that conversion — pad a number with zeros, fix a float to two decimal places, align a column of text — add a format spec after a colon: {expr:[[fill]align][0][width][.precision][d|f|s|x|o|b]}. It's the same mini-language Python's f"{x:04d}" and Rust's format!("{:>8}") use. The trailing type letter (d decimal, f float, s string, x hex, o octal, b binary) documents intent but is optional — the compiler already knows the expression's type.
n = 7 print("{n:04d}") // 0007 — zero-padded to width 4 pi = 3.14159 print("{pi:.2f}") // 3.14 — fixed to 2 decimal places price = 9.5 print("Total: ${price:.2f}") // Total: $9.50 name = "Bob" print("[{name:>8s}]") // [ Bob] — right-align in 8 columns print("[{name:<8s}]") // [Bob ] — left-align in 8 columns print("[{name:^8s}]") // [ Bob ] — center-align byte_val = 255 print("{byte_val:04x}") // 00ff — zero-padded hexadecimal // format specs also work on index expressions, not just bare variables items = ["a", "bb", "ccc"] print("[{items[1]:>5s}]") // [ bb]
3.14
Total: $9.50
[ Bob]
[Bob ]
[ Bob ]
00ff
[ bb]
DO: Use format specs to build aligned tables and reports ("{name:<12s}{score:>6d}") instead of hand-rolling pad_left/pad_right calls and string concatenation.
DON'T: Forget that width is the TOTAL field width, not extra padding on top of the value — {name:>8s} on the 3-letter name "Bob" produces an 8-character result (5 spaces + "Bob"), not 3+8=11 characters.
Your first function
fn greet(name) print("Hello, {name}!") fn main() greet("Bob") greet("Carol")
Hello, Carol!
Line-by-line:
fn greet(name)— declares a function. No type annotation onname. The compiler infersnameis a string because you pass"Bob"at the call site. In Java:void greet(String name). In NOVA: justfn greet(name).- The body is indented 4 spaces. Indentation defines the block (like Python). No braces, no
end. fn main()— the program entry point. When it exists, execution starts here.
DON'T: Use curly braces { } or semicolons. NOVA is indentation-based. fn greet(name) { print("hi"); } is a syntax error.
main() — when you need it
For simple one-file scripts, top-level code runs directly — no main() needed. For larger programs with multiple functions that call each other, use fn main():
fn add(a, b) a + b fn main() result = add(3, 4) print("3 + 4 = {result}") // Output: 3 + 4 = 7
Line-by-line breakdown:
fn add(a, b)— declares a function calledaddthat takes two parameters. No type annotations written. The compiler figures outaandbare integers because you calladd(3, 4)below — integer literals tell the compiler the type.a + b— this is the entire body of the function. Noreturnkeyword. In NOVA, the last expression in a function is automatically its return value.a + bevaluates to7, and that value is returned to the caller. This is like Rust, Ruby, and Kotlin, where the last expression is the implicit return.fn main()— themainfunction is the entry point. Whenfn main()exists, NOVA starts executing there instead of running top-level statements in order.result = add(3, 4)— calls theaddfunction with arguments 3 and 4, stores the return value (7) in a variable calledresult. Nolet, novar, noconst— just write the name and assign. This is NOVA's zero-ceremony design: the simplest possible syntax for the most common operation.print("3 + 4 = {result}")— prints the string with{result}replaced by the value of the variable (which is7). Output:3 + 4 = 7.
When to use main() vs top-level code:
- One-file scripts: Do not need
main(). Top-level statements run in order. Great for quick experiments. - Multi-file projects: Use
main()as the entry point so the compiler knows where execution begins. - Programs with tests: Use
main()so that test functions (test_run/test_summary) do not conflict with normal execution.
DO: Use fn main() for programs with multiple functions. DON'T: Write let result = add(3, 4) — there is no let in NOVA. Just write result = add(3, 4). This is the most common mistake for developers coming from JavaScript or Kotlin.
Comments
// This is a line comment — everything from // to end of line is ignored x = 42 // This is an inline comment // NOVA does not have multi-line /* */ comments // Use multiple // lines instead // like this // and this // Comments are great for explaining WHY, not what: timeout_ms = 5000 // 5 seconds — enough for slow mobile connections
DON'T: Write /* multi-line comment */ — NOVA does not support this syntax. Use multiple // lines. This keeps the syntax simpler: one comment style, not two.
3. Values and types
What is this? NOVA has five basic value types. The compiler infers all of them — you never have to write type annotations for local variables. The Hindley-Milner (HM) type inference engine figures out every type from how values are used.
Integers
Integers are 64-bit signed values. They can hold any value from -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807.
x = 42 y = -17 big = 1_000_000_000 // underscores for readability hex = 0xFF // hexadecimal: 255 bin = 0b1010 // binary: 10 oct = 0o77 // octal: 63 print(hex) // 255
Integer overflow behavior: NOVA integers wrap on overflow. If you add 1 to the maximum 64-bit integer, it wraps to the minimum — defined behavior, never undefined like C.
max_int = 9223372036854775807 print(max_int + 1) // -9223372036854775808 (wrapped) // Integer division truncates toward zero print(7 / 2) // 3 (not 3.5) print(-7 / 2) // -3 (rounds toward zero, not -4) print(7 % 2) // 1 (remainder)
3
-3
1
Overflow detection with checked_add / checked_sub / checked_mul: If you need to fail fast on overflow instead of wrapping silently, use the checked variants — on success they return the same plain int as +/-/*, but the moment the operation overflows they panic (crash the current task) instead of returning a value. They do not return a Result:
a = 9223372036854775807 // max int result = checked_add(a, 1) // PANICS: "NOVA panic: integer overflow" — crashes this task, no value returned print(result) // never reached // Safe multiplication — useful in cryptography and image processing r2 = checked_mul(1000000, 1000000) // 10^12 — fits in i64, returns 1000000000000 r3 = checked_mul(10000000000, 10000000000) // 10^20 — too large, PANICS: "integer overflow"
DO: Use underscores in large numbers: 1_000_000 instead of 1000000. Use checked_add/checked_mul when overflow would be a security issue (counter rollover, buffer size calculation). DON'T: Expect 7 / 2 to return 3.5 — integer division returns 3. Use 7.0 / 2.0 for float division.
overflow_panic() — triggering the same crash by hand
checked_add, checked_sub, and checked_mul don't invent a special failure mode when they detect overflow — internally they call overflow_panic(), the same primitive you can call directly. It immediately terminates the current task with "NOVA panic: integer overflow". Reach for it when you have already computed your own overflow condition by hand — checking that a multiplication would overflow before doing the multiply, for instance — and you want the identical crash and error message the checked_* family gives, rather than inventing your own ad hoc error string.
fn buffer_bytes(elements, elem_size) // Guard the multiply by hand: elements * elem_size would overflow i64 if elem_size != 0 and elements > 9223372036854775807 / elem_size overflow_panic() // same crash checked_mul would produce elements * elem_size print(buffer_bytes(1000, 8)) // 8000 print(buffer_bytes(9223372036854775807, 2)) // panics: NOVA panic: integer overflow
DO: Call overflow_panic() when you have already worked out your own overflow condition and want the exact crash and message checked_add/checked_sub/checked_mul produce — one consistent failure mode across the codebase. DON'T: Expect a value back to match against — like the checked_* family, overflow_panic() never returns; it unconditionally terminates the current task (or the whole program, if the root task calls it).
Floats
Floats are 64-bit IEEE 754 double-precision values, the same as double in C or float in Python.
pi = 3.14159 sci = 1.5e10 // scientific notation: 15000000000.0 // THE FLOAT COMPARISON TRAP — do not do this: x = 0.1 + 0.2 if x == 0.3 print("equal") // This will NOT print — 0.1+0.2 = 0.30000000000000004 // CORRECT: compare with a tolerance if abs(0.1 + 0.2 - 0.3) < 0.0001 print("approximately equal") // This prints
Math functions available for floats:
print(sqrt(16.0)) // 4.0 print(abs(-5.3)) // 5.3 print(floor(3.7)) // 3.0 print(ceil(3.2)) // 4.0 print(round(3.5)) // 4.0 (rounds to nearest, .5 rounds up) print(pow(2.0, 10.0)) // 1024.0 print(sin(3.14159)) // ~0.0 (angles in RADIANS) print(cos(0.0)) // 1.0 print(log(2.71828)) // ~1.0 (natural log, base e) print(log2(1024.0)) // 10.0 print(log10(1000.0)) // 3.0 print(exp(1.0)) // 2.71828... (e^x) print(min(3.0, 7.0)) // 3.0 print(max(3.0, 7.0)) // 7.0 print(atan2(1.0, 1.0)) // 0.7853... (pi/4 — angle of vector (1,1)) print(hypot(3.0, 4.0)) // 5.0 (sqrt(a²+b²) — hypotenuse)
5.3
3.0
4.0
4.0
1024.0
~0.0
1.0
~1.0
10.0
3.0
2.71828...
3.0
7.0
0.7853...
5.0
DON'T: Compare floats with ==. 0.1 + 0.2 == 0.3 is false (IEEE 754 rounding). Use abs(a - b) < 0.0001. This is true in every language — not just NOVA.
NOTE: Trig functions (sin, cos, atan2) use radians, not degrees. To convert: degrees * 3.14159 / 180.0.
Strings
Strings are immutable UTF-8 sequences. Every operation that "changes" a string creates a new one.
s = "Hello, NOVA!" print(len(s)) // 12 print(upper(s)) // HELLO, NOVA! print(lower(s)) // hello, nova! print(find(s, "NOVA")) // 7 (index of first match) print(replace(s, "NOVA", "World")) // Hello, World! print(starts_with(s, "Hello")) // true print(split("one two", " ")) // ["one", "two"] print(slice(s, 7, 11)) // NOVA print(char_at(s, 0)) // H print(char_at(s, -1)) // ! (negative = from end) print(pad_left("42", 5, "0")) // 00042 print(repeat("ha", 3)) // hahaha
HELLO, NOVA!
hello, nova!
7
Hello, World!
true
[one, two]
NOVA
H
!
00042
hahaha
The string building trap: Strings are immutable, so result = result + piece inside a loop copies the entire accumulated string each iteration — O(n²) total work. For N=1000, this copies ~500,000 characters instead of ~1000.
// SLOW: O(n²) — each + copies the whole string result = "" for i in 0..999 result = result + str(i) + "," // FAST: O(n) — collect parts in a list, join once at the end parts = [] for i in 0..999 push(parts, str(i)) result = join(parts, ",") // ALSO FAST: buffer approach (no list overhead) buf = buffer_create() for i in 0..999 buf_append(buf, str(i)) result = buf_to_str(buf)
DON'T: Write result = result + piece inside a loop — it is O(n²) and becomes a visible performance problem above ~100 iterations. Use join(parts, sep) or buffer_create().
Triple-quoted strings — multi-line text, dedented
Ordinary "..." strings are single-line. For multi-line text — embedded HTML, SQL queries, JSON templates, help text — use triple quotes: """...""". NOVA strips whatever leading indentation is common to every line of the literal, so you can indent the literal to match the surrounding code without that indentation becoming part of the string's value.
html = """ <html> <body> <h1>Report</h1> </body> </html> """ print(html) // the 4-space indent common to every line is stripped: // <html> // <body> // <h1>Report</h1> // </body> // </html> name = "Alice" greeting = """Hello, {name}!""" print(greeting) // Hello, {name}! — the braces are LITERAL here, not interpolated
<body>
<h1>Report</h1>
</body>
</html>
Hello, {name}!
For text that is naturally full of backslashes — regular expressions, Windows paths, JSON containing escaped quotes — triple backtick blocks go one step further than triple quotes: they skip escape processing entirely, so every backslash is a literal character instead of the start of an escape sequence.
pattern = ```\d+\.\d\d``` print(pattern) // \d+\.\d\d — every backslash is literal, not an escape code win_path = ```C:\Users\alice\data.csv``` print(win_path) // C:\Users\alice\data.csv — no need to double every backslash
C:\Users\alice\data.csv
DO: Use triple-quoted strings for embedded multi-line templates where matching the surrounding code's indentation matters, and triple-backtick blocks for text dense with backslashes (regex patterns, Windows paths).
DON'T: Expect {name} to interpolate inside """...""" — it does not. If you need both multi-line text AND interpolation, build the pieces with ordinary "..." strings and join() or buf_append() instead.
Type conversions
print(str(42)) // "42" print(str(3.14)) // "3.14" print(str(true)) // "true" print(int("42")) // 42 print(float("3.14")) // 3.14 print(int(3.9)) // 3 (truncates toward zero — does NOT round) print(int(-3.9)) // -3 (truncates toward zero) print(float(7) / float(2)) // 3.5 (explicit float division)
3.14
true
42
3.14
3
-3
3.5
Line-by-line breakdown:
str(42)— converts integer 42 to the string"42". Works on any value.int("42")— parses the string"42"as a decimal integer. Crashes if the string is not a valid integer — useparse_int_safe("42")if the input might be invalid (returnsOk(42)orErr(...)).int(3.9)— truncates the decimal part.3.9becomes3. This is NOT rounding —int(3.9)andint(3.1)both return3. To round, useint(round(3.9))which gives4.float(7) / float(2)— converts both ints to float first, then divides. Gives3.5. Without thefloat()calls,7 / 2would give3(integer division).
Booleans
a = true b = false print(not a) // false print(a and b) // false — both must be true print(a or b) // true — at least one must be true // Comparison operators return bool print(3 == 3) // true print(3 != 4) // true print(3 < 4) // true print(3 >= 3) // true
false
true
true
true
true
true
Short-circuit evaluation: and and or only evaluate the right side if needed:
// If the left side of `and` is false, right side is NEVER evaluated x = 0 if x != 0 and 10 / x > 2 print("safe") // Without short-circuit: 10/0 would crash. With short-circuit: x != 0 is false, // so `10/x` is never reached. Safe. // If the left side of `or` is true, right side is NEVER evaluated if true or expensive_check() print("fast") // expensive_check() is never called
Truthiness: In boolean contexts like if, these values are falsy: 0, "" (empty string), false, and null. Everything else is truthy:
if 42: print("truthy") // prints — non-zero int is truthy if "": print("truthy") // does NOT print — empty string is falsy if "hello": print("truthy") // prints — non-empty string is truthy if []: print("truthy") // does NOT print — empty list is falsy if [0]: print("truthy") // prints — list with one element is truthy
DO: Use and/or/not for boolean logic. DON'T: Write &&/||/! — they do not exist in NOVA and cause a syntax error. Also: write if x, not if x == true. Write if not x, not if x == false.
Lists
Lists are ordered, mutable sequences that can hold any values. They are the primary collection type in NOVA.
nums = [1, 2, 3, 4, 5] names = ["alice", "bob", "carol"] mixed = [1, "hello", 3.14, true] // lists can hold mixed types empty = [] // Access by index — 0-based print(nums[0]) // 1 (first element) print(nums[4]) // 5 (last element via index) print(nums[-1]) // 5 (last element via negative index) print(nums[-2]) // 4 (second to last) print(len(nums)) // 5 // SAFETY: out-of-range access panics with a clear message print(nums[10]) // Runtime error: index 10 out of bounds (list length 5) // In C this would silently read garbage memory. NOVA stops you.
5
5
4
5
0
Line-by-line breakdown:
nums[0]— index 0 is the first element. Lists are "zero-indexed" — a 5-element list has indices 0, 1, 2, 3, 4.nums[-1]— negative indices count from the end.-1is the last element,-2is second-to-last, etc.len(nums)— returns the number of elements (5). Note: the last valid index islen(nums) - 1= 4, not 5.- Out-of-range access — NOVA checks bounds at runtime and panics with a clear message rather than silently corrupting memory (which C does).
Modifying lists:
nums = [1, 2, 3] // Append to end — O(1) amortized push(nums, 4) print(nums) // [1, 2, 3, 4] // Remove and return last element — O(1) last = pop(nums) print(last) // 4 print(nums) // [1, 2, 3] // Insert at position — O(n) because elements shift insert(nums, 1, 99) print(nums) // [1, 99, 2, 3] // Remove by value (first occurrence) — O(n) remove(nums, 99) print(nums) // [1, 2, 3] // Remove by index — O(n) because elements shift remove_at(nums, 0) print(nums) // [2, 3] // Membership test print(2 in nums) // true print(99 in nums) // false // Concatenate two lists — creates a new list a = [1, 2] b = [3, 4] c = a + b print(c) // [1, 2, 3, 4] // Sort in place — modifies the original list data = [3, 1, 4, 1, 5] sort(data) print(data) // [1, 1, 3, 4, 5] // Reverse in place reverse(data) print(data) // [5, 4, 3, 1, 1] // Slice — returns a new sub-list (does NOT modify original) sub = data[1:3] print(sub) // [4, 3]
Trailing commas
NOVA allows — but never requires — a trailing comma right before the closing bracket in any comma-separated construct: list literals, dict literals, function call arguments, function parameter lists, and struct field initializers. The comma carries no meaning of its own; it exists purely so that adding, removing, or reordering a line never forces you to also edit the punctuation on the line above it.
// Trailing comma in a multi-line list literal scores = [ 95, 87, 91, ] print(len(scores)) // 3 // Trailing comma in a dict literal config = { "host": "localhost", "port": 8080, } // Trailing comma in a function call and in a parameter list fn volume( l, w, h, ) l * w * h print(volume( 2, 3, 4, )) // 24 // Trailing comma in a struct initializer (must still be a single line — see below) type Point x: float y: float p = Point { x: 1.0, y: 2.0, } print(p.x) // 1.0
24
1.0
DO: Add a trailing comma to any list, dict, call, or parameter list you expect to write across multiple lines — every entry becomes a self-contained line, so version-control diffs stay one line per change instead of also touching the line above when you append an entry. DON'T: Try to spread a TypeName { ... } struct initializer itself across multiple lines — unlike lists, dicts, and calls, the { ... } here must be written on one physical line; a trailing comma still works, it just doesn't buy you the multi-line layout it does elsewhere.
Dicts
Dicts are unordered key-value maps. Keys are typically strings. Very fast lookup — O(1) amortized via hash table.
person = {"name": "Alice", "age": 30, "city": "NYC"}
// Read a value
print(person["name"]) // Alice
// Check key existence BEFORE accessing — missing key returns default, not error
if contains(person, "email")
print(person["email"])
else
print("no email") // prints this
// Set / add a key
person["email"] = "alice@example.com"
print(person["email"]) // alice@example.com
// Update an existing key
person["age"] = 31
print(person["age"]) // 31
// Delete a key
delete(person, "city")
print(contains(person, "city")) // false
// Length
print(len(person)) // 3
// Get all keys / all values
ks = keys(person)
vs = values(person)
// Iterate over key-value pairs
for k in keys(person)
print("{k}: {person[k]}")Line-by-line breakdown:
{"name": "Alice", "age": 30}— dict literal. Keys are strings in quotes, values can be any type.person["name"]— dict lookup. If the key does not exist, returns a default value (0 for int context, "" for string context), which can silently cause bugs.contains(person, "email")— always check key existence before accessing a key that might not be there. This prevents silent default-value bugs.delete(person, "city")— removes a key from the dict. Dict is modified in-place.for k in keys(person)— iterate over all keys. Dict iteration order is not guaranteed (hash map).
DO: Always check contains(d, key) before accessing d[key] when the key might not exist. DON'T: Access d[key] on a missing key expecting an error — it silently returns a default value and causes hard-to-find bugs.
null — the absence of a value
x = null if x print("has value") else print("is null") // prints this — null is falsy print(x == null) // true
1
Use null sparingly — only for optional fields. Prefer the Result type (see Section 9) for operations that can fail, because Result carries the reason for failure while null just means "nothing." null alone tells you nothing about what went wrong.
Type inference: how the compiler knows what type everything is
NOVA uses Hindley-Milner type inference (the same algorithm used by Haskell and Rust). You write no type annotations on local variables. The compiler figures out every type from how you use the value:
x = 42 // compiler infers: int (because 42 is an int literal) y = 3.14 // compiler infers: float name = "Alice" // compiler infers: string items = [1, 2, 3] // compiler infers: list of int // Inference works across function calls too fn double(x) x * 2 print(double(5)) // compiler infers x is int here → returns int print(double(3.14)) // compiler infers x is float here → returns float // If types conflict, you get a compile error x = 42 x = "hello" // compile error: cannot assign string to int variable
When to write type annotations — for 95% of code, write zero annotations. The compiler figures everything out. The two cases where you do need them:
- Struct field types (critical for performance):
x: floaton a struct field means native CPU register math.x: Float(capital) means dynamic dispatch — 150× slower. Always use lowercase. - Function parameters for public APIs:
fn distance(a: Point, b: Point) -> float— clearer for readers even though the compiler could infer it.
type Point x: float // CORRECT: lowercase float = native CPU math = fast y: float // x: Float ← WRONG: capital Float = dynamic dispatch = 150x SLOWER
DO: Let the compiler infer types. Write annotations only on struct fields and public function signatures. DON'T: Write type annotations on every variable like Java — that defeats NOVA's simplicity promise and is unnecessary.
The any type and runtime type predicates
Most NOVA code never needs any — Hindley-Milner inference pins down a concrete type for nearly everything you write. any exists for the genuinely dynamic slice of code: values arriving from JSON, a database row, or a plugin boundary whose shape isn't known until the program is running. Declaring something any needs no special ceremony — the compiler widens to any at the declaration and narrows it back down every time you check it with a type predicate.
fn describe(v: any) -> string if is_int(v) return "int: " + str(v) if is_float(v) return "float: " + str(v) if is_string(v) return "string: " + v if is_list(v) return "list of " + str(len(v)) + " items" if is_struct(v) return "struct " + type_name(v) "unknown" print(describe(42)) // int: 42 print(describe(3.14)) // float: 3.14 print(describe("hi")) // string: hi print(describe([1, 2, 3])) // list of 3 items
float: 3.14
string: hi
list of 3 items
The full predicate set: is_int, is_float, is_string, is_bool, is_list, is_dict, is_struct, and is_numeric (true for either int or float, useful when you don't care which). Each returns a plain bool, so they compose directly with if, and/or, and match guards.
type_of(v) and type_name(v) answer two different questions. type_of returns a coarse KIND string — "int", "string", "list", "struct" — and that last one is the SAME string for every struct type that has ever existed in your program. type_name returns the concrete declared name:
type Point x: int y: int let v: any = Point(1, 2) print(type_of(v)) // struct -- coarse kind, same for EVERY struct type print(type_name(v)) // Point -- the concrete type name
Point
DO: Branch on type_name(v), or better, match on the concrete type, when you need to tell two struct types apart in any-typed code. DON'T: Use type_of(v) to distinguish a Point from a User — it collapses every struct type to the single string "struct" and cannot tell them apart.
The null value
null is a special value meaning "no value." It is used when a function or lookup has no result. Unlike Python's None or Java's null, you rarely need to write null yourself — it appears as a return value from functions that can fail to find something:
// dict.get() returns null if the key does not exist d = {"x": 42} val = d["y"] // "y" is not in the dict if val == null print("key not found") // channel_recv_timeout returns null on timeout (no message received) ch = channel() result = channel_recv_timeout(ch, 100) // wait 100ms if result == null print("timed out")
The null trap — always check before using: Accessing a field or calling a function on null will panic at runtime.
// WRONG: crash at runtime if key not found name = user_data["name"] print(upper(name)) // CRASH if name is null // CORRECT: check null before use name = user_data["name"] if name == null print("no name provided") else print(upper(name))
DO: Always check if value == null before using a value that might be null (dict lookups on unknown keys, timeout receives, optional function returns). DON'T: Assume dict lookups always return a value — any key that was never set returns null.
Optional types with T?
T? is sugar for Option<T> — a type that is either "there is a value of type T" or "there is nothing." It works anywhere a type appears: a struct field, a function parameter, a let type annotation, or a return type. Reach for it when "missing" needs to be a tracked, checked possibility rather than a value you have to remember to compare against null — the compiler forces every caller to deal with the "nothing" case before it can reach the value inside.
type User id: int name: string // Return type says: "either a User, or nothing" — right in the signature fn find_by_id(users, id) -> User? for u in users if u.id == id return some(u) none() users = [User { id: 1, name: "Alice" }, User { id: 2, name: "Bob" }] found = find_by_id(users, 2) if is_some(found) print(unwrap(found).name) // Bob else print("no such user") missing = find_by_id(users, 99) if is_some(missing) print(unwrap(missing).name) else print("no such user") // no such user
no such user
The same sugar works directly on a struct field or a let-typed local:
type Profile username: string bio: string? // a profile might not have a bio yet p = Profile { username: "nova_dev", bio: none() } print(is_some(p.bio)) // false
DO: Reach for T? for data that is genuinely, sometimes absent — a bio nobody filled in yet, a lookup that legitimately might not find anything. DON'T: Mix T? and plain null for the same kind of "missing" inside one function boundary — pick one representation of "nothing" per API so callers only need to learn a single check.
Variable reassignment and scope
Variables are block-scoped. Once created, a variable can be reassigned with =. NOVA has compound assignment operators for common update patterns:
x = 10 // Reassignment — just use = again x = x + 1 // x is now 11 x = x * 2 // x is now 22 // Compound assignment — shorthand for the common patterns x += 5 // x = x + 5 → x is now 27 x -= 7 // x = x - 7 → x is now 20 x *= 3 // x = x * 3 → x is now 60 x /= 4 // x = x / 4 → x is now 15 x %= 7 // x = x % 7 → x is now 1 // NOVA has no ++ or -- operators // Use += 1 and -= 1 instead i += 1 // equivalent to i++ in C/Java/JavaScript
Variable scope — indentation determines lifetime:
x = 10 // top-level scope — exists throughout the whole block if x > 5 y = 20 // y is only visible inside this if-block print(y) // 20 — OK: inside the if-block print(x) // 10 — OK: x is in the outer scope // print(y) ← ERROR: y is out of scope here // Loop variables are only visible inside the loop body for i in 0..4 square = i * i print(square) // OK: inside the loop // print(i) ← ERROR: i is out of scope after the loop // print(square) ← ERROR: square is out of scope after the loop
10
0
1
4
9
DON'T: Write i++ or i-- — NOVA has no increment/decrement operators. Use i += 1 and i -= 1. This is a common mistake for C, Java, and JavaScript developers.
Immutable by default: let and let mut
Both x = 5 and let x = 5 compile to the same code — let never changes what's generated. What it changes is what the line COMMUNICATES: let marks "this is a brand-new binding," so a plain x = 5 later in the same scope reads unambiguously as a reassignment of something that already existed, not a second declaration. A bare let binding is meant to be treated as not-reassigned again — the moment you know a name will be updated later in the function, spell it let mut so a reader scanning top-to-bottom already knows, before they hit the reassignment, which names are going to change.
let x = 5 // a plain binding — not reassigned again below let mut counter = 0 // explicitly mutable — this name WILL be reassigned counter = counter + 1 counter = counter + 1 print(counter) // 2 let mut total = 0 for n in [10, 20, 30] total += n print(total) // 60
60
DO: Write let for every new binding — it is the recommended NOVA style, not merely tolerated syntax. Add mut the moment a binding gets reassigned. DON'T: Treat bare x = 5 (no let) as wrong — it compiles to identical code; let is a readability convention the compiler doesn't enforce, not a requirement it checks.
Multi-assign and swap
NOVA can assign to several names in one statement: a, b = 1, 2 binds them in parallel, and a, b = b, a swaps two values without a temporary variable. The right-hand side is fully evaluated — as a whole, using the OLD values of every name involved — before any assignment on the left happens, which is exactly what makes the swap correct even though a is being read and written in the same statement.
a = 1 b = 2 a, b = b, a print("a={a} b={b}") // a=2 b=1 // Parallel assignment for unrelated values — not just a swap x, y, z = 10, 20, 30 print("x={x} y={y} z={z}") // x=10 y=20 z=30 // A real use: swapping two list elements in place, e.g. inside a sort nums = [5, 3, 8, 1] i = 0 j = 2 nums[i], nums[j] = nums[j], nums[i] print(nums) // [8, 3, 5, 1]
x=10 y=20 z=30
[8, 3, 5, 1]
DO: Use a, b = b, a for a swap instead of a temp variable — one line, and it's correct by construction because the whole right-hand side is evaluated before anything is written. DON'T: Stretch this to a wide parallel-assignment line with many names — past 2–3 targets, separate assignment statements read more clearly than one line that requires counting positions on both sides.
4. Control flow
What is this? How to make decisions and repeat things. NOVA uses indentation for blocks (like Python), and/or/not for boolean operators (NOT &&/||/!), and if is an expression (it returns a value).
If / else
fn classify(n) if n > 0 "positive" else if n == 0 "zero" else "negative" fn main() print(classify(5)) // positive print(classify(0)) // zero print(classify(-3)) // negative
zero
negative
Line-by-line:
if n > 0— evaluates the condition. No parentheses needed (unlike C, Java, JavaScript)."positive"— the last expression in each branch is the value of the wholeifexpression. This function returns astringwithout writingreturn.else if n == 0— chained condition. NOVA useselse if(two words), notelif.
If as an expression with then: For short single-line results, use then:
label = if n > 0 then "pos" else "non-pos"
DO: Let if return a value rather than assigning in each branch.
DON'T: Use && or || or ! — they don't exist in NOVA. Use and, or, not.
Ternary if-expression — the value-first form
Besides if COND then A else B, NOVA also accepts the value-first ternary order familiar from Python: VALUE_IF_TRUE if COND else VALUE_IF_FALSE — no then keyword. Both are legitimate if-expression spellings; pick whichever reads more naturally at the call site. The value-first form chains right-associatively, so it stacks into an else-if ladder without extra parentheses.
x = 7 label = "big" if x > 3 else "small" print(label) // big // chains right-associatively — reads like an else-if ladder grade = "A" if x > 90 else "B" if x > 80 else "C" print(grade) // C (x is 7 — neither > 90 nor > 80) // the equivalent then/else form, for comparison — same result, different word order label2 = if x > 3 then "big" else "small" print(label2) // big
C
big
DO: Reach for the value-first form ("big" if x > 3 else "small") when porting logic straight from Python — it reads identically.
DON'T: Mix the two word orders inside one expression (if x > 3 then "big" if y > 0 else "small") — pick one order per expression so a reader isn't tracking two different grammars at once.
A bare if/else as an entire function body
The multi-line block form above (if / else if / else, each branch on its own indented line) works as a function's implicit-return body. So does the then-based ternary shown above — but only on the right-hand side of an assignment. There's a third shape, tighter than either: write the condition and both branches on one line, with no then, as the function's only statement. NOVA recognizes this specifically when a statement starts with if — the value of the if/else expression becomes the function's return value, the same implicit-return rule used everywhere else in the language.
fn max(a: int, b: int) -> int if a > b a else b fn abs_val(n: int) -> int if n < 0 -n else n fn main() print(max(3, 7)) print(max(12, 5)) print(abs_val(-8)) print(abs_val(6))
12
8
6
Notice max has no then anywhere. then belongs to the ternary-on-assignment form (label = if cond then a else b) shown above — it exists there to separate the condition from the value when both sit after an = on the same line. When the if opens the statement itself there is nothing to separate it from, so the grammar doesn't need the keyword: if a > b a else b parses unambiguously as "condition, then-branch, else-branch" without one.
DO: Collapse a function that is purely a single comparison into this one-line form instead of the four-line block version — it reads like the mathematical definition of the function. DON'T: Write then here — if a > b then a else b as a function's opening statement is not the same construct as the assignment-RHS ternary; drop then whenever the if starts the statement.
For loops and ranges
// Iterate a list for x in ["a", "b", "c"] print(x) // a // b // c // Range with .. (right end is EXCLUSIVE — half-open, like Python's range()) for i in 0..5 print(i) // 0 1 2 3 4 // Iterate with index: "for i, v in xs" is the native form — do NOT wrap it in enumerate() fruits = ["apple", "banana", "cherry"] for i, fruit in fruits print("{i}: {fruit}") // 0: apple // 1: banana // 2: cherry // Iterate a dict scores = {"alice": 95, "bob": 87} for name, score in scores print("{name} scored {score}")
b
c
0
1
2
3
4
0: apple
1: banana
2: cherry
NOVA range 0..5 is exclusive of the right end — it gives 0, 1, 2, 3, 4, exactly like Python's range(0, 5) (also exclusive of the end). To loop N times: use 0..N (gives exactly N iterations).
While and break/continue
i = 0 while i < 10 i = i + 1 if i % 2 == 0 continue // skip even numbers print(i) // 1 3 5 7 9 // Search loop: break when found nums = [4, 7, 2, 9, 1] i = 0 while i < len(nums) if nums[i] == 9 print("found 9 at index {i}") break i = i + 1
3
5
7
9
found 9 at index 3
The loop keyword
For infinite loops, loop is cleaner than while true:
count = 0 loop count = count + 1 if count >= 5 break print(count) // 5
Compound assignment operators
x = 10 x += 5 // x = 15 x -= 3 // x = 12 x *= 2 // x = 24 x /= 4 // x = 6 x %= 4 // x = 2
Multi-target assignment, swapping, and comma-list destructuring
NOVA lets a single assignment target several names at once from a comma-separated list. The right-hand side is evaluated into temporaries FIRST, then assigned left to right — so a, b = b, a is a genuine simultaneous swap, not a bug waiting for a missing temp variable.
a = 1 b = 2 a, b = b, a // swap — no temp variable needed print("a={a}, b={b}") // a=2, b=1 // works with any number of targets, and any expressions on the right x, y, z = 10, 20, 30 print("{x} {y} {z}") // 10 20 30 // proof it's a REAL simultaneous swap: a sequential "a = b" then "b = a" // would leave BOTH equal to the original b — this doesn't nums = [5, 1, 9, 3] nums[0], nums[3] = nums[3], nums[0] print(nums) // [3, 1, 9, 5]
10 20 30
[3, 1, 9, 5]
The same left/right shape works with let, treating the right-hand side as a list and mapping positions 1:1 — this is comma-list destructuring. Use it when a function already returns a list and you want to name each element instead of indexing into it.
fn divmod(a, b) [a / b, a % b] let a, b = [10, 20] print("a={a}, b={b}") // a=10, b=20 let q, r = divmod(17, 5) print("q={q}, r={r}") // q=3, r=2
q=3, r=2
DO: Use a, b = b, a for swaps — it's clearer and safer than juggling a manual temp variable.
DON'T: Assume the targets are written one at a time from values that are changing as you go — ALL right-hand values are computed before any target is written, so a, b = b, a can never partially clobber itself, unlike a naive two-line a = b then b = a.
Iterating dicts — keys, values, pairs
Dicts can be iterated in three patterns. Pick the one that matches what you actually need:
scores = \{"Alice": 95, "Bob": 87, "Carol": 91\}
// Pattern 1: iterate keys, look up value separately
for name in keys(scores)
print("{name}: {scores[name]}")
// Pattern 2: destructure key/value pairs directly (preferred)
for name, score in scores
print("{name}: {score}")
// Pattern 3: build a new dict from two parallel lists
names = ["Alice", "Bob", "Carol"]
points = [95, 87, 91]
result = \{\}
for i in 0..len(names) - 1
result[names[i]] = points[i]
print(result) // {Alice: 95, Bob: 87, Carol: 91}Line-by-line breakdown:
for name in keys(scores)—keys()returns a list of the dict's keys in insertion order. This pattern is useful when you need just the keys, or when you need to conditionally skip looking up the value.for name, score in scores— iterating a dict directly yields key/value pairs. This is the preferred pattern: it's one lookup per iteration instead of two (key lookup + value lookup). Python calls the equivalentdict.items(); NOVA makes it the default dict iteration.result[names[i]] = points[i]— builds a dict by combining two parallel lists. The range0..len(names) - 1is exclusive on the right end (half-open: includesa, excludesb), so with 3 names this iteratesi = 0, 1, 2.
DO: Use for k, v in mydict when you need both the key and the value — it's one operation. DON'T: Use for k in mydict and then do mydict[k] inside the loop — that works but performs two lookups per iteration when one would do.
Destructuring tuples in a for-loop
When you're iterating a list whose elements are themselves tuples — pairs, coordinates, records you built by hand — for (a, b) in pairs destructures each element positionally right in the loop header, the same way let (a, b) = pair destructures a single tuple. This is distinct from the list/dict auto-dispatch form for i, v in items (which supplies the index or key FOR you): here, the tuples already exist as elements of the sequence you're iterating.
pairs = [(1, "one"), (2, "two"), (3, "three")] for (n, word) in pairs print("{n} = {word}") // 1 = one // 2 = two // 3 = three // real use: coordinates your own code built, not a dict points = [(0, 0), (3, 4), (6, 8)] for (px, py) in points print("distance from origin: {sqrt(px * px + py * py)}") // distance from origin: 0.0 // distance from origin: 5.0 // distance from origin: 10.0
2 = two
3 = three
distance from origin: 0.0
distance from origin: 5.0
distance from origin: 10.0
DO: Reach for for (a, b) in pairs specifically when the sequence's elements are already tuples you built.
DON'T: Use it on a plain dict — iterating a dict directly with for k, v in dict_expr is the auto key/value form and needs no parentheses; wrapping a dict in (k, v) parentheses is for tuple-shaped elements, not dict iteration.
loop — finding the first match
When you need to exit a loop from the middle — not from the condition at the top or bottom — loop with break is the clearest tool. This is better than while true because it signals intent: this loop exits from inside, not from an external condition.
fn find_first_even(nums) i = 0 loop if i >= len(nums) return err("no even number found") if nums[i] % 2 == 0 return ok(nums[i]) i += 1 print(find_first_even([1, 3, 7, 4, 9])) // Ok(4) print(find_first_even([1, 3, 5])) // Err(no even number found)
Err(no even number found)
Line-by-line breakdown:
i = 0— the index starts at zero. We manually manage it because we need to track position to access elements.loop— starts an infinite loop. The loop runs until areturnorbreakexits it.if i >= len(nums)— the bounds check comes first. If we've gone past the end, no even number exists. We return an error rather than crash.if nums[i] % 2 == 0— the even check.%is the modulo operator. If the remainder when dividing by 2 is zero, the number is even.return ok(nums[i])— exits the function immediately with the found value. We don't need to set a flag or break to an outer scope.i += 1— only reached if neither condition matched. Advance to the next element.
Why not for i in 0..len(nums)-1? A for loop works too, but loop makes the mid-loop exit pattern explicit. When a reader sees loop, they know the exit is inside — the structure itself documents the intent.
Nested loops — multiplication table
Loops can be nested to any depth. Each loop has its own break and continue: break only exits the innermost loop it appears in, not all loops.
// Print a 4x4 multiplication table for row in 1..5 line = "" for col in 1..5 line += "{row * col}\t" print(line) // Output: // 1 2 3 4 // 2 4 6 8 // 3 6 9 12 // 4 8 12 16 // break exits only the INNERMOST loop // To exit the outer loop, use a flag: found_row = -1 found_col = -1 done = false for r in 1..5 if done: break for c in 1..5 if r * c == 6 found_row = r found_col = c done = true break print("First occurrence of 6: row={found_row}, col={found_col}") // First occurrence of 6: row=2, col=3
Line-by-line breakdown:
for row in 1..5— iterates 1, 2, 3, 4 (exclusive right end (half-open range)). The outer loop controls which row we are printing.line = ""— resets the line string for each row. Each row is built by appending column values.for col in 1..5— inner loop builds one row.\tin the string literal inserts a tab for alignment.if done: break— the outer-loop escape. When the flag is set, the outer loop exits on its next iteration. This is the standard pattern because NOVA has no labeled break.breakin the inner loop — exits only thefor c in 1..5loop. Control returns to the outer loop, which checksif doneand exits.
DO: Use a boolean flag to break out of an outer loop. DON'T: Expect break to exit multiple loop levels — it only exits the innermost enclosing loop.
Loop types — comparison table
NOVA has three loop forms. Choosing the right one makes your intent clear to the reader:
| Loop form | When to use | Python equivalent | Go equivalent | Rust equivalent |
|---|---|---|---|---|
for x in collection | Iterating a list, dict, range — you know what you're iterating | for x in items: | for _, x := range items | for x in items |
while condition | Exit condition is known at the top, checked before each iteration | while condition: | for condition | while condition |
loop | Exit condition is in the middle or bottom — signals "runs until I break out" | while True: + break | for { } | loop { } |
Why loop instead of while true: Both work, but loop is better because it communicates intent. A reader who sees loop immediately knows the exit is inside the body, not at the top. A reader who sees while true has to scan the body to understand the exit. NOVA, Rust, and Crystal all have loop for this reason.
Complete countdown example — putting it together
// Count down from 10 using while n = 10 while n > 0 print(n) n -= 1 print("Blast off!") // Output: 10 9 8 7 6 5 4 3 2 1 Blast off! // Skip even numbers using continue for i in 1..10 if i % 2 == 0 continue // skip even numbers print(i) // prints 1, 3, 5, 7, 9 // Stop early using break for i in 0..9 if i == 5 break // exit the loop at 5 print(i) // prints 0, 1, 2, 3, 4 // Infinite loop reading user input — exits from inside loop line = readline() // read a line from stdin if line == "quit" break print("You said: {line}")
Line-by-line breakdown:
while n > 0— checks the condition before each iteration. Whennreaches 0, the loop stops. Then -= 1inside the body decrements n each time.if i % 2 == 0: continue—continueimmediately jumps to the next iteration without running any code below it in the loop body. This skips even numbers: wheniis 2, 4, 6, 8, or 10,printis never reached.if i == 5: break—breakimmediately exits the entire loop. Execution continues with the first statement after the loop. So we print 0, 1, 2, 3, 4 — not 5.loopwithif line == "quit": break— the exit condition is checked in the middle after reading input. The loop runs until the user types "quit." This cannot be expressed as awhilecondition because we need to read the input first to know whether to exit.
DO: Use for x in collection when iterating a list, dict, or range. Use while condition when the exit condition is known upfront. Use loop when the exit is inside the body. DON'T: Use while true as an infinite loop — prefer loop. It reads more clearly and exactly matches the pattern in Rust.
Loop performance — what the compiler sees
Every loop form in NOVA compiles to the same underlying LLVM IR — a branch, a body, and a back-edge. The for, while, and loop keywords are purely a developer-facing readability choice; they have identical performance characteristics. The compiler applies the same optimizations (loop invariant code motion, strength reduction, unrolling) to all of them.
| Loop pattern | LLVM construct | Optimization opportunities |
|---|---|---|
for i in 0..n | Induction variable loop | Unrolling, vectorization (SIMD), strength reduction |
for x in list | Pointer-chase over list buffer | Prefetching, auto-vectorization if element type is numeric |
while cond | Conditional branch at top | Loop invariant hoisting |
loop | Unconditional back-edge | Same as while true after optimization |
The practical consequence: you should choose a loop form based on readability, not performance. The compiler will not penalize you for writing loop { if done: break } versus while not done. Choose the one that makes the code's intent clearest.
unless and until — negated conditionals
unless and until are sugar for the negated form of if and while. unless COND means if not COND; until COND means while not COND. Reach for them when phrasing a check in the positive would force a double negative on the reader.
done = false fn process_next() print("processing next item") unless done process_next() // done is false, so this runs count = 0 until count > 10 count += 1 print(count) // 11
11
Line-by-line:
unless done— runs the body only whendoneis false. It is exactlyif not done, desugared by the compiler before type checking.until count > 10— repeats the body until the condition becomes true. It is exactlywhile not (count > 10).- Both keywords are pure readability sugar. Anything you write with
unless/untilyou could write withif not/while not.
DO: Use unless/until when negating the condition removes an awkward double negative — unless authorized reads better than if not authorized.
DON'T: Reach for them with compound and/or conditions — unless a and b is genuinely ambiguous about what's being negated.
for...else and while...else — did the loop find something?
A loop's else clause runs when the loop finishes normally — that is, when it was not stopped early by break. This gives you a direct answer to "did the loop find what it was looking for?" without a separate boolean flag.
fn search(items, target) for item in items if item == target print("found!") break else print("not found") // only runs if no break happened search([3, 7, 2, 9, 4], 9) // found! search([3, 7, 2, 9, 4], 100) // not found
not found
Line-by-line:
elseis attached to the loop, not to theifinside it — notice its indentation matchesfor/while, one level shallower than theif.breakskips theelse— this is the entire point of the construct.- Without this feature, the same logic needs a flag:
found = falsebefore the loop,found = truenext to thebreak, thenif not found: ...after the loop — three extra lines.
DO: Use for...else to replace the "declare a found flag, set it near break, check it after the loop" pattern.
DON'T: Confuse this with try/else in other languages — NOVA's loop else has nothing to do with error handling, only with whether break fired.
while / else — a dedicated example
The same else-runs-unless-break-fired rule from for...else applies identically to while. The clause runs when the loop's condition becomes false naturally — never when break ends it early. This is the direct way to distinguish "the search space ran out" from "we found it and stopped."
fn countdown_to_target(start, target) x = start while x > 0 if x == target print("hit {target} on the way down") break x -= 1 else print("reached zero without hitting {target}") // only runs if no break happened countdown_to_target(10, 5) // hit 5 on the way down countdown_to_target(10, -1) // reached zero without hitting -1
reached zero without hitting -1
Line-by-line:
while x > 0...else— theelse's indentation matcheswhile, one level shallower than the loop body, exactly likefor...else.- First call:
breakfires whenx == 5, so theelseclause is skipped entirely. - Second call: target
-1is never equal to anyxthe loop visits —xcounts down to1and the conditionx > 0goes false beforexcould ever reach-1— so the loop ends normally andelseruns.
DO: Use while...else instead of a separate found-flag when the loop's own exit condition already tells you "search space exhausted."
DON'T: Assume else only fires when the loop body ran zero times — it fires any time the loop exits via its condition rather than break, including after many normal iterations.
Chained comparisons
NOVA lets you chain comparison operators the way you'd write them on paper: 0 <= x <= 100 instead of 0 <= x and x <= 100. The compiler desugars a chain into an and of each adjacent pair — but crucially, it evaluates each middle term only once.
x = 42 if 0 <= x <= 100 print("in range") // in range a = 1 b = 5 c = 9 d = 20 if a < b < c < d print("ascending") // ascending // Proof the middle term is evaluated ONCE, not twice: count = 0 fn next_val() count += 1 count if 1 <= next_val() <= 10 print("call count: {count}") // call count: 1
ascending
call count: 1
Line-by-line:
0 <= x <= 100— reads exactly like the math notation it mirrors. Noandrequired.1 <= next_val() <= 10— the compiler evaluates the middle term once, stores it, and reuses the stored value for both comparisons —countends at1, provingnext_val()ran exactly once.
DO: Use chained comparisons for readable range checks like 0 <= x <= 100 or min < value < max.
DON'T: Mix directions in a way that hurts readability — keep a chain consistently ascending or descending.
For-in inline filter
Adding if CONDITION right after the iterable filters elements inline, before they ever reach the loop body — no separate filter() call and no extra indentation level.
items = [-3, 5, -1, 8, 0, 2] for x in items if x > 0 print(x) // only positive items: 5, 8, 2 // Exactly equivalent to, but without the extra indentation level: for x in items if x > 0 print(x)
8
2
Line-by-line:
for x in items if x > 0— theifafter the iterable is a filter clause, not a nested statement. Only elements that pass flow into the loop body.- This is sugar — the compiler generates the same code as the nested-
ifversion. Use the inline form when the filter is one simple condition. - vs
filter(items, x => x > 0)—filter()builds a new list before the loop starts.for x in items if x > 0tests each element as it's produced and never allocates an intermediate list.
DO: Use inline filters for simple, single-condition loops. Combine multiple conditions with and: for x in items if x > 0 and x % 2 == 0.
DON'T: Reach for filter() first when all you're going to do with the result is loop over it once — the inline filter avoids the intermediate list.
range_inclusive — when you genuinely need both ends
The .. operator is exclusive of its right bound (see the note above and Chapter 36) — that's the correct, Python-style default for most loops and slices. When the problem itself has an inclusive boundary — days 1 through 31, dice faces 1 through 6, a countdown that must include zero — reach for the range_inclusive(a, b) builtin instead of writing a..b + 1 by hand. Both forms visit the same values; range_inclusive says what it means at the call site instead of relying on the reader to silently apply the + 1 translation every time.
// Both ends inclusive — visits 1, 2, 3, 4, 5 for i in range_inclusive(1, 5) print(i) // 1 2 3 4 5 // The equivalent using the exclusive `..` operator — you must remember the + 1 for i in 1..6 print(i) // 1 2 3 4 5 — same result, but the "6" only makes sense once you recall .. excludes it // range_inclusive is a real list, not just loop syntax days = range_inclusive(1, 31) print(len(days)) // 31
2
3
4
5
1
2
3
4
5
31
DO: Reach for range_inclusive(a, b) whenever the natural boundary in the PROBLEM is inclusive — it documents intent directly, with no mental + 1 translation for the next reader. DON'T: Write a..b + 1 out of habit — it works, but it silently depends on the reader independently remembering that .. excludes its right bound.
5. Functions
What is this? Functions are the primary unit of code reuse. Declared with fn. The last expression in the body is the return value — no return keyword needed. Parameters don't need type annotations. Functions are first-class values — you can store them in variables and pass them to other functions.
Basic function syntax
fn add(a, b) a + b print(add(3, 4)) // 7
Line-by-line breakdown:
fn add(a, b)— declares a function namedaddwith two parametersaandb. No types written — the compiler infers them from the call site. In Java this would beint add(int a, int b). In NOVA: justfn add(a, b).a + b— the body is indented one level (4 spaces). This is the only statement in the body. Because it is the last expression, its value is automatically the return value. Noreturnkeyword needed. This is the same as Rust, Ruby, and Kotlin.print(add(3, 4))— callsaddwith arguments 3 and 4. The function computes3 + 4 = 7and returns7.printoutputs7.
The risk of skipping types on public functions
An untyped parameter isn't checked against "any reasonable type" — it's checked against nothing until a call site fixes it. For a private helper only your own file calls, that's harmless: every call site is right there to read. For a function OTHER modules call, an untyped parameter silently accepts whatever shape a caller happens to pass, and the mismatch surfaces as a runtime failure deep inside the function body — far from the call site that actually got it wrong. The same risk applies to return types: if a function has no -> T annotation and its return paths produce genuinely different shapes, the compiler doesn't reject the conflict — it silently widens the inferred return type to any, and every caller loses type checking on the result without being told.
// UNTYPED public function — looks fine from inside this file... fn compute_discount(price, pct) price * (1.0 - pct / 100.0) // ...but nothing stops a caller from passing the wrong shape: // compute_discount("99.99", 10) // no compile error — misbehaves at runtime instead // TYPED public function — the same mistake is caught immediately, at the call site fn compute_discount_safe(price: float, pct: float) -> float price * (1.0 - pct / 100.0) print(compute_discount_safe(99.99, 10.0)) // 89.991 // compute_discount_safe("99.99", 10.0) // compile error: expected float, got string
// No -> annotation, and the two branches return DIFFERENT shapes fn parse_setting(raw) if raw == "on" true // bool else raw // string — the compiler widens the inferred return type to `any` setting = parse_setting("on") print(setting) // true
DO: Annotate parameter and return types on every function another module calls — it turns a class of runtime failure into a compile-time error exactly at the call site that got it wrong. DON'T: Treat "the compiler infers it" as equivalent to "the compiler checks it" for a public API — without an explicit annotation, inference accepts whatever shows up; it doesn't narrow or validate anything at the boundary.
Explicit return — early exit
fn divide(a, b) if b == 0 return err("division by zero") ok(a / b) print(divide(10, 3)) // Ok(3) print(divide(10, 0)) // Err(division by zero)
Err(division by zero)
Line-by-line:
if b == 0— guards against division by zero.return err("division by zero")—returnexits the function immediately with an error value. Withoutreturn, the function would fall through to the last line.ok(a / b)— the last expression is the return value whenb != 0.ok()wraps the result in a success value.
Default parameters
fn greet(name, greeting = "Hello") print("{greeting}, {name}!") greet("Alice") // Hello, Alice! greet("Bob", "Good morning") // Good morning, Bob!
Good morning, Bob!
Default parameters must come after required parameters. If you provide the second argument, it overrides the default.
Optional parameter sugar (T?)
A parameter written p: T? is shorthand for p: Option<T> — it documents, right in the signature, that the caller may legitimately have nothing meaningful to pass. This is a different tool from the default-parameter-value form above: a default value (greeting = "Hello") is a concrete fallback the compiler substitutes automatically when an argument is OMITTED entirely. An optional parameter is Option-typed — the caller passes some(x) or none() explicitly, so "nothing was provided" becomes a real, type-checked case the function body has to handle, not a value quietly standing in for it.
fn greet(name: string?) -> string "Hello, " + (name ?? "stranger") + "!" print(greet(some("Alice"))) // Hello, Alice! print(greet(none())) // Hello, stranger!
Hello, stranger!
Line-by-line:
name: string?—namehas typeOption<string>, not a barestring. Inside the body you must unwrap it before using it as a plain string — here with??to supply a fallback.name ?? "stranger"— ifnameissome(x), this yieldsx; if it'snone(), it yields"stranger". See Chapter 9 for the full??/Optionstory.
DO: Reach for T? when "nothing was provided" is meaningful business logic the caller should have to acknowledge — an optional filter, an optional override. DON'T: Use T? as a substitute for a default value when a fixed fallback constant is all you need — p = default_expr is simpler for that case and doesn't force every caller to wrap their argument in some(...).
Multiple return values via structs
NOVA has real tuples — a literal like (a, b, c), with let (a, b, c) = ... destructuring and positional patterns in match (see Chapter 8, "Tuple patterns in match"). For return values, though, prefer a struct (named fields are self-documenting) — a tuple or list works for quick, throwaway local use:
// Preferred: struct (self-documenting, fields have names) type DivResult quotient: int remainder: int fn divmod(a, b) DivResult { quotient: a / b, remainder: a % b } r = divmod(17, 5) print("17 / 5 = {r.quotient} remainder {r.remainder}") // Output: 17 / 5 = 3 remainder 2
// Alternative: list (fields unnamed — only for quick local use) fn minmax(items) lo = items[0] hi = items[0] for x in items if x < lo: lo = x if x > hi: hi = x [lo, hi] result = minmax([3, 1, 4, 1, 5, 9]) print("min={result[0]}, max={result[1]}") // min=1, max=9
DO: Use structs for return values with more than 2 fields, or when the function is part of a module API. DON'T: Return a list of 5 unnamed values — nobody will remember which index is which.
Closures (anonymous functions)
Closures are functions without a name. Useful when you need a short function to pass to map(), filter(), or any other higher-order function. NOVA has three closure forms:
// Form 1: Arrow — single parameter, single expression double = x => x * 2 print(double(5)) // 10 // Form 2: Arrow — multiple parameters (wrap in parentheses) add = (a, b) => a + b print(add(3, 4)) // 7 // Form 3: Block body — multiple statements, use fn keyword process = fn(x) y = x * 2 y + 1 // last expression is the return value print(process(5)) // 11
7
11
Line-by-line breakdown of Form 1:
double = x => x * 2— creates a closure that takes one parameterxand returnsx * 2. The=>arrow separates the parameter from the body. This is similar to JavaScript'sx => x * 2or Python'slambda x: x * 2. The closure is stored in the variabledoubleand can be called like any function.- When you call
double(5), the compiler substitutesx = 5and evaluates5 * 2 = 10.
Line-by-line breakdown of Form 3:
process = fn(x)— the block-body form starts withfn(parameters). No name — it is anonymous. The body is indented below, just like a regular function.y = x * 2— first statement: computesx * 2and stores iny.y + 1— last expression in the closure. Its value is the return value. Forprocess(5):y = 10, then returns10 + 1 = 11.
Two more closure forms: bar-lambda and multi-line block-lambda
Beyond the arrow form (x => expr) and the fn(x) ... block form shown above, NOVA accepts a fourth spelling — bar-lambda, |x| expr — familiar to anyone coming from Ruby or Rust. It compiles to the exact same closure as the arrow form; which one you reach for is purely a matter of house style or matching whatever syntax your team already knows.
// Bar-lambda: identical meaning to x => x + 1 inc = |x| x + 1 print(inc(5)) // 6 // Multiple parameters, comma-separated between the bars add = |a, b| a + b print(add(3, 4)) // 7 // Zero parameters — empty bars always_true = || true print(always_true()) // true
7
true
Separately, when a callback passed directly as a function CALL argument needs more than one statement, write the parameter(s) followed by => and an indented block on the following lines — no fn keyword needed. The compiler lifts the block into a real nested function that still captures the enclosing scope, so it behaves exactly like a named closure, just spelled inline:
fn run_with_logging(label, action) print("start: {label}") result = action() print("done: {label}") result // Multi-line body — only recognized directly inside a call's argument list total = run_with_logging("batch", () => subtotal = 10 + 20 subtotal + 5 ) print(total) // 35
done: batch
35
DO: Reach for param => expr or |param| expr when the body fits on one line, and the multi-line () => block form directly inside a call's parentheses when a callback needs several statements — no separate named function required. DON'T: Expect the multi-line => block form to work anywhere except directly inside a call's argument list — assigning it to a variable first (f = x => followed by an indented block) is not supported; use the fn(x) ... body ... form from earlier in this chapter when you want to store a multi-statement closure in a variable.
Closures capture by value — not by reference
x = 10 f = fn() print(x) x = 20 // change x AFTER creating the closure f() // prints 10, NOT 20 — closure captured x's value at creation
Line-by-line breakdown:
x = 10— sets x to 10.f = fn() print(x)— creates a closure that printsx. At this moment,xis 10. The closure captures a copy of 10. It now permanently remembersx = 10regardless of what happens to the variablexlater.x = 20— changes the variablexto 20. The closurefalready captured the old value and does not see this update.f()— calls the closure. Prints10, not20.
Why this matters — the Python closure bug:
# Python — this is a classic, real bug: funcs = [] for i in range(5): funcs.append(lambda: print(i)) for f in funcs: f() # prints 4, 4, 4, 4, 4 — NOT 0, 1, 2, 3, 4! # All closures share the SAME `i` variable, which ends at 4
// NOVA — capture by value makes this bug impossible: funcs = [] for i in 0..4 push(funcs, fn() print(i)) // each closure captures its OWN copy of i for f in funcs f() // prints 0, 1, 2, 3, 4 — correct!
1
2
3
DO: Use x => expr for short one-liner closures. Use fn(x) ... body ... for multi-statement closures. DON'T: Expect a closure to see changes made to captured variables after creation — it captured a copy, not a reference to the variable.
A fourth form: multi-line lambda in argument position
Form 3 above (fn(x) ... body ...) is single-expression-bodied even when it's the whole point of the closure — written as a bare fn(...) value, it takes exactly one expression, full stop, in every position. But when a closure is written directly as a call argument using the => arrow, NOVA accepts a genuine multi-line, multi-statement body — not just the one-line x => expr from Form 1. Write the arrow, then a newline, then an indented block exactly like a function body; the compiler lifts it into a real nested named function behind the scenes, so it still captures the enclosing scope like every other closure form, and it nests: a block lambda can contain another block lambda.
fn apply(n, f) f(n) fn main() base = 100 // arrow + newline + indented block — multiple statements, still captures `base` result = apply(5, x => scaled = x * 2 boosted = apply(scaled, y => bonus = y + base // nested block lambda — also captures `base` bonus ) boosted ) print(result)
Line-by-line breakdown:
apply(5, x => ...)— the outer closure receivesx = 5, computesscaled = 10, then callsapplyagain with a second, nested block lambda.- The inner closure receives
y = 10and computesbonus = y + base = 110— it readsbasefrom the outermost scope even though it's nested two closures deep, exactly as any closure would. boostedends up110; the outer closure's last line,boosted, is its return value, soresultis110.
This exact shape is what lets NOVA route tables read naturally in a single call: a router-building function whose closure argument spans many lines, one per route registration, instead of forcing every route handler into its own separate named function just to satisfy the parser.
DO: Reach for param => (or () => / (a, b) =>) followed by an indented block when a call argument needs more than one statement — it's lifted into a real function, so early-exit code like data = fetch(req) else return not_found() works inside it exactly as it would in a named function. DON'T: Expect a multi-statement body from the bare fn(x) ... keyword form, or from an arrow lambda that ISN'T sitting directly in call-argument position (e.g. one assigned straight into a variable) — fn() is always exactly one expression, and the multi-line block body is specifically an argument-position feature of =>.
Higher-order functions: map, filter, reduce
A higher-order function takes another function as a parameter (or returns one). This lets you write general-purpose code customizable by the caller. The three built-in higher-order functions you'll use constantly:
map — transform every element:
nums = [1, 2, 3, 4, 5] squares = map(nums, x => x * x) print(squares) // [1, 4, 9, 16, 25]
map(nums, x => x * x)— takes a list and a function, applies the function to every element, returns a new list. Think of it as a conveyor belt: each item goes in, gets transformed, comes out. The originalnumslist is not modified.- Step by step:
1*1=1,2*2=4,3*3=9,4*4=16,5*5=25→[1, 4, 9, 16, 25]
filter — keep only elements that pass a test:
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] evens = filter(nums, x => x % 2 == 0) print(evens) // [2, 4, 6, 8, 10]
filter(nums, x => x % 2 == 0)— takes a list and a predicate (a function returning true/false). Returns a new list with only elements where the predicate returned true. The closurex % 2 == 0tests if x is even.
reduce — combine all elements into a single value:
nums = [1, 2, 3, 4, 5] total = reduce(nums, 0, (acc, x) => acc + x) print(total) // 15
reduce(nums, 0, (acc, x) => acc + x)— takes a list, an initial value (the "accumulator"), and a function. Applies the function step by step: start acc=0, then acc=0+1=1, then acc=1+2=3, then acc=3+3=6, then acc=6+4=10, then acc=10+5=15. Returns the final accumulator.
Chaining map → filter → reduce:
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] // Square all numbers, keep only squares > 20, sum them // Step 1: map squares → [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] // Step 2: filter > 20 → [25, 36, 49, 64, 81, 100] // Step 3: sum → 355 result = reduce( filter(map(nums, x => x * x), x => x > 20), 0, (acc, x) => acc + x ) print(result) // 355
DO: Use map to transform, filter to select, reduce to combine. DON'T: Write a for loop to do what map or filter already does — the built-in functions are cleaner and the compiler can optimize them better.
Writing your own higher-order function
fn apply_twice(f, x) f(f(x)) print(apply_twice(x => x + 1, 5)) // 7 — f(f(5)) = f(6) = 7 print(apply_twice(x => x * 2, 3)) // 12 — f(f(3)) = f(6) = 12
12
fn apply_twice(f, x)—fis a parameter that holds a function. The compiler infers it is a function because you call it withf(...)inside the body.f(f(x))— callsfonx, then callsfagain on the result. Forapply_twice(x => x + 1, 5): first callf(5) = 6, second callf(6) = 7.
Sorting with custom comparators
The built-in sort_by function accepts a function that extracts the sort key from each element. This lets you sort any list by any field:
type Student name: string grade: int gpa: float students = [ Student { name: "Carol", grade: 11, gpa: 3.7 }, Student { name: "Alice", grade: 10, gpa: 3.9 }, Student { name: "Bob", grade: 12, gpa: 3.5 } ] // Sort by name (alphabetical) by_name = sort_by(students, s => s.name) for s in by_name: print("{s.name}") // Alice, Bob, Carol // Sort by GPA (highest first) — negate to reverse by_gpa_desc = sort_by(students, s => -s.gpa) for s in by_gpa_desc: print("{s.name}: {s.gpa}") // Alice: 3.9, Carol: 3.7, Bob: 3.5 // Sort by grade, then by name within same grade by_grade = sort_by(students, s => s.grade) for s in by_grade: print("Grade {s.grade}: {s.name}") // Grade 10: Alice, Grade 11: Carol, Grade 12: Bob
Line-by-line breakdown:
sort_by(students, s => s.name)— sorts by whatever the key function returns. Here the key is a string, so sort is alphabetical. The key function receives each element and returns the comparison key.sort_by(students, s => -s.gpa)— negating a float reverses the sort order. Highest GPA becomes the smallest negative number, so it appears first after sorting ascending. This is simpler than a separatesort_descfunction.- Sort is stable — when two elements have equal keys, they remain in their original relative order. This is why sorting by grade twice (first by name, then by grade) gives you a secondary sort by name within each grade.
DO: Use sort_by(xs, key_fn) instead of writing a comparison loop. DON'T: Sort in place (NOVA's sort_by returns a new list). DO: Chain sorts: first sort by secondary key, then sort by primary key — the stable sort preserves secondary-key order within equal primary-key groups.
Function composition — building pipelines
Function composition is the practice of combining two functions into one: the output of f becomes the input to g. In NOVA you can express this directly or build a compose utility:
// Manual composition: apply steps one at a time data = [" Alice ", "BOB", " carol", "DAVE "] normalized = map( filter( map(data, s => lower(trim(s))), // step 1: trim whitespace, lowercase s => len(s) > 0 // step 2: remove empty strings ), s => s[0] + slice(s, 1, len(s)) // step 3: capitalize first letter ) print(normalized) // ["Alice", "Bob", "Carol", "Dave"] // Named step functions — more readable for complex pipelines fn normalize(s) lower(trim(s)) fn not_empty(s) len(s) > 0 fn capitalize(s) s[0] + slice(s, 1, len(s)) result = map(filter(map(data, normalize), not_empty), capitalize) print(result) // ["Alice", "Bob", "Carol", "Dave"]
The named-step version is more readable: each step's name tells you exactly what it does. The compiler inlines small functions, so there's no performance penalty for breaking the pipeline into named pieces.
Recursion
fn factorial(n) if n <= 1 return 1 n * factorial(n - 1) print(factorial(10)) // 3628800 fn fibonacci(n) if n <= 1: return n fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(10)) // 55
55
DON'T use deep recursion (depth >3000) in NOVA — there is no tail-call optimization, so deep recursion overflows the 32KB stack. Use an explicit loop for iterative algorithms. factorial(20) is fine. fibonacci(40) is both fine (depth-wise) and slow (exponential time) — use dynamic programming instead.
Named functions vs closures — when to use which
Use a named function (fn name(...)) | Use a closure (x => expr or fn(x) ...) |
|---|---|
| Reusable logic called from multiple places | Short callbacks for map/filter/reduce |
| Part of your module's public API | One-time-use functions in local scope |
| Methods on structs (impl blocks) | Functions stored in data structures or lists |
| Functions with complex multi-line bodies | Adapters and wrappers (e.g. x => process(x, config)) |
Returning functions — function factories
Functions are first-class values in NOVA. A function can return another function. This pattern is called a "function factory" or "closure factory." It is a clean alternative to configuration objects or classes when you need to create customized behavior:
fn make_adder(n) fn(x) x + n // captures n from the outer scope add5 = make_adder(5) add10 = make_adder(10) print(add5(3)) // 8 (3 + 5) print(add10(3)) // 13 (3 + 10) print(add5(add10(0))) // 15 (0 + 10 = 10, then 10 + 5 = 15)
Line-by-line breakdown:
fn make_adder(n)— outer function takesn, the "base" value. This is the factory — calling it produces a customized adder function.fn(x) x + n— the inner function is the return value ofmake_adder. It closes overnfrom the outer scope. Each call tomake_addercreates a new closure with its own captured copy ofn.add5 = make_adder(5)— calls the factory withn = 5. The result is a function that adds 5 to its argument. The closure permanently remembersn = 5.add10 = make_adder(10)— a separate call creates a separate closure withn = 10.add5andadd10are independent — changing one does not affect the other.add5(3)— calls the closure withx = 3. Evaluates3 + 5 = 8.
Function factories for validation:
// Factory: creates a function that tests if a number is in a range fn in_range(lo, hi) fn(x) x >= lo and x <= hi is_percentage = in_range(0, 100) is_single_byte = in_range(0, 255) is_temp_celsius = in_range(-273, 1000) print(is_percentage(75)) // true print(is_percentage(150)) // false print(is_single_byte(200)) // true // Use them with filter — the predicate is already built, no lambda needed scores = [45, 72, 110, 88, -5, 95] valid = filter(scores, is_percentage) print(valid) // [45, 72, 88, 95]
Why this pattern matters: In Python you'd write lambda x: 0 <= x <= 100 inline at each use site — readable for one use, repetitive for many. In Java you'd write a full class or interface. In NOVA, in_range(0, 100) is a reusable, named, testable function. Pass it to filter, map, or any higher-order function that expects a predicate.
How NOVA compares to other languages for higher-order functions:
| Pattern | NOVA | Python | JavaScript | Go |
|---|---|---|---|---|
| Single-param closure | x => x * 2 | lambda x: x * 2 | x => x * 2 | func(x int) int { return x * 2 } |
| Multi-param closure | (a, b) => a + b | lambda a, b: a + b | (a, b) => a + b | func(a, b int) int { return a + b } |
| map | map(xs, x => x*2) | [x*2 for x in xs] | xs.map(x => x*2) | manual loop |
| filter | filter(xs, x => x>0) | [x for x in xs if x>0] | xs.filter(x => x>0) | manual loop |
| Function factory | fn make_adder(n) fn(x) x+n | def make_adder(n): return lambda x: x+n | n => x => x+n | func(n int) func(int) int |
DO: Use function factories when you need to create multiple similar functions with different configurations. make_adder(5) is more readable than x => x + 5 at every use site. DON'T: Over-use closures when a simple named function would be clearer — closures shine for short, single-use logic.
Real-world functions example — a text processing library
Here is a complete example of functions working together to build a small, reusable text processing library. Notice how each function does exactly one thing, and larger operations are composed from smaller ones:
// text_utils.nova — a small text processing library // Primitives — each does exactly one thing fn normalize(s) lower(trim(s)) fn not_empty(s) len(s) > 0 fn word_count(s) len(split(trim(s), " ")) // Composed from primitives — does more complex work fn clean_lines(lines) filter(map(lines, normalize), not_empty) fn word_frequency(text) words = split(lower(text), " ") freq = {} for w in words w = trim(w) if len(w) > 0 if contains(freq, w) freq[w] += 1 else freq[w] = 1 freq fn top_words(freq, n) pairs = [] for word, count in freq push(pairs, [count, word]) sorted = sort_by(pairs, p => -p[0]) // sort by count descending slice(sorted, 0, min(n, len(sorted))) // take top N // Using the library text = "to be or not to be that is the question to be is the answer" freq = word_frequency(text) top = top_words(freq, 3) for pair in top print("{pair[1]}: {pair[0]}") // to: 3 // be: 3 // is: 2 lines = [" Hello World ", "", " NOVA is great", " "] cleaned = clean_lines(lines) print(cleaned) // ["hello world", "nova is great"]
Design principles illustrated here:
- Single responsibility: Each function does exactly one thing.
normalizeonly trims and lowercases.not_emptyonly tests length. This makes each function easy to test and reuse. - Composition over complexity:
clean_linesis justfilter(map(...))— no new logic, just combining existing functions. Reading it tells you exactly what it does. - No mutation: Every function returns a new value.
word_frequencybuilds a new dict rather than modifying one passed in. This makes functions safe to use from multiple places. - First-class functions as parameters:
sort_by(pairs, p => -p[0])— the sort key is a closure that negates the count to get descending order.sort_bydoesn't know or care what the key function does — it just calls it.
Pipe operator |>
The pipe operator takes the value on its left and inserts it as the first argument of the function call on its right. It turns deeply nested calls — which read inside-out — into a left-to-right sequence that reads in the same order the computation actually happens.
fn double(x) x * 2 fn square(x) x * x result = 5 |> double |> square print(result) // 100 // Same computation, nested instead of piped — reads right-to-left: result2 = square(double(5)) print(result2) // 100 — identical result // Pipes shine with a multi-step HOF pipeline: v = [1, 2, 3, 4, 5] |> filter(fn(x) x > 2) |> map(fn(x) x * 10) print(v) // [30, 40, 50] // The piped value is inserted as the FIRST argument; // any arguments already in the parens shift right: fn add(a, b) a + b r = 3 |> add(4) |> double |> add(1) print(r) // 15
100
[30, 40, 50]
15
Line-by-line:
5 |> double |> square—5flows intodoublefirst (double(5) = 10), then10flows intosquare(square(10) = 100). Read top-to-bottom or left-to-right — never inside-out.|> filter(fn(x) x > 2)— when the right-hand side already has arguments in parens, the piped value slots in before them:v |> filter(pred)becomesfilter(v, pred), matching every existing NOVA HOF signature (collection first, then the closure).3 |> add(4)— becomesadd(3, 4), notadd(4, 3). The piped value is always argument zero; whatever you wrote inside the parens shifts one position to the right.
DO: Reach for |> once you have 3 or more chained transforms — that's where nested calls start actively fighting readability.
DON'T: Pipe a single call — x |> f is no clearer than f(x), and the plain call form is more familiar to readers coming from other languages.
UFCS — Uniform Function Call Syntax
Any function whose first parameter matches the type of x can be called as a method on x: x.f(rest...) is rewritten to f(x, rest...) at compile time. This is purely a call-syntax rewrite — there's no new dispatch mechanism, no vtable, nothing at runtime.
// Any function f(x, ...) can be called as x.f(...) data = [-2, 5, -8, 3, 9, -1] result = data.filter(fn(x) x > 0).map(fn(x) x * 2).sort() print(result) // [6, 10, 18] // Extension methods on any type: Type__method, called as x.method() fn int__double(x: int) -> int x * 2 r = 3.double() print(r) // 6 fn string__shout(s: string) -> string upper(s) + "!!!" print("hello".shout()) // HELLO!!!
6
HELLO!!!
Line-by-line:
data.filter(...).map(...).sort()—filter,map, andsortall already take the collection as their first parameter, so UFCS turns the nested formsort(map(filter(data, ...), ...))into a left-to-right chain that reads in execution order.fn int__double(x: int) -> int— theType__methodnaming convention marks this as an extension method onint. It's an ordinary top-level function; the compiler recognizes the naming pattern and makes it callable as3.double().- Resolution order: struct method → module function → builtin. This lets you add methods to any type — including builtins like
intandstring— without ever touching their source.
DO: Chain UFCS calls to avoid nested-call soup, especially with HOFs (data.filter(...).map(...).sort()).
DON'T: Define extension methods just to shave a few characters off a call — UFCS earns its keep in chains of three or more calls, where the alternative forces you to read inside-out.
Named arguments
Every parameter's name can be used as a keyword at the call site — name: value or name = value (both spellings work identically). This lets a call be self-documenting and lets arguments be given in any order.
fn greet(name, greeting = "Hello") greeting + ", " + name + "!" print(greet("Alice")) // Hello, Alice! print(greet(greeting: "Yo", name: "Eve")) // Yo, Eve! print(greet(name = "Bob", greeting = "Hey")) // Hey, Bob!
Yo, Eve!
Hey, Bob!
Line-by-line:
greet("Alice")— positional call.greetingis omitted, so it falls back to its default,"Hello".greet(greeting: "Yo", name: "Eve")— named arguments. Order at the call site no longer has to match declaration order.greet(name = "Bob", greeting = "Hey")— NOVA accepts=as an alternative to:for naming an argument. They mean exactly the same thing.
DO: Use named arguments when a call has two or more parameters of the same type — connect(host: "db1", timeout: 30) is unambiguous where connect("db1", 30) forces the reader to remember argument order.
Variadic parameters (T...)
A trailing parameter typed T... collects any number of extra arguments into an ordinary list[T]. It must be the last parameter.
fn sum_all(xs: int...) -> int total = 0 for x in xs total += x total print(sum_all(1, 2, 3, 4)) // 10 print(sum_all(7)) // 7 print(sum_all()) // 0 — zero arguments is valid
7
0
Line-by-line:
xs: int...— the...after the type marksxsas variadic. Inside the function body,xsis a plainlist[int].- Only the last parameter may be variadic —
fn f(a, b: int...)is valid;fn f(a: int..., b)is not.
DO: Use variadic parameters for genuinely open-ended argument counts — formatting helpers, sum_all, max_of(1, 2, 3, 4, 5).
DON'T: Use them as a substitute for accepting a list — if callers already have a list[int] in hand, a plain fn sum_all(xs: list[int]) is simpler.
Where clause
A where clause attaches local bindings to the single statement right before it. The bindings are computed first, then the expression uses them — read a where clause right-to-left: "compute these, then evaluate that."
result = x * y where x = 6, y = 7 print(result) // 42 area = width * height where width = 10 height = 20 print(area) // 200
200
Line-by-line:
x * y where x = 6, y = 7— single-line form: comma-separated bindings.width * height wherefollowed by indented bindings — multi-line form.- Scope:
widthandheightexist only for the statement they're attached to — they are not visible on the next line. This is Haskell'swhere, not a regular assignment.
DO: Use where to name intermediate values right next to the one expression that uses them, keeping the surrounding scope free of one-off variables.
DON'T: Use where for a value you need in more than one statement — once a value outlives a single expression, a normal let/assignment is the right tool.
Multi-clause functions with when guards
You can declare the same function name multiple times, each with a different when guard. NOVA tries each clause top-to-bottom and runs the body of the first one whose guard passes — a final clause with no when acts as the catch-all.
fn classify(n) when n < 0 "negative" fn classify(n) when n == 0 "zero" fn classify(n) "positive" print(classify(-5)) // negative print(classify(0)) // zero print(classify(7)) // positive
zero
positive
Line-by-line:
- Three
fn classify(n)heads, same name and arity — since each carries (or omits) awhenguard, NOVA registers them as clauses of one function. when n < 0— a guard. At the call site, NOVA evaluates each clause's guard in declaration order and dispatches to the first one that passes.- The final
fn classify(n)with nowhenis the catch-all. Order matters: clauses are tried top-to-bottom.
DO: Order guarded clauses from most specific to most general, ending with an unguarded catch-all.
DON'T: Expect a performance difference from match — multi-clause dispatch is sugar for the same top-to-bottom guard chain you'd write by hand.
6. Structs
What is this? Structs are how you create custom data types with named fields. They are NOVA's equivalent of Java classes (but without inheritance), Go structs, Rust structs, or Python dataclasses. Declare with type. Access fields with dot notation. Methods are functions defined with the Type.method prefix.
Declaring a struct
type Point x: float y: float type User name: string age: int email: string
Line-by-line:
type Point— declares a new struct type namedPoint. Type names must start with an uppercase letter.x: float— declares a field namedxof typefloat. Type annotations are required on struct fields — this is one of the few places NOVA asks you to write types explicitly.- Each field goes on its own indented line under the
typedeclaration.
THE most important rule in NOVA: lowercase field types
This single rule has the largest impact on your program's performance:
// FAST: lowercase type names → native CPU math → C-level performance type FastPoint x: float // lowercase float → stored as 64-bit IEEE 754 number y: float // SLOW: capital type names → dynamic dispatch → ~150× slower type SlowPoint x: Float // capital Float → boxed value with runtime type checks y: Float
Why this matters — the numbers:
x: float→ compiler generates native CPU instructions:fmul,fadd. Time for 1M dot products: ~2ms.x: Float→ compiler generates calls tonova_rt_mul()which checks the type at runtime before doing the operation. Time for 1M dot products: ~300ms.- That is a 150× difference for the exact same algorithm.
Real consequences: A physics simulation at 60fps with 10,000 particles requires ~16ms per frame. FastPoint achieves this. SlowPoint would take ~2,400ms per frame — a slideshow.
Complete list of lowercase type names for struct fields: int, float, string, bool, list. Always use these, never their capitalized variants.
DO: Use lowercase type names in struct fields: x: float, name: string, count: int. DON'T: Ever write x: Float, name: String, count: Int in a struct field. This is the #1 performance mistake in NOVA — 150× slower with no visible change to your code.
Constructing a struct
p = Point { x: 3.0, y: 4.0 } u = User { name: "Alice", age: 30, email: "alice@example.com" } // Access fields with dot notation print(p.x) // 3.0 print(u.name) // Alice // Mutate fields p.x = 5.0 print(p.x) // 5.0 // NOVA auto-renders structs for print — no toString() needed print(p) // Point{x: 5.0, y: 4.0}
Construction rules:
- Every field must be provided — you cannot omit any. This prevents the bug of creating a
Userwith no email and crashing later. - Field names in
{ }must match the declared names exactly. - Giving an unknown field name or wrong type is a compile error, not a runtime crash.
// These are ALL compile errors — caught before your program runs: p = Point { x: 3.0 } // missing field 'y' p = Point { x: 3.0, y: 4.0, z: 5.0 } // unknown field 'z' p = Point { x: "hello", y: 4.0 } // wrong type for 'x'
Methods
Methods are functions that belong to a struct type. Define them outside the type block using the TypeName.method_name prefix. Inside a method, self automatically refers to the instance:
type Circle radius: float fn Circle.area() -> float 3.14159 * self.radius * self.radius fn Circle.circumference() -> float 2.0 * 3.14159 * self.radius fn Circle.scale(factor: float) -> Circle Circle { radius: self.radius * factor } // returns NEW circle, does not modify self c = Circle { radius: 5.0 } print(c.area()) // 78.53975 print(c.circumference()) // 31.4159 big = c.scale(2.0) print(big.area()) // 314.159
31.4159
314.159
Line-by-line:
fn Circle.area() -> float— theCircle.prefix says this method belongs toCircle. The-> floatdeclares the return type. Inside this method,selfautomatically refers to theCircleinstance the method was called on. You do NOT declareselfas a parameter — NOVA adds it automatically.3.14159 * self.radius * self.radius—self.radiusreads theradiusfield of whichever instance this method was called on. Last expression = return value.fn Circle.scale(factor: float) -> Circle— methods can take extra parameters. This returns a NEW Circle with the scaled radius. It does NOT modify the original —c.radiusstays 5.0 after callingc.scale(2.0).
How methods compare to other languages:
| Language | Method definition | Self reference |
|---|---|---|
| NOVA | fn Circle.area() -> float — outside the type block | self is implicit (not a parameter) |
| Python | def area(self): — inside class Circle: | self is an explicit parameter |
| Rust | fn area(&self) -> f64 — inside impl Circle { } | self is explicit with borrow annotation |
| Go | func (c Circle) area() float64 — receiver before name | c (the receiver name) |
| Java | double area() { ... } — inside class Circle { } | this |
DO: Define methods as fn TypeName.method_name() outside the type block. DON'T: Try to put methods inside the type block — that is only for field declarations.
Operator dispatch — giving your types [], for..in, (), and +
NOVA recognizes a small set of method and function names by convention and wires them straight into operator syntax — no separate operator keyword, no interface to implement. Define index(self, i) on a type and x[i] calls it. Define iter(self) and for item in x calls it to get the thing that actually gets iterated. Define call(self, args...) and x(args) calls your type as if it were a function. And define a free function named Type__add, Type__sub, Type__mul, Type__eq, or Type__lt and the matching operator (+, -, *, ==, <) dispatches to it — the same Type__name naming convention UFCS already uses for extension methods (Chapter 5).
type Grid cells: list fn Grid.index(i) -> int self.cells[i] fn Grid.iter() self.cells g = Grid { cells: [10, 20, 30, 40] } print(g[2]) // 30 — calls Grid.index(g, 2) for cell in g // calls Grid.iter(g), then iterates the list it returns print(cell) // 10 20 30 40
10
20
30
40
type Multiplier factor: int fn Multiplier.call(x: int) -> int x * self.factor double = Multiplier { factor: 2 } print(double(21)) // 42 — calls Multiplier.call(double, 21)
type Vec2 x: float y: float fn Vec2__add(a: Vec2, b: Vec2) -> Vec2 Vec2 { x: a.x + b.x, y: a.y + b.y } v = Vec2 { x: 1.0, y: 2.0 } + Vec2 { x: 3.0, y: 4.0 } // calls Vec2__add print(v) // Vec2{x: 4.0, y: 6.0}
Compare: this is the same idea as Python's __getitem__/__iter__/__call__/__add__ dunder protocol, Rust's Index/Iterator/Fn/Add traits, and C++'s operator[]/operator+ overloads — but resolved purely by name, with no trait to declare and no class to inherit from.
DO: Use this to make a domain type feel native — a Matrix you can index with m[i], a Vec2 you can add with +. DON'T: Overload an operator to mean something a reader wouldn't expect — + joining two file paths is a stretch that's still readable; + secretly meaning "compare" is the kind of surprise that makes code impossible to trust at a glance.
Structs as values — copy semantics
a = Point { x: 1.0, y: 2.0 } b = a // b is a COPY of a — separate data b.x = 99.0 print(a.x) // 1.0 — a is unchanged print(b.x) // 99.0
Structs are value types — when you assign or pass a struct, the data is copied. Lists and dicts are reference types — when you assign them, both variables point to the same data.
Nested structs
type Line start: Point end_pt: Point fn Line.length() -> float dx = self.end_pt.x - self.start.x dy = self.end_pt.y - self.start.y sqrt(dx * dx + dy * dy) line = Line { start: Point { x: 0.0, y: 0.0 }, end_pt: Point { x: 3.0, y: 4.0 } } print(line.length()) // 5.0 (Pythagorean theorem: sqrt(9 + 16))
Traits — shared behavior across types
A trait declares a set of methods that a type must provide — a method can be left bodiless (a required signature) or given a default body that implementing types inherit unless they override it. A type opts in explicitly, on its own declaration line — type TypeName : TraitName — there is no separate impl block, and NOVA does not infer conformance just because a type happens to have matching methods:
trait Shape fn area(self) -> float fn name(self) -> string type Circle : Shape radius: float type Rectangle : Shape width: float height: float fn Circle.area() -> float = 3.14159 * self.radius * self.radius fn Circle.name() -> string = "circle" fn Rectangle.area() -> float = self.width * self.height fn Rectangle.name() -> string = "rectangle" // A function accepting ANY type that satisfies the Shape trait fn describe(s: Shape) -> string "{s.name()} with area {s.area()}" c = Circle { radius: 5.0 } r = Rectangle { width: 4.0, height: 6.0 } print(describe(c)) // circle with area 78.53975 print(describe(r)) // rectangle with area 24.0
How NOVA traits differ from other languages:
- vs Java interfaces: Java requires
class Circle implements Shape— a separate keyword on the class. NOVA folds the same idea into the type's own declaration line,type Circle : Shape— one declaration, not two — but it is still explicit: the compiler does not treatCircleas aShapejust because it happens to have matching methods. - vs Rust traits: Rust requires a separate
impl Shape for Circle { ... }block. NOVA has noimplkeyword at all — conformance is declared once, inline, on the type itself:type Circle : Shape. Same nominal requirement as Rust, less ceremony — and because there is no free-standing impl block, you cannot attach a trait to a type you don't own the declaration of (a builtin likeint, or a struct from another module). - vs Go interfaces: Go is structural — any type with matching methods satisfies an interface automatically, no declaration anywhere. NOVA is not structural here: a generic
<T: Shape>bound or aShape-typed parameter only accepts types that wrote: Shapeon their own declaration line.
DO: Use traits to define shared behavior across multiple types. A function that accepts a trait type works with ANY type that has the required methods. DON'T: Try to create a "base struct" to inherit from — NOVA has no inheritance. Use traits for polymorphism.
Default method bodies in traits
A trait method can be written two ways. A bare signature — no indented body — means every conforming type MUST supply its own implementation. A signature WITH an indented body becomes a default: any conforming type that doesn't define that method inherits the trait's version unchanged. A type only writes its own version when its behavior genuinely differs from the shared default.
trait Describable fn name() -> string fn describe() -> string "I am " + self.name() type Dog : Describable breed: string fn Dog.name() -> string "Dog(" + self.breed + ")" type Cat : Describable breed: string fn Cat.name() -> string "Cat(" + self.breed + ")" fn Cat.describe() -> string "A very independent " + self.name() let d = Dog { breed: "Labrador" } let c = Cat { breed: "Siamese" } print(d.describe()) // I am Dog(Labrador) -- uses the trait's default, Dog never wrote its own print(c.describe()) // A very independent Cat(Siamese) -- Cat overrides it
A very independent Cat(Siamese)
Line-by-line:
fn name() -> stringinside the trait has no body — it's abstract. BothDogandCatmust (and do) define their ownType.name(), or the compiler rejects the: Describableconformance as incomplete.fn describe() -> stringinside the trait DOES have a body — it's a default.Dognever definesDog.describe(), so callingd.describe()runs the trait's own version, which in turn callsself.name()— and becauseselfis aDogat that call site, it resolves toDog.name(), not some generic placeholder.Catdefines its ownCat.describe(), so that overrides the default entirely — the trait's version is never reached forCat.
Compare: this is the same idea as Java 8+'s default interface methods (default String describe() { ... }) and Rust's trait default methods (a body written directly inside the trait block). NOVA needs no extra keyword like default — whether the method has an indented body IS the signal, read directly off the trait declaration.
DO: Give a trait method a body when there's a sensible shared behavior every conforming type can start from — implementers then only override it where they actually differ. DON'T: Give every trait method a body "just in case" — a signature-only method is how you tell the compiler (and the next reader) "every conforming type MUST supply this one," which is itself useful documentation.
Putting it all together — a geometry library
Here is a complete example that shows how structs, methods, and traits work together to build a real, usable module:
// geometry.nova — a small 2D geometry library type Vec2 x: float y: float type Circle center: Vec2 radius: float type Rect min_pt: Vec2 max_pt: Vec2 // Vec2 methods fn Vec2.length() -> float sqrt(self.x * self.x + self.y * self.y) fn Vec2.add(other: Vec2) -> Vec2 Vec2 { x: self.x + other.x, y: self.y + other.y } fn Vec2.scale(s: float) -> Vec2 Vec2 { x: self.x * s, y: self.y * s } fn Vec2.distance_to(other: Vec2) -> float dx = self.x - other.x dy = self.y - other.y sqrt(dx * dx + dy * dy) // Circle methods fn Circle.area() -> float 3.14159 * self.radius * self.radius fn Circle.contains(pt: Vec2) -> bool self.center.distance_to(pt) <= self.radius // Rect methods fn Rect.width() -> float self.max_pt.x - self.min_pt.x fn Rect.height() -> float self.max_pt.y - self.min_pt.y fn Rect.area() -> float self.width() * self.height() fn Rect.contains(pt: Vec2) -> bool pt.x >= self.min_pt.x and pt.x <= self.max_pt.x and pt.y >= self.min_pt.y and pt.y <= self.max_pt.y // Using the library origin = Vec2 { x: 0.0, y: 0.0 } center = Vec2 { x: 3.0, y: 4.0 } circle = Circle { center: center, radius: 5.0 } print(center.length()) // 5.0 (distance from origin) print(origin.distance_to(center)) // 5.0 print(circle.area()) // 78.53975 print(circle.contains(Vec2 { x: 1.0, y: 1.0 })) // true print(circle.contains(Vec2 { x: 9.0, y: 9.0 })) // false box = Rect { min_pt: Vec2 { x: 0.0, y: 0.0 }, max_pt: Vec2 { x: 10.0, y: 5.0 } } print(box.area()) // 50.0 print(box.contains(Vec2 { x: 5.0, y: 3.0 })) // true print(box.contains(Vec2 { x: 11.0, y: 3.0 })) // false
Key patterns to notice:
- Methods call other methods:
Rect.area()callsself.width()andself.height()— methods are composable. - Methods accept struct parameters:
Vec2.add(other: Vec2)andCircle.contains(pt: Vec2)take struct arguments. The type annotation on parameters in method signatures is required. - Chained method calls:
self.center.distance_to(pt)—self.centeris aVec2, so you can callVec2.distance_to()on it. Chains read left to right naturally. - All fields lowercase:
x: float,y: float,radius: float. This is why the code runs at C-level speed — native float arithmetic throughout, no boxing, no type checks.
Structs as data containers — a complete product catalog example
Structs shine as data containers. Here is a realistic example building a small product catalog — using structs to model domain data, methods to add behavior, and functions to query and transform the collection:
type Product name: string price: float quantity: int category: string fn Product.total_value() -> float self.price * self.quantity fn Product.is_in_stock() -> bool self.quantity > 0 fn Product.discounted(pct: float) -> Product Product { name: self.name, price: self.price * (1.0 - pct / 100.0), quantity: self.quantity, category: self.category } fn catalog_value(products) let total = 0.0 for p in products total += p.total_value() total fn main() let catalog = [ Product { name: "Laptop", price: 999.99, quantity: 5, category: "electronics" }, Product { name: "Headphones", price: 149.99, quantity: 0, category: "electronics" }, Product { name: "Desk", price: 450.00, quantity: 3, category: "furniture" }, Product { name: "Chair", price: 299.00, quantity: 10, category: "furniture" } ] print("Total value: {catalog_value(catalog)}") // 9339.95 // Filtering and transforming are written inline here rather than through // filter()/map() or a helper that returns a list — see the callout below // for exactly why, and what's actually safe today vs. what to avoid. for p in catalog if p.is_in_stock() print("{p.name}: {p.quantity} in stock") let sale = [] for p in catalog if p.category == "electronics" push(sale, Product { name: p.name, price: p.price * 0.8, quantity: p.quantity, category: p.category }) // 20% off for p in sale print("{p.name} on sale: \${p.price}")
Line-by-line breakdown of the key patterns:
fn Product.total_value() -> float— a method that computes a derived value from the struct's own fields. No arguments needed because the data is already inself. Called per-item insidecatalog_value's own loop, this is reliable.fn Product.discounted(pct: float) -> Product— returns a NEW Product with a reduced price, functional-style: transform and return, don't mutateself. Reliable when called directly on a single value. The sale loop above builds the discountedProductinline instead of calling it — see the callout just below for why.- Filtering is a plain
ifinside aforloop here, notfilter(products, p => p.is_in_stock())— again, see the callout.
A real, currently-open compiler bug, characterized precisely: once a list<Product> has passed through a function call — as a parameter, as a return value, even through a helper that just runs a plain for/if/push loop with no filter/map involved — reading a float field on its elements afterward comes back as a garbage value. int and string fields on the exact same elements read back correctly; only float is affected. This is why total_value() is safe (it reads self.price from inside the same loop that owns the list, never after the list has crossed a function boundary) while a separate in_stock(catalog)/by_category(catalog, ...)-style helper, called and then iterated for its .price field afterward, is not. Filtering and building the discount inline in main(), as done above, sidesteps it entirely. If you see a float field come back as a huge nonsense number specifically after a list of structs has been returned from a function, this is the known cause.
DO: Give struct methods names that read like English when called: product.is_in_stock(), circle.contains(point), user.has_permission("admin"). DON'T: Name methods get_X() or set_X() — just use p.price to read and p.price = 99.0 to write. Java-style getters/setters are unnecessary in NOVA.
Generics
A generic function or type works over any type T without the caller ever writing out a concrete type — the compiler infers T from how the function is called, the same way it infers everything else in NOVA. Type parameters are declared in angle brackets that come before the function name.
fn <T> identity(x: T) -> T return x fn <T, U> my_map(xs: list<T>, f) -> list<U> let result = [] for x in xs push(result, f(x)) return result type Box<T> value: T
CRITICAL: type parameters go BEFORE the function name — fn <T> name(...), NOT fn name<T>(...). Writing the type parameters in the wrong position is not a syntax error you'll immediately see: it silently truncates the module's exports, so callers in other files simply cannot find the function. Always write the type-parameter list first.
Compare: Rust and C# put type parameters after the name (fn identity<T>(x: T)); NOVA puts them before, closer to how a universally-quantified type is written in formal type theory (∀T. T → T) — and it reads as "for any T, define identity."
Constraining a generic with a trait
An unconstrained <T> accepts literally any type — including ones whose values don't support whatever the function body tries to do with them. With no bound, the compiler can only catch a mismatch at the CALL SITE, once T is finally known, which puts the error far from the generic function's own definition. Writing <T: TraitName> instead narrows T to only the types that satisfy that trait, which lets the compiler check the function's BODY against the trait's methods right where the function is defined — the same before-the-name position as an unconstrained type parameter, just with : TraitName added.
trait Comparable fn less_than(self, other) -> bool // Constrained: T must satisfy Comparable — the body can safely call less_than fn <T: Comparable> smaller(a: T, b: T) -> T if a.less_than(b) a else b type Money cents: int fn Money.less_than(other: Money) -> bool self.cents < other.cents m1 = Money { cents: 500 } m2 = Money { cents: 250 } cheaper = smaller(m1, m2) print(cheaper.cents) // 250
DO: Add a : Trait bound the moment a generic function's body calls a method on T — it turns "works for the types I happened to test" into "works for exactly the types that support this operation," and the check happens at the function's own definition, not scattered across every call site. DON'T: Over-constrain — fn <T> identity(x: T) -> T needs no bound at all, because its body never calls anything on T; a bound it doesn't need only narrows who can call it.
Struct reflection API (automatic — zero annotation)
Every struct you define automatically gets a set of reflection functions — no @derive, no annotation, no opt-in. The compiler already knows every field name and type at compile time, so it generates these for free.
type Person name: string age: int let p = Person("Bob", 30) field_names(p) // ["name", "age"] field_types(p) // ["string", "int"] field_get(p, "name") // "Bob" type_name(p) // "Person" fields(p) // ["Bob", 30]
When to use this: writing generic debug/print utilities, building your own serializers, validation frameworks that need to walk arbitrary structs, or ORM-style code that maps struct fields to database columns — all without writing per-type boilerplate.
Compare: Java needs reflection APIs (Class.getFields()) that are verbose and lose compile-time type safety. Python's vars(obj)/__dict__ works but has no static type information behind it. NOVA's reflection is generated by the compiler from real static field types, so field_types(p) is exact, not inferred at runtime from whatever value happens to be stored.
Automatic to_json / from_json
Every struct also auto-generates JSON conversion — to_json, from_json, from_json_safe, from_dict, and from_dict_list — again with zero annotation. from_json_safe returns a Result, so malformed input is a normal Err you handle with match, not a crash.
type User name: string age: int let u = User("Ann", 30) let j = u.to_json() // {"name":"Ann","age":30} let j2 = json_stringify(u) // same let r: Result<User> = from_json_safe(body) match r Ok(u) => print(u.name) Err(e) => print("bad json: " + e)
Key: using @derive to ask for serialization (the pattern from Rust's #[derive(Serialize)] or similar) is a HARD compile error in NOVA — not just unnecessary. Derivation is automatic and universal; there is no annotation that turns it on because it is never off.
Compare: Rust requires #[derive(Serialize, Deserialize)] plus the serde crate. Go requires struct tags (`json:"name"`) and still only gets marshal/unmarshal, not a full reflection API. Java needs a library like Jackson with annotations. NOVA needs none of this — every struct is JSON-capable and introspectable the moment you write type.
Automatic type coercion in from_dict
from_dict and from_dict_list build a struct from a plain dict — a database row, an HTTP form body, a CSV record — where every value naturally arrives as a STRING, regardless of what type the struct field actually is. Rather than making you hand-parse each field before construction, from_dict reads the target struct's declared field types and coerces automatically: a field typed int gets parsed as an integer, float gets parsed as a float, and bool accepts common string spellings like "true"/"false" — all before the struct object exists.
type Product name: string price: float quantity: int on_sale: bool // Every value here is a STRING — as if it came from a DB row or a form POST body row = {"name": "Widget", "price": "19.99", "quantity": "42", "on_sale": "true"} p = from_dict(row) as Product print(p.price) // 19.99 — the string "19.99" was coerced to a float print(p.quantity + 1) // 43 — the string "42" was coerced to an int, so + works print(p.on_sale) // true — coerced to a real bool, not the string "true"
43
true
DO: Reach for from_dict whenever the source data is naturally string-typed — SQL driver rows, HTML form submissions, CSV rows, environment variables. DON'T: Assume it silently succeeds on garbage input — an unparsable numeric string fails the same way int("abc") would; validate untrusted input before constructing the struct if the source isn't already trustworthy.
Universal equality, hashing, and copy — no annotation required
Three more operations work automatically on every value in NOVA — not just structs, and not just the ones covered by the reflection/serialization table above. == performs structural equality (it compares fields recursively, not object identity). hash(v) computes a structural hash that's guaranteed consistent with that equality — equal values always hash equal, which is exactly what's required to use a struct as a dict key or a set member. And copy(v) performs a deep clone: new memory, with zero aliasing to the original. None of the three need an annotation, a trait, or a hand-written method — they fall directly out of the same field and type information the compiler already tracks for every struct.
type Point x: float y: float a = Point { x: 1.0, y: 2.0 } b = Point { x: 1.0, y: 2.0 } print(a == b) // true — structural equality: same fields, different objects print(hash(a) == hash(b)) // true — equal values always hash equal // copy() forces a deep clone even for REFERENCE types like lists original = [1, 2, 3] alias = original // lists are reference types — alias points at the SAME list clone = copy(original) // copy() breaks that sharing push(alias, 4) push(clone, 99) print(original) // [1, 2, 3, 4] — alias shares storage with original print(clone) // [1, 2, 3, 99] — clone is fully independent
true
[1, 2, 3, 4]
[1, 2, 3, 99]
DO: Reach for hash(v) when you need a struct as a dict key or a member of a set — it is guaranteed consistent with == for every type, automatically. DON'T: Assume plain assignment (alias = original) gives you the independence copy(original) gives you for reference types like lists and dicts — assignment shares storage for those types; only copy() clones it.
Universal equality, hashing, and cloning
Three more operations come free on every struct you declare, no annotation required: == for deep structural equality, copy(x) for an independent clone, and hash(x) for a content-derived integer. These are universal runtime operations built into the language itself — not code the compiler generates per type, and not something you reach for by importing a module.
type Point x: int y: int let a = Point(1, 2) let b = Point(1, 2) print(a == b) // true -- compares every field, not object identity print(a != Point(1, 3)) // true -- differs in y print(hash(a) == hash(b)) // true -- hash() agrees with == : equal values hash equal
true
true
Structs already copy on plain assignment (see "Structs as values" above), so copy() matters most for lists and dicts — NOVA's reference types, where = aliases the same underlying data instead of duplicating it:
let original = [1, 2, 3] let alias = original // NOT a copy -- alias points at the same list let cloned = copy(original) // cloned is an independent list push(alias, 4) push(cloned, 99) print(original) // [1, 2, 3, 4] -- alias shares storage with original print(cloned) // [1, 2, 3, 99] -- untouched by the push through alias
[1, 2, 3, 99]
DO: Use a == b to compare two values for equality — never hand-write a field-by-field comparison function; it already exists and is generated correctly for every struct. Call copy(x) explicitly whenever you need an independent snapshot of a list or dict. DON'T: Assume struct equality is reference identity like Java's default Object.equals() — NOVA's == always compares actual field values, recursing into nested structs.
Structural equality, hash, and copy — also automatic, also universal
The reflection API and JSON conversion above cover two-thirds of what @derive would normally exist to generate. The remaining third is three operations that work on every struct exactly the same way, produced directly by the compiler from the struct's own field list — no method to write, no interface to implement, no annotation to remember. == performs a deep, field-by-field structural comparison: two separately-constructed Person values with identical field values are equal. This is never pointer/reference identity — the trap Java's default == and Python's default is both fall into, where two objects that look identical compare unequal because they happen to occupy different memory. hash(v) derives a stable integer hash from every field, with the guarantee that two structurally-equal values always hash equal — the exact invariant Java's equals()/hashCode() contract requires you to maintain by hand, and the exact invariant a forgotten field update breaks in practice. In NOVA you cannot break it, because both operations are generated from the same field list at the same time. copy(v) produces an independent deep clone: every heap-backed field — a nested struct, a list, a dict — is cloned too, not just the top-level struct shell, so mutating the copy can never reach back and corrupt the value you copied it from.
type Point x: int y: int let a = Point(1, 2) let b = Point(1, 2) let c = a print(a == b) // true -- same field values, different objects print(a == c) // true -- c is the same object as a let cloned = copy(a) cloned.x = 99 print(a.x) // 1 -- copy() made an independent clone print(cloned.x) // 99 print(hash(a) == hash(b)) // true -- structurally equal values hash equal
true
1
99
true
DO: trust == for correctness checks in tests and business logic — assert_eq(a, b) from Chapter 16 uses this exact structural comparison, so a test that compares two freshly-constructed structs behaves the way you'd expect. Reach for copy(v) before mutating a struct you received as a function argument, whenever the caller should keep seeing the original untouched. DON'T: hand-write an equals-style method or a manual field-by-field clone function "to be safe" — the compiler's version is correct by construction (it can never drift out of sync with the struct's field list the way a hand-maintained Java equals()/hashCode() pair can after someone adds a field to one but not the other), and it's exactly what @derive would have generated, if NOVA had a @derive.
Structural cast from a dict — form_as
from_json_safe (above) starts from a JSON string. form_as is the equivalent typed, validating cast that starts from a dict you already have in hand — an HTTP form body, a database row, or any name-keyed {string: string} data — and decodes it into a struct by matching dict keys to field names. Like from_json_safe, the target type comes from the let binding's own type annotation, not from anything written at the call site: let r: Result<T> = form_as(d).
type Signup name: string age: int score: float let d = {} d["name"] = "ada" d["age"] = "30" d["score"] = "9.5" let r: Result<Signup> = form_as(d) match r Ok(s) => print("welcome, {s.name} (age {s.age})") Err(e) => print("bad form: {e}")
Field handling is total, not all-or-nothing: a key that is missing from the dict, or present with an empty string, decodes to that field's zero value (0, 0.0, false, "") instead of failing the whole cast — the common shape of an optional form field or a NULL-able database column. Only a value that is present, non-empty, and fails to coerce — "age": "not-a-number" into an int field — produces Err, and that error names the offending field so you know exactly which input to reject back to the user.
DO: Annotate the binding with the exact target type — let r: Result<Signup> = form_as(d) — that annotation is how the compiler knows which struct to decode into; there is no call-site type argument like form_as<Signup>(d). DON'T: Treat a missing key as a validation failure — form_as already fills in a type-correct default for that case; check field values yourself with a follow-up validator (Chapter 9) if an absent field should actually be rejected.
Type alias and distinct newtype
NOVA has two different ways to give a type a new name, and they mean opposite things. A plain type X = Y alias is transparent — zero cost, and X and Y are fully interchangeable everywhere. A type X = distinct Y newtype is opaque — a genuinely distinct type that will not unify with Y, even though it has the same runtime representation.
// Transparent alias — zero cost, fully interchangeable type Json = dict<string, any> type Headers = dict<string, string> // Distinct newtype — genuinely distinct type, won't unify type UserId = distinct int let u = UserId(42) assert_eq(u.value, 42) // UserId + int is a type error — must convert explicitly
When to use which: use a plain alias (Json, Headers) purely for readability — you want the shorter name, but never want the type checker to stop you from passing a dict<string,any> wherever a Json is expected. Use distinct when mixing up two values of the same underlying representation would be a real bug — a UserId and a ProductId are both int underneath, but adding them together or passing one where the other is expected should be a compile error, not a runtime data-corruption bug.
Compare: this is the classic "newtype pattern" — Haskell's newtype, Rust's tuple-struct wrapper (struct UserId(i64)), F#'s units-of-measure. NOVA gives you both the zero-ceremony alias AND the type-safe wrapper as one keyword difference (distinct) instead of requiring a full wrapper struct with manual field access.
7. Enums
What is this? Enums (sum types / tagged unions) define a type that can be exactly one of several named variants, each carrying different data. Enums eliminate entire classes of bugs: you can't accidentally mix up a Circle and a Rectangle because the type system tracks which one you have.
Declaring and using enums
enum Shape Circle(radius: float) Rect(width: float, height: float) Triangle(base: float, height: float) fn area(s) match s Circle(r) => 3.14159 * r * r Rect(w, h) => w * h Triangle(b, h) => 0.5 * b * h fn describe(s) match s Circle(r) => "circle with radius {r}" Rect(w, h) => "rectangle {w}x{h}" Triangle(b, h) => "triangle base={b} height={h}" fn main() shapes = [Circle(5.0), Rect(3.0, 4.0), Triangle(6.0, 8.0)] for s in shapes print("{describe(s)} has area {area(s)}") // circle with radius 5.0 has area 78.53975 // rectangle 3.0x4.0 has area 12.0 // triangle base=6.0 height=8.0 has area 24.0
Trap: a variant constructor's value has the VARIANT's own type, not the parent enum's type
Circle(5.0) has static type Circle — not Shape — even though Circle is declared as one variant of the Shape enum above. NOVA's type checker does not implicitly widen a variant value to its parent sum type at a function boundary the way you might expect coming from Rust or TypeScript. This is exactly why area and describe above are written as fn area(s) rather than fn area(s: Shape): annotating the parameter as Shape would reject every individual call site, because the argument you actually pass — Circle(5.0), Rect(3.0, 4.0) — has the variant's own narrower type, not the enum's. Leaving the parameter unannotated lets inference assign it whatever type the call site actually needs; the match inside the function body is what establishes that all of Shape's variants are being handled together.
// WRONG — annotating s: Shape rejects a bare Circle(...) argument: Circle(5.0) has // type Circle, not Shape, so a call like area(Circle(5.0)) fails to type-check. // fn area(s: Shape) -> float // match s // Circle(r) => 3.14159 * r * r // ... // CORRECT — leave the parameter unannotated; inference assigns the right type per call site. fn area(s) match s Circle(r) => 3.14159 * r * r Rect(w, h) => w * h Triangle(b, h) => 0.5 * b * h print(area(Circle(5.0)))
DO: leave enum-matching function parameters unannotated — fn area(s), not fn area(s: Shape) — and let NOVA infer the right type at each call site instead of forcing every constructor call to widen to the parent enum type up front. DON'T: assume this works the way Rust's fn area(s: &Shape) does. NOVA's variant types are genuinely distinct types one level below the enum; the enum type itself only becomes the operative type where the compiler has already unified the variants together, such as inside the match body itself.
Enums without payload (simple variants)
enum Direction North South East West fn move_player(pos, dir) x, y = pos match dir North => x, y - 1 South => x, y + 1 East => x + 1, y West => x - 1, y
Enums for state machines
enum OrderStatus Pending Processing(warehouse: string) Shipped(tracking: string) Delivered Cancelled(reason: string) fn status_message(s) match s Pending => "Your order is pending payment" Processing(w) => "Being packed at {w}" Shipped(t) => "On the way! Track: {t}" Delivered => "Delivered. Enjoy!" Cancelled(reason) => "Cancelled: {reason}"
Named-field construction
For variants with many fields, you can use named-field syntax for clarity:
// Both are identical — positional vs. named s1 = Circle(5.0) s2 = Circle { radius: 5.0 } r1 = Rectangle(4.0, 6.0) r2 = Rectangle { width: 4.0, height: 6.0 } // clearer with many fields
DON'T use dot notation on enum values
This is the most common mistake for newcomers from object-oriented languages:
s = Circle(5.0) // WRONG: cannot access fields with dot notation // print(s.radius) // ERROR — field access not allowed on enums // CORRECT: use match to extract data match s Circle(r) => print(r) // 5.0 _ => print("not a circle")
Enums are not structs. You cannot access fields with . because the compiler does not know at that point which variant the value is. You must use match to tell the compiler which variant to extract from.
When to use enums vs structs
| Use a struct when... | Use an enum when... |
|---|---|
| Every value has the SAME fields | A value can be ONE of several different "shapes" |
| A point always has x and y | A shape can be a Circle OR Rectangle OR Triangle |
| A user always has name and email | A response can be Success(data) OR Failure(msg) |
| A config always has host and port | An event can be Click(x,y) OR KeyPress(key) OR Scroll(dir) |
Why enums over dicts? A dict with a "type" field is a common pattern but it's unchecked — the compiler won't catch typos in "Procssing". An enum variant is checked at compile time: Processing(w) with a typo is a compile error, not a runtime surprise.
| Language | Tagged union syntax | Exhaustiveness check? |
|---|---|---|
| NOVA | enum Color { Red, Green, Blue(v) } | Yes — compile error if case missing |
| Rust | enum Color { Red, Green, Blue(u8) } | Yes |
| Python | No native support | No |
| TypeScript | Discriminated union via interfaces | Partial (with strict null checks) |
Recursive enums — tree structures
An enum variant can reference the enum itself. This lets you build recursive data structures — trees, linked lists, expression trees — entirely in NOVA's type system with no pointers or nullable fields.
enum IntList Empty Cons(head: int, tail: IntList) fn list_sum(xs) match xs Empty => 0 Cons(h, rest) => h + list_sum(rest) // Build a list: 1 -> 2 -> 3 -> Empty my_list = Cons(1, Cons(2, Cons(3, Empty))) print(list_sum(my_list)) // 6 // A recursive length function fn list_len(xs) match xs Empty => 0 Cons(_, rest) => 1 + list_len(rest) print(list_len(my_list)) // 3
Line-by-line breakdown:
enum IntList— declares a recursive enum.Emptyis the base case (no data).Cons(head, tail)is the inductive case: one integer plus the rest of the list.Cons(1, Cons(2, Cons(3, Empty)))— builds a three-element list by nesting constructors. EachConsholds one value and a reference to the remaining list. This is exactly the singly-linked list data structure, expressed in NOVA's type system rather than with raw pointers.match xs— the function recurses on the structure of the list. TheEmptyarm is the base case — the recursion terminates here, returning 0.Cons(h, rest) => h + list_sum(rest)— the recursive arm. The compiler guarantees this match is exhaustive: bothEmptyandConsare handled. If you add a third variant toIntListlater, the compiler will flag everymatchonIntListthat doesn't handle it.Cons(_, rest)inlist_len— the underscore_is a wildcard: "there's a head field but I don't care what it is." The compiler still checks that the pattern is structurally correct; it just discards the head value.
Vs Python: Python has no recursive algebraic types. A Python programmer would use a class with a next field that can be None. The None is unchecked — you can accidentally pass a None to code that expects a list node. In NOVA, Empty is a real variant: the type system prevents you from treating an empty list as if it has a head element.
Real-world: command-line argument parsing
Enums are the natural representation for command-line arguments: each flag is a distinct variant, some carry values and some don't. This beats a string-switch approach because the compiler checks exhaustiveness — if you add a new flag, every match that processes CliArg will produce a compile error until you handle the new case.
enum CliArg Help Version OutputFile(path: string) Verbose Unknown(text: string) fn parse_arg(s) match s "--help" => Help "-h" => Help "--version" => Version "--verbose" => Verbose _ => if starts_with(s, "--output=") OutputFile(slice(s, 9, len(s))) else Unknown(s) fn main() verbose = false output = "out.txt" for a in args() match parse_arg(a) Help => print("Usage: myapp [--verbose] [--output=FILE]") Version => print("myapp 1.0.0") Verbose => verbose = true OutputFile(path) => output = path Unknown(text) => print("Unknown flag: {text}") print("verbose={verbose}, output={output}")
Line-by-line breakdown:
enum CliArg— each command-line flag is a variant.HelpandVerbosecarry no data.OutputFilecarries the file path.Unknowncarries whatever string the user typed.fn parse_arg(s)— converts a raw string argument into a typedCliArg. The last expression is the return value — noreturnkeyword."--help" => Help— a string match arm. Both"--help"and"-h"map to the sameHelpvariant. This is simpler than checking string equality in if-chains._ =>with a block body — the wildcard arm runs when no other arm matches. The block usesif/elseto distinguish--output=...from truly unknown flags.OutputFile(slice(s, 9, len(s)))— strips the"--output="prefix (9 characters) and wraps the remainder in theOutputFilevariant.for a in args()—args()returns the command-line arguments as a list of strings (excluding the program name).match parse_arg(a)— the exhaustiveness check is onCliArg: all 5 variants are covered. If you add a 6th variant to the enum, the compiler will flag this match and require you to handle it.
Why enums beat string-switch: In Python or Java, you might do if arg == "--help", elif arg.startswith("--output="), etc. There's no way to enforce exhaustiveness — a new flag added to the parser might be silently ignored by the handler. NOVA's enum approach makes every new variant a compile-time obligation everywhere it's matched.
Enums with associated data — a payment system
One of the most powerful uses of enums is modeling domain concepts where different "shapes" of the same concept carry completely different data. Here is a payment method enum where each payment variant carries only the data relevant to that method:
enum PaymentMethod CreditCard(number: string, expiry: string, cvv: string) BankTransfer(account: string, routing: string) Crypto(wallet: string, coin: string) Cash fn process_payment(amount, method) match method CreditCard(num, exp, _) => print("Charging card *{slice(num, len(num)-4, len(num))} exp {exp}: \${amount}") BankTransfer(acc, routing) => print("Wire \${amount} to account {acc} routing {routing}") Crypto(wallet, coin) => print("Send \${amount} in {coin} to wallet {wallet}") Cash => print("Cash payment of \${amount} — collect at register") card = CreditCard("4111111111111111", "12/26", "123") bank = BankTransfer("987654321", "021000021") bitcoin = Crypto("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf", "BTC") process_payment(99.99, card) process_payment(500.00, bank) process_payment(25.00, bitcoin) process_payment(15.00, Cash)
Line-by-line breakdown:
CreditCard(number: string, expiry: string, cvv: string)— this variant carries three string fields. A credit card needs number, expiry, AND CVV — all three are always present together.BankTransfer(account: string, routing: string)— completely different fields. Bank transfers have no expiry or CVV. The enum keeps each variant's data logically separate.Cash— a unit variant with no data. Cash payments carry no card details, no account number. This is expressed as no parentheses at all.CreditCard(num, exp, _)in the match — extractsnumberintonum,expiryintoexp, and discardscvvwith_(you don't log CVV to the console for security). The pattern binds positionally.slice(num, len(num)-4, len(num))— extracts the last 4 digits of the card number for display. Never show full card numbers in logs.
What this looks like in Python — and why NOVA is better:
# Python — dict-based, no exhaustiveness checking payment = {"type": "credit_card", "number": "4111..."} if payment["type"] == "credit_card": process_card(payment["number"]) # KeyError if "number" missing elif payment["type"] == "bank": process_bank(payment["account"]) # "credit_card" typo would be silent bug # "crypto" silently ignored — no exhaustiveness error!
DO: Use enums with associated data when different cases carry different fields — payments, events, network messages, configuration options. Each variant carries exactly the data it needs, no more. DON'T: Use a dict with a "type" key and dynamic field access — NOVA's enum gives you compile-time exhaustiveness and field safety that dicts cannot provide.
Option<T> — built-in optional values
NOVA has null for missing values, but for functions that need to signal "found / not found" in a type-safe way, Option<T> is cleaner — and it is a compiler-native type, exactly like Result<T,E> (Section 9). You never declare it yourself: the T? type suffix is sugar for Option<T>, some(value)/none() construct it, and match already recognizes Some(value)/None as its two cases — no enum definition required:
// Some(value) / None need no enum declaration -- Option<T> is compiler-native, // exactly like Result<T,E>. A function that finds the first element matching a condition: fn find_first(items, predicate) for item in items if predicate(item) return Some(item) None fn find_user(users, name) find_first(users, u => u.name == name) type User name: string email: string fn main() users = [ User { name: "Alice", email: "alice@example.com" }, User { name: "Bob", email: "bob@example.com" } ] result = find_user(users, "Alice") match result Some(u) => print("Found: {u.email}") // Found: alice@example.com None => print("User not found") missing = find_user(users, "Carol") match missing Some(u) => print("Found: {u.email}") None => print("User not found") // User not found
Option vs null — when to use each:
| Situation | Use null | Use Option<T> |
|---|---|---|
| Returning a missing item quickly | Yes — simpler | Overkill |
| Public API for a library | No — callers forget to check | Yes — compiler forces the check |
| Storing optional fields in a struct | Yes — email: string can be null | Works but verbose |
| Chaining: "if found, do X, then if found, do Y" | Nested ifs get ugly | Yes — match stays flat |
NOVA's built-in Result type (see Section 9) is a related pattern for operations that can fail WITH an error message — use Result when failure needs a reason, and Option<T> when it's simply "not found."
Enums as state machines
Enums shine when you model a system that can only be in one of a known set of states. The compiler guarantees you never create an invalid state — unlike integers or strings which accept any value.
enum TrafficLight Red Yellow Green fn next_state(light) match light Red => Green Green => Yellow Yellow => Red fn can_go(light) match light Green => true Yellow => false // slow down, not go Red => false // Simulate one full cycle light = Red for _ in 0..5 print("{light} — go: {can_go(light)}") light = next_state(light)
Green — go: true
Yellow — go: false
Red — go: false
Green — go: true
Yellow — go: false
Notice that next_state returns an enum value directly from a match — no intermediate variable needed. The compiler verifies that all three cases are handled; delete any arm and it refuses to compile.
Multi-state business workflow — order lifecycle
Here is a real-world example: an e-commerce order that can only advance through legal states. Invalid transitions (e.g. shipping a cancelled order) are compile-time impossible because the transition functions only accept the right variant.
enum OrderState Cart PendingPayment(total: float) Processing(order_id: string, total: float) Shipped(order_id: string, tracking: string) Delivered(order_id: string) Cancelled(reason: string) // Each function encodes a legal transition fn checkout(state, total) match state Cart => PendingPayment(total) _ => Cancelled("can only checkout from Cart") fn pay(state, order_id) match state PendingPayment(total) => Processing(order_id, total) _ => Cancelled("payment not expected in this state") fn ship(state, tracking) match state Processing(id, _) => Shipped(id, tracking) _ => Cancelled("can only ship a processing order") fn deliver(state) match state Shipped(id, _) => Delivered(id) _ => Cancelled("can only deliver a shipped order") fn summary(state) match state Cart => "Shopping cart (empty)" PendingPayment(total) => "Awaiting payment — total ${total}" Processing(id, total) => "Processing order #{id}, ${total}" Shipped(id, tracking) => "Shipped #{id}, tracking: {tracking}" Delivered(id) => "Delivered #{id} — thank you!" Cancelled(reason) => "Cancelled: {reason}" // Walk an order through its full lifecycle order = Cart order = checkout(order, 149.99) order = pay(order, "ORD-8821") order = ship(order, "1Z999AA10123456784") order = deliver(order) print(summary(order))
| Concern | String/int approach | Enum approach |
|---|---|---|
| Typos | "shiped" silently accepted | Compile error — no such variant |
| Missing transitions | Runtime crash or wrong behavior | Exhaustiveness check at compile time |
| Associated data | Separate maps/structs required | Stored directly in the variant |
| Adding a new state | Must grep every switch manually | Compiler lists every match that needs updating |
| Performance | String comparison O(n) | Tag dispatch O(1) |
8. Pattern matching
What is this? match inspects a value and runs different code based on its shape. It's more powerful than if/else: it extracts data in the same step, is exhaustive (compiler forces you to handle every case), and compiles to an efficient jump table for integer patterns.
Exhaustiveness — why match is safer than if/else chains
The central guarantee of match is that it must cover every possible case. When you match on an enum, the compiler checks that every variant has an arm. When you add a new variant to an enum later, every match that is missing the new arm becomes a compile error — which means the compiler automatically finds every place in your code that needs updating. This is impossible with if/else chains or string comparisons.
| Match pattern | What it does | Example |
|---|---|---|
| Literal value | Matches exactly that value | 0 => "zero" |
| Enum variant (no data) | Matches that variant | Red => "stop" |
| Enum variant (with data) | Matches and destructures | Ok(v) => use v |
| Named binding | Binds the matched value to a name | x => "got {x}" |
Wildcard _ | Matches anything, discards it | _ => "other" |
Guard if condition | Extra predicate on top of pattern | n if n > 100 => "big" |
enum Direction North South East West fn opposite(dir) match dir North => South // must handle all 4 variants South => North // if you omit one, the compiler errors East => West West => East // Later, add a new variant: NorthEast // The compiler now errors on `opposite` saying: // NorthEast is not covered — add an arm or use _ for a default // With an if/else chain you would never know
Wildcard _ vs named binding: Use _ when you intentionally do not care about the value. Use a named binding (x, e, etc.) when you need to use the matched value in the arm's body. Using _ with named enum variants means you accepted the exhaustiveness check but chose not to act on some cases — this is intentional and fine. Using _ as the ONLY arm defeats exhaustiveness checking entirely — avoid it unless the enum has many variants and you truly only care about a few.
Matching integers with guards
fn describe(x) match x 0 => "zero" 1 => "one" n if n < 0 => "negative: {n}" n if n > 100 => "big: {n}" _ => "other" // _ = wildcard, matches anything print(describe(0)) // zero print(describe(-5)) // negative: -5 print(describe(200)) // big: 200 print(describe(42)) // other
Line-by-line:
0 => "zero"— matches the literal integer 0. The=>separates the pattern from the value.n if n < 0 => ...— binds the value ton, then guards with anifcondition. This is a guard clause._ => "other"— the wildcard._matches anything and doesn't bind a name. Every match must be exhaustive — if you don't have a_, the compiler checks that all cases are covered.
Range patterns and or-patterns
Two more pattern kinds round out match: a range pattern LOW..HIGH => ... tests whether the subject falls within a range in a single arm, and an or-pattern p1 | p2 => ... matches if EITHER alternative matches, letting several cases share one arm body.
Critical gotcha — range PATTERNS are inclusive of both ends, the opposite of range EXPRESSIONS: the range expression 0..5 used in a for loop or a slice is exclusive of 5 (Python range() semantics). A match-arm range pattern 0..5 => ... is INCLUSIVE of 5 — it matches 0 through 5. These are two different parts of the grammar that happen to share the .. spelling; do not assume they share inclusivity too.
fn classify_temp(c) match c -10..0 => "freezing" // matches -10 through 0, INCLUSIVE of 0 1..15 => "cold" // matches 1 through 15, INCLUSIVE of 15 16..25 => "mild" 26..40 => "hot" _ => "out of range" print(classify_temp(0)) // freezing — 0 is the inclusive upper bound of -10..0 print(classify_temp(15)) // cold — 15 is the inclusive upper bound of 1..15 print(classify_temp(30)) // hot // or-pattern: several distinct cases, one body enum Status Pending Loading Ready Failed fn is_settled(s) match s Ready | Failed => true Pending | Loading => false print(is_settled(Ready)) // true print(is_settled(Loading)) // false
cold
hot
true
false
Line-by-line:
-10..0 => "freezing"— a range pattern reads like a literal but tests membership; equivalent to a guardn if n >= -10 and n <= 0, but as a single arm instead of a bound-plus-guard.Ready | Failed => true— the or-pattern|lets two enum variants share one arm without duplicating the body or falling back to a guard likes if s == Ready or s == Failed.- Arm order still matters with ranges exactly as it does with literals: a later range pattern that overlaps an earlier one is unreachable dead code — write non-overlapping ranges top to bottom.
DO: Reach for a range pattern instead of a guard when the check is a simple bounds test — 0..59 => "valid minute" is clearer than n if n >= 0 and n <= 59 => "valid minute".
DON'T: Carry the range EXPRESSION's exclusive-of-the-end habit into a match arm — 0..59 as a pattern already includes 59; writing 0..60 "to be safe" silently accepts one value too many.
Nested constructor patterns
What is this? A pattern arm can destructure more than one level of nesting in a single arm — Wrap(IntVal(n)) matches an Outer that is a Wrap AND, in the same breath, matches the Inner value inside it against IntVal, binding its field straight to n. Without this, the same check needs a separate nested match for every layer — exactly the ceremony NOVA's pattern matching exists to remove. It works to arbitrary depth, and it works both for your own enums and for the built-in Result/Option types.
enum Inner IntVal(n: int) StrVal(s: string) enum Outer Wrap(inner: Inner) Empty() fn render(o: Outer) -> string match o Wrap(IntVal(n)) => "int:{n}" // two levels, one arm Wrap(StrVal(s)) => "str:{s}" Empty() => "empty" // Nesting through a BUILT-IN sum type works the same way — // Result<Inner> here, not just user-defined enums. fn sum_nested(r: Result<Inner>) -> string match r Ok(IntVal(n)) => "ok-int:{n}" Ok(StrVal(s)) => "ok-str:{s}" Err(e) => "err:{e}" print(render(Wrap(IntVal(42)))) // int:42 print(sum_nested(ok(IntVal(5)))) // ok-int:5
ok-int:5
Line-by-line: Wrap(IntVal(n)) reads outside-in: first confirm the value is a Wrap, then look inside it and confirm THAT value is an IntVal, then bind its field to n — all as one pattern, checked as one arm. The nesting isn't limited to two levels or to your own enum types: it composes through Result, Option, and arbitrarily deep user-defined enums alike, because the compiler compiles a nested pattern the same way regardless of how many constructors deep it goes. Note the case difference between building and matching a Result: you construct one with the lowercase builtin ok(...)/err(...), but you match against it with the capitalized pattern Ok(...)/Err(...) — see the callout in §9 (Error handling).
DO: Reach for nested patterns whenever the shape you actually care about spans more than one constructor — matching a Result of an Option, a tree node's typed children, or a protocol frame's payload variant. It replaces what would otherwise be a separate match for every layer. DON'T: Assume a nested match can skip a case at the inner level and still compile — exhaustiveness is checked all the way through the nesting, not just at the outer layer, so every inner variant needs an arm (or an outer arm covering it) somewhere.
Matching strings
fn http_status(method) match method "GET" => "reading" "POST" => "creating" "PUT" => "updating" "DELETE" => "deleting" other => "unknown method: {other}"
Classic FizzBuzz with match
fn fizzbuzz(n) match n % 15 0 => "FizzBuzz" _ if n % 3 == 0 => "Fizz" _ if n % 5 == 0 => "Buzz" _ => str(n) for i in 1..20 print(fizzbuzz(i))
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Matching enum variants (destructuring)
enum Message Quit Move(x: int, y: int) Say(text: string) fn handle(msg) match msg Quit => print("quitting") Move(x, y) => print("moving to {x},{y}") Say(t) => print("says: {t}") handle(Move(10, 20)) // moving to 10,20 handle(Say("hello")) // says: hello
quitting
Why not if/else chains? match extracts data and checks the type in one step. An if/else equivalent would require two separate operations. The compiler also guarantees exhaustiveness — if you add a new variant later, every match on that enum becomes a compile error until you handle the new case.
Recursive match — expression evaluator
Match shines when combined with recursive enum types. This is the classic interpreter pattern used in real compilers:
enum Expr Num(value: float) Add(left: Expr, right: Expr) Mul(left: Expr, right: Expr) fn eval(e) match e Num(v) => v Add(l, r) => eval(l) + eval(r) Mul(l, r) => eval(l) * eval(r) // Build the AST for: (2 + 3) * 4 expr = Mul(Add(Num(2.0), Num(3.0)), Num(4.0)) print(eval(expr)) // 20.0
Line-by-line breakdown:
enum Expr— A recursive enum (an expression is either a number, or a pair of sub-expressions).AddandMuleach contain twoExprchildren — that is the recursion.Num(v) => v— Base case: a leaf node. Just return its value.Add(l, r) => eval(l) + eval(r)— Recursive case: evaluate the left sub-expression, evaluate the right sub-expression, then add them. The compiler guarantees this match is exhaustive — there is no "forgot a case" bug.expr = Mul(Add(Num(2.0), Num(3.0)), Num(4.0))— Builds the tree(2 + 3) * 4as a data structure. No parsing needed — NOVA's enum syntax IS the AST.eval(expr)— Walks the tree recursively:eval(Mul(...)) → eval(Add(Num(2), Num(3))) * eval(Num(4)) → (2+3) * 4 → 5 * 4 → 20.0.
This pattern is the foundation of every interpreter, compiler, and parser. In languages without match, this requires a visitor pattern with 6 files and 200 lines of boilerplate. In NOVA: 8 lines.
Matching Results
fn safe_divide(a, b) if b == 0 return err("division by zero") ok(a / b) match safe_divide(10, 3) Ok(v) => print("result: {v}") Err(e) => print("error: {e}") // Output: result: 3
The built-in Result type uses Ok(value) and Err(message). Matching on a Result is the canonical way to handle both success and failure. See Section 9 for the full error handling guide.
Matching with guards and multiple conditions
Match arms can include a if guard to add extra conditions beyond the pattern shape. When no single pattern handles two distinct values the same way, guards let you group logic without duplicating the arm body.
fn classify(n) match n 0 => "zero" x if x < 0 => "negative" x if x % 2 == 0 => "positive even" _ => "positive odd" print(classify(0)) // zero print(classify(-3)) // negative print(classify(8)) // positive even print(classify(7)) // positive odd // Matching HTTP status codes — group ranges with guards fn status_label(code) match code 200 => "OK" 201 => "Created" 204 => "No Content" 301 => "Moved Permanently" 302 => "Found" 400 => "Bad Request" 401 => "Unauthorized" 403 => "Forbidden" 404 => "Not Found" 500 => "Internal Server Error" x if x >= 100 and x < 200 => "1xx Informational" x if x >= 200 and x < 300 => "2xx Success" x if x >= 300 and x < 400 => "3xx Redirect" x if x >= 400 and x < 500 => "4xx Client Error" x if x >= 500 and x < 600 => "5xx Server Error" _ => "Unknown status" print(status_label(404)) // Not Found print(status_label(418)) // 4xx Client Error
negative
positive even
positive odd
Not Found
4xx Client Error
Line-by-line breakdown:
x if x < 0 => "negative"— the patternxbinds the matched value into a variable namedx. The guardif x < 0is evaluated only if the pattern binds (it always does for a bare name). The arm runs only when the guard is true.x if x % 2 == 0 => "positive even"— guards are checked top-to-bottom. Because0andx if x < 0are checked first, by the time we reach this arm,xis guaranteed to be positive._ => "positive odd"— the fallback wildcard. At this point,xis positive (not caught above) and odd (guard above was false). The wildcard satisfies the exhaustiveness requirement.x if x >= 200 and x < 300 => "2xx Success"— theandoperator combines two conditions in the guard. In NOVA,and/or/notare keywords, not symbols like&&/||/!.
Real-world: HTTP request router
Nested match is a clean, exhaustive HTTP router. Each level of matching handles one dimension: method first, then path. If you add a new handler, the compiler's exhaustiveness check tells you exactly which paths are unhandled — not a 404 discovered in production.
fn get_users() "[Alice, Bob]" fn create_user() "Created" fn get_user(id) "User {id}" fn delete_user(id) "Deleted {id}" fn dispatch(method, path) match method "GET" => match path "/health" => "200 OK" "/users" => get_users() _ => if starts_with(path, "/users/") get_user(slice(path, 7, len(path))) else "404 Not Found" "POST" => match path "/users" => create_user() _ => "404 Not Found" "DELETE" => match path _ => if starts_with(path, "/users/") delete_user(slice(path, 7, len(path))) else "404 Not Found" _ => "405 Method Not Allowed" print(dispatch("GET", "/health")) // 200 OK print(dispatch("GET", "/users/42")) // User 42 print(dispatch("POST", "/users")) // Created print(dispatch("DELETE", "/users/7")) // Deleted 7 print(dispatch("PATCH", "/users")) // 405 Method Not Allowed
User 42
Created
Deleted 7
405 Method Not Allowed
Line-by-line breakdown:
fn get_users() "[Alice, Bob]"— single-expression function bodies can be written on the same line as the signature. The string is the return value.match method— the outer match dispatches on HTTP method. Only four methods are explicitly handled; all others fall to the_ =>wildcard returning 405.match path— the inner match dispatches on the path. Because each method arm produces a value (the response string), the inner match must also produce a string in every arm.if starts_with(path, "/users/")— dynamic path parameters like/users/42can't be exact-matched statically, so the wildcard arm uses anifto check the prefix and extract the ID.slice(path, 7, len(path))—sliceextracts a substring. Index 7 skips the 7 characters of"/users/"to get just the ID portion._ => "405 Method Not Allowed"— the outer wildcard. Any HTTP method not listed (PATCH, HEAD, OPTIONS, etc.) returns 405. This arm is what makes the outer match exhaustive.
Why nested match beats a dict of function pointers: A dict-based router like \{("GET", "/users"): get_users\} cannot enforce exhaustiveness at compile time. If get_users is removed or renamed, the dict entry becomes a runtime error. With nested match, the compiler verifies the structure. The Forge web framework (see §20) provides a higher-level router built on exactly this pattern — but for small programs, a plain match is all you need.
if let — pattern matching sugar
if let PATTERN = EXPR tests whether EXPR matches PATTERN; if it does, the pattern's bindings are available in the if body. The optional else runs when it doesn't match. Use it instead of a full match when you only care about one pattern and want everything else handled by a single fallback.
type User name: string fn find_user(id) -> Result<User> if id == 1 return ok(User("Alice")) err("no such user") if let Ok(user) = find_user(1) print(user.name) else print("not found") // Alice if let Ok(user) = find_user(99) print(user.name) else print("not found") // not found
not found
Line-by-line:
if let Ok(user) = find_user(1)— evaluatesfind_user(1)and tries to match it against the patternOk(user). Sincefind_user(1)returnsok(User("Alice")), the pattern matches anduseris bound to the innerUservalue for the body.else— runs when the pattern does not match, here whenever the result isErr(...).if letsupports exactly one pattern and one fallback — it is not a substitute formatchwhen you need to distinguish between several non-matching cases.- Works with any pattern, not just
Result—Some(x), a specific enum variant, a literal. Reach forif letwhenever amatchwould have exactly one real arm and one wildcard arm.
DO: Use if let when you have exactly one pattern you care about and a single fallback for everything else.
DON'T: Reach for if let when you need different behavior for two or more non-wildcard cases (e.g. Ok, Err(NotFound), Err(other)) — if let lumps every non-matching case into the same else, while match gives each one its own arm and checks exhaustiveness.
while let — loop while a pattern keeps matching
while let PATTERN = EXPR is if let's looping counterpart: it re-evaluates EXPR before every iteration and keeps running the body as long as the pattern matches, binding the pattern's variables fresh each time. The loop stops the moment the expression no longer matches — no separate flag variable, no manual break.
fn next_item(queue) if len(queue) == 0 return none() some(pop(queue)) // pop removes and returns the LAST element queue = ["a", "b", "c"] while let Some(item) = next_item(queue) print("processing: {item}") // processing: c // processing: b // processing: a print("queue drained: {len(queue) == 0}") // queue drained: true
processing: b
processing: a
queue drained: true
Line-by-line:
next_item(queue)— returnsSome(value)while the queue still has elements,Noneonce it's empty. Every call mutatesqueueviapop, which is why the loop makes progress instead of running forever.while let Some(item) = next_item(queue)— the condition is RE-EVALUATED (callingnext_itemagain) at the top of every iteration, not just once up front. The moment it returnsNone, the pattern fails to match and the loop exits — no explicitif item == None: breakneeded.- Compare to a plain
while: without pattern binding you'd need a temporary read before the loop AND again at the bottom of the body to keep the condition in sync — two places to update instead of one.
DO: Use while let for "keep pulling until the source is exhausted" loops — draining a queue, reading lines until EOF, walking a linked structure until you hit None.
DON'T: Use while let when you need to react differently to WHY the match failed (e.g. Err(Timeout) should stop the loop differently than Err(Closed)) — a loop with a full match and an explicit break per case gives you that control; while let only has one "stop" path.
Tuple patterns in match
Grouping several values in parentheses as the match subject — match (a, b) — lets each arm's pattern test both values positionally in one arm, instead of nesting a match inside a match.
fn classify_point(a, b) match (a, b) (0, 0) => "origin" (x, 0) => "on x-axis at {x}" (0, y) => "on y-axis at {y}" (x, y) => "general point ({x}, {y})" print(classify_point(0, 0)) // origin print(classify_point(5, 0)) // on x-axis at 5 print(classify_point(0, 3)) // on y-axis at 3 print(classify_point(4, 7)) // general point (4, 7)
on x-axis at 5
on y-axis at 3
general point (4, 7)
Line-by-line:
match (a, b)— groups two values into a single match subject. Each arm's pattern(x, y)lines up positionally: the first slot tests againsta, the second againstb.(0, 0) => "origin"— matches only when bothaandbare exactly0.- Arm order matters, same as any
match:(0, 0)must come before(x, 0)and(0, y), or the more general bindings would shadow the specific case.
Relation to Chapter 5: this parenthesized grouping is not a match-only trick — match (a, b) builds a real tuple value as its subject, and (x, y) in each arm is the same tuple pattern you can use anywhere. Tuples are first-class in NOVA: (a, b) is a literal you can store in a variable, pass to a function, or return, and let (a, b, ...) = expr destructures one outside of match too — use _ to discard a slot (see Chapter 5, "Multiple return values via structs"). Reach for a struct instead when the slots need names — a tuple's positions carry no documentation of their own.
The matches operator (boolean test)
matches is a boolean infix operator: VALUE matches PATTERN evaluates to true or false, nothing more. It is unrelated to the match statement — match dispatches to different code per case, while matches just answers one yes/no question, usable anywhere a boolean is expected.
response = "200 OK" if response matches "200 OK" print("success") // success value = parse_int_safe("42") if value matches Ok(v) print("got: {v}") // got: 42
got: 42
Line-by-line:
response matches "200 OK"— when the right side is a string,matchesis a regex test (see Chapter 13). A plain string with no regex metacharacters matches only itself literally.value matches Ok(v)— when the right side is a pattern likeOk(v),matchestests whethervaluehas that shape, and — inside theifbody — bindsvto the extracted inner value.matchesvsmatch:matchis a statement that runs different code per case.matchesis an expression — it can sit insideif,while, anand/orchain, or a plain assignment likefound = value matches Ok(_).
DO: Use matches for a quick "does this fit the shape I expect" check folded into a larger condition — if x matches Some(_) and y > 0.
DON'T: Chain multiple matches tests as a substitute for match when you need to act differently for every case — once you're distinguishing three or more shapes, a real match is clearer and exhaustiveness-checked, while repeated matches tests are not.
9. Error handling
What is this? NOVA uses Result<T> — a value that is either Ok(value) when the operation succeeded, or Err(message) when it failed. The type system forces you to handle both cases. You can never accidentally use a value from an operation that might have failed.
Creating results
fn divide(a, b) if b == 0 return err("division by zero") ok(a / b) fn main() match divide(10, 2) Ok(result) => print("Result: {result}") // Result: 5 Err(e) => print("Error: {e}") match divide(10, 0) Ok(result) => print("Result: {result}") Err(e) => print("Error: {e}") // Error: division by zero
Error: division by zero
Safe numeric parsing — parse_int_safe and parse_float_safe
Converting text into a number is one of the most common places a program meets the outside world — a form field, a CSV column, a config value, a command-line argument — and one of the most common places it silently breaks. int(s) and float(s) (Chapter 3) crash the process on malformed input. parse_int_safe(s) and parse_float_safe(s) are the total, non-crashing counterparts: each returns a Result, so a bad string becomes an ordinary Err you handle like any other failure, not a panic that takes down the whole program.
match parse_int_safe("42") Ok(n) => print("got integer: {n}") Err(e) => print("bad input: {e}") // got integer: 42 match parse_float_safe("3.14") Ok(f) => print("got float: {f}") Err(e) => print("bad input: {e}") // got float: 3.14 match parse_float_safe("not a number") Ok(f) => print("got float: {f}") Err(e) => print("bad input: {e}") // bad input: not a number
got float: 3.14
bad input: not a number
Both functions reject the same shapes of malformed input the same way: an empty string or a string of only whitespace, and a string with trailing non-numeric characters after a valid prefix ("42abc" is an Err, not a silently-truncated 42) both come back as Err, never a guessed value. parse_int_safe additionally rejects a numeral that overflows 64 bits as Err("integer out of range") instead of wrapping silently.
DO: Reach for parse_int_safe/parse_float_safe — paired with match, try, or unwrap_or — for any number that originates outside your program's control. DON'T: Follow a successful format/regex check with a second round of error handling on the parse result "just in case" — if the string is already proven numeric, unwrap_or with an unreachable default is enough; a full match at that point is handling a case that cannot occur.
The try keyword — propagating errors
try is NOVA's shorthand: if the result is Ok(value), unwrap it and continue. If it's Err(e), immediately return the error from the current function. This is identical to Rust's ? operator but spelled as a word.
fn parse_age(s) n = try parse_int(s) // if parse_int fails, return the error immediately if n < 0 or n > 150 return err("age out of range: {n}") ok(n) // Without try, you'd write this: fn parse_age_verbose(s) result = parse_int(s) match result Err(e) => return err(e) Ok(n) => if n < 0 or n > 150 return err("age out of range") ok(n)
try eliminates the boilerplate of manually matching every intermediate result.
Chaining fallible operations
fn load_config(path) content = try read_file(path) // fails if file not found data = try from_json(content) // fails if invalid JSON port = try parse_int(data["port"]) // fails if not an integer ok(port) fn main() match load_config("config.json") Ok(port) => print("Starting on port {port}") Err(e) => print("Config error: {e}")
Result helper functions
For simple cases where match feels heavy, these helper functions extract values from a Result:
r = parse_port("8080") print(is_ok(r)) // true print(unwrap(r)) // 8080 ← only safe if you already checked is_ok! r2 = parse_port("99999") print(is_err(r2)) // true print(unwrap_err(r2)) // "port must be 1-65535, got 99999" // unwrap_or: get value, or use default if it's an Err port = unwrap_or(parse_port("bad"), 8080) print(port) // 8080 (the default, because "bad" failed to parse)
| Function | What it does | Crashes on? |
|---|---|---|
is_ok(r) | Returns true if the result is Ok | Never |
is_err(r) | Returns true if the result is Err | Never |
unwrap(r) | Extracts value from Ok | Yes — crashes if r is Err |
unwrap_err(r) | Extracts message from Err | Yes — crashes if r is Ok |
unwrap_or(r, default) | Value from Ok, or default if Err | Never |
ok(value) | Creates an Ok(value) result | Never |
err(msg) | Creates an Err(msg) result | Never |
DO: Use match as your primary way to handle Results — it is the safest because the compiler forces you to handle both Ok and Err. DO: Use unwrap_or(r, default) when a sensible fallback exists. DON'T: Use unwrap(r) without first calling is_ok(r) — it will crash your program on Err.
Unused Result warning — the compiler catches ignored errors
If a function's return type is a Result and the calling statement discards that value entirely — never assigned, never matched, never chained with try or ? — the compiler emits a warning naming the function and the call site where the result vanished. This is NOVA closing the exact gap the language comparison later in this chapter calls out in Go: in Go, ignoring an error is one character (_); in NOVA, ignoring one is flagged by the compiler by default, with no linter to configure and no opt-in required.
fn save_to_disk(path, data) -> int or Error if len(data) == 0 return err("cannot save empty data") ok(write_file(path, data)) fn main() save_to_disk("out.txt", "") // the Result is discarded here // compiler warning: unused Result from save_to_disk() — this call can fail // and nothing here checks it; use match, try, or unwrap_or.
The fix is the same as every other Result in this chapter — handle it explicitly:
fn main() match save_to_disk("out.txt", "") Ok(n) => print("wrote {n} bytes") Err(e) => print("save failed: {e}") // save failed: cannot save empty data
DO: Let this warning guide you — every flagged call is a real place an error could occur that nothing in your program would otherwise notice. DON'T: Silence a flagged call with a throwaway binding just to make the warning go away — if you deliberately don't care about one specific failure, try, match, or unwrap_or(expr, default) still document that decision in code, which is strictly more useful than suppressing the warning.
never panic on user input
DON'T: Use a function that panics on invalid input (like direct integer parsing without error handling) for user-provided values. Always use the Result-returning version and match or try. A panic kills the whole process. An Err lets you send a helpful message back to the user.
Building a chain of fallible operations
Real programs often need to perform a sequence of operations where any step could fail. Without try, each step requires a full match block — indentation explodes and the happy path gets buried. With try, the chain reads cleanly from top to bottom:
fn connect_and_query(host, port, query) -> Result conn = try tcp_connect(host, port) try tcp_send(conn, query) response = try tcp_recv(conn) tcp_close(conn) ok(response) fn main() match connect_and_query("localhost", 5432, "SELECT 1") Ok(data) => print("got: {data}") Err(e) => print("failed: {e}")
Line-by-line breakdown:
fn connect_and_query(host, port, query) -> Result— the-> Resultreturn type annotation signals that this function returns aResult(eitherOk(data)orErr(message)). This tells callers they must handle both outcomes.conn = try tcp_connect(host, port)—tryunwraps theOkvalue and stores it inconn. Iftcp_connectreturnsErr(host unreachable, port closed, DNS failure),tryimmediately returns that error fromconnect_and_query— the remaining lines do not run.try tcp_send(conn, query)— tries to send the query. Notice there is no assignment — we don't need theOkvalue (which would just be a confirmation), we just need to ensure the send succeeded. If sending fails (connection dropped),trypropagates the error immediately.response = try tcp_recv(conn)— tries to receive the response. If the network is disrupted between sending and receiving,trycatches it and propagates. If it succeeds,responseholds the received data.tcp_close(conn)— closes the connection. This does NOT usetrybecause we do not care if closing fails — the response is already inresponse, so the operation succeeded. Cleaning up after success is a "best effort" action.ok(response)— wraps the result inOk. If we reached this line, all threetryoperations succeeded. This is the happy path.
Compare to the non-try version: Without try, the same function would require nested match blocks three levels deep — the happy path would be buried in 15+ lines of boilerplate. With try, the happy path is a straight line of 5 statements.
Compare to Go: Go uses if err != nil { return nil, err } after every call — 3 lines of boilerplate per step. NOVA's try is one word, same semantics. Compare to Rust's ? operator — identical semantics, but NOVA's keyword is more readable to beginners.
DO: Use try to propagate errors in any function that returns Result. Read top-to-bottom as the happy path — the error handling is implicit. DON'T: Use try in a function that does NOT return Result — the compiler will reject it because there is nowhere to propagate the error to.
Providing fallback values
Not every error should propagate up the call stack. Sometimes a sensible default is the right response — a missing config value, a missing dict key, a failed optional lookup. Use unwrap_or for simple defaults and match for conditional logic:
// unwrap_or: provide a default if the result is an error // If parse_port("bad") fails, use 8080 instead port = unwrap_or(parse_port(input), 8080) // match: different behavior for success vs failure match read_file("config.toml") Ok(content) => parse_config(content) Err(e) => print("Using defaults: {e}") default_config()
Line-by-line breakdown:
port = unwrap_or(parse_port(input), 8080)—parse_port(input)returns aResult. If it isOk(n),unwrap_orextracts the port numbern. If it isErr(...)(bad format, out of range),unwrap_ordiscards the error and returns the default8080instead. In one line: parse OR fall back to 8080. Nomatch, no boilerplate.match read_file("config.toml")— when you need different behavior for success vs failure,matchis cleaner than chainedif/else. The compiler forces you to handle both arms.Ok(content) => parse_config(content)— if the file was read successfully, parse it.contentis the file contents as a string.Err(e) =>— if reading failed (file not found, permission denied), run the alternative block.eis the error message string.print("Using defaults: {e}")— log why we fell back (so it is visible in logs when deploying).default_config()— return a default configuration. This is the last expression in theErrarm, so it is the return value of the wholematch.
Choosing between try, match, and unwrap_or:
| Situation | Use | Reason |
|---|---|---|
| Error should propagate to caller | try expr | Clean, single word, same semantics as Rust's ? |
| Have a sensible constant default | unwrap_or(expr, default) | One-liner, clear intent, no boilerplate |
| Need different logic for Ok vs Err | match result ... | Compiler forces you to handle both outcomes |
| Want to check before using | if is_ok(r) ... unwrap(r) | Explicit guard, safe when combined with check |
DO: Prefer try for functions that are "infrastructure" (loading config, connecting, reading). Prefer unwrap_or for optional values with clear defaults (port numbers, timeouts, missing keys). Prefer match when the error case has meaningful user-visible behavior (showing error messages, choosing alternative data sources).
Error handling compared
| Language | Approach | Problem |
|---|---|---|
| C | Return -1 or NULL, check manually | Easy to forget to check; no type enforcement |
| Java/Python | Exceptions — thrown anywhere, caught somewhere | Error paths invisible in function signature |
| Go | (value, error) tuples | Easy to ignore with _, err := f() |
| Rust | Result<T, E> + ? operator | Correct but verbose error type system |
| NOVA | Result + try keyword | None — forced handling, readable English syntax |
Real-world example — validated user registration
This example wires together the three error handling tools (try, match, unwrap_or) in a realistic scenario: validating form data before saving to a database. Each validator returns a Result; the orchestrating function uses try to fail-fast at the first invalid field.
// Individual validators — each returns Ok or Err with a user-readable reason fn validate_username(name) if len(name) < 3 return err("username too short (minimum 3 characters)") if len(name) > 32 return err("username too long (maximum 32 characters)") for ch in name if not(is_alnum(ch) or ch == "_") return err("username may only contain letters, digits, and underscores") ok(name) fn validate_email(email) if not(contains(email, "@")) return err("email must contain @") parts = split(email, "@") if len(parts) != 2 or len(parts[0]) == 0 or not(contains(parts[1], ".")) return err("email format invalid") ok(email) fn validate_password(pw) if len(pw) < 8 return err("password must be at least 8 characters") ok(pw) // Orchestrator: check every field in order, stop at first failure. // Nested match, not try — chaining try through more than one user-defined // Result-returning function currently loses the unwrapped value's type (a // real, open compiler gap, not a style choice); explicit match is unaffected. fn register_user(form) match validate_username(form["username"]) Err(e) => return err(e) Ok(username) => match validate_email(form["email"]) Err(e) => return err(e) Ok(email) => match validate_password(form["password"]) Err(e) => return err(e) Ok(password) => // All three passed — safe to write to DB return ok({"username": username, "email": email, "created_at": time_ms()}) fn main() good_form = {"username": "alice", "email": "alice@example.com", "password": "hunter2!"} bad_form = {"username": "x", "email": "notanemail", "password": "short"} match register_user(good_form) Ok(user) => print("Registered: {user["username"]}") Err(e) => print("Error: {e}") match register_user(bad_form) Ok(user) => print("Registered: {user["username"]}") Err(e) => print("Error: {e}")
Error: username too short (minimum 3 characters)
Key observations:
- Each validator is a pure function: it takes a string, returns
Ok(string)orErr(string). They are independently testable. register_userchecks each field in turn via nestedmatch; the firstErrarm hit returns immediately — the remaining validators do not run. This is fail-fast behaviour: the user sees the first validation problem, not all of them at once. (This usesmatchrather thantrydeliberately — chainingtrythrough more than one user-definedResult-returning function currently loses the unwrapped value's concrete type, a real open compiler gap;tryon a single builtin call, as seen elsewhere in this tutorial, is unaffected.)- The validated values (
username,email,password) are guaranteed clean because they came from theOkarm of their respectivematch. There is no way to reach theok({...})line without all three validators having passed. - The caller (
main) usesmatchto handle success and failure explicitly. If you are building a web handler, you would instead return an HTTP 400 response in theErrarm.
Error handling in Forge web handlers
In the Forge web framework, every request handler is just a function. Validate what you need directly, and return an error response the moment something fails — the same fail-fast shape as the standalone example above, just returning a Response instead of a Result:
import forge type RegisterRequest username: string email: string fn handle_register(req: Request) -> Response let body: RegisterRequest = from_json(req.body) if len(body.username) < 3 return forge.resp_error(400, "username too short (minimum 3 characters)") if not(contains(body.email, "@")) return forge.resp_error(400, "email must contain @") forge.resp_json(200, {"status": "ok", "username": body.username}) fn main() let app = forge.app() forge.post(app, "/register", handle_register) forge.serve_app(app, 8080)
POST {"username":"x","email":"notanemail"} → 400 {"error":"username too short (minimum 3 characters)"}
The web framework does not need special error types, exception classes, or middleware to catch errors — an early return with the response you want to send IS the error handling. One thing worth flagging plainly: matching a Result — match some_call() Ok(x) => ... Err(e) => ... — and reading the bound value back out is reliable in ordinary code, but doing that same match directly inside the body of a function registered as a Forge route handler (passed to forge.get/forge.post/etc.) is a real, currently-open compiler bug: the bound value comes back as 0, even the WHOLE value with no field access at all — not something about which field you read. This happens whether the Result comes from your own ok()/err() or from a builtin like jwt_verify. The fix is simple and shown in the next example: do the matching in an ordinary function, and have the route handler call that function and work with the plain value it returns, rather than matching a Result in the handler's own body. This is why the handler above validates directly (plain if checks, no match) rather than matching one.
The ? operator — Result propagation
Alongside the try keyword, NOVA has a postfix ? operator that does the exact same job with less punctuation weight in a chain: expr? unwraps an Ok(value) and continues, or immediately returns the Err from the enclosing function. The compiler auto-threads the failing function's name and line into the error context, so a propagated error carries a trail of where it passed through — not just where it originated.
fn middle(flag) let v = might_fail(flag)? // if Err, returns it immediately ok(v + 1) fn outer(flag) let n = middle(flag)? ok("got " + str(n))
Compare: Rust has bare ? with no built-in context trail (you reach for a crate like anyhow to get one). Go writes if err != nil { return err } after every call — three lines of ceremony per step. NOVA's ? is Rust's operator with the context-trail behavior built in, not bolted on.
DO: Use ? when you want propagation inline inside a larger expression (let n = parse(s)? + 1). Use the try keyword when the propagating call is its own statement — both compile to identical semantics, so pick whichever reads better at the call site.
Option<T> and the T? sugar
Option<T> is the compiler-native sibling of Result<T,E> from earlier in this chapter — the same kind of built-in sum type, but for the narrower case of "a value, or nothing," with no reason attached the way Err carries one. In any type annotation, the suffix T? is sugar for Option<T> — the spelling you'll actually see in real NOVA signatures far more often than the fully spelled-out generic form.
fn first_even(xs: list<int>) -> int? for x in xs if x % 2 == 0 return some(x) none() fn describe(v: int?) -> string match v Some(n) => "got {n}" None => "nothing" print(describe(first_even([1, 3, 5, 8, 9]))) // got 8 print(describe(first_even([1, 3, 5]))) // nothing
nothing
Line-by-line:
-> int?reads as "returns an int, or nothing" — the exact same shape as the-> T or Esugar forResulta few sections from here, but for absence instead of failure.some(x)/none()construct the two cases;Some(n)/Nonedestructure them in amatch, exhaustively — there is no third case to accidentally forget.
Key: don't reach for null (Section 3) where an Option fits better. none() is a real, statically-tracked value the type checker follows through every call site — a function returning int? can never be silently treated as a plain int anywhere in the program, the way a value that might be null can slip through unchecked. Reserve null for the rare case where you genuinely don't want the type system tracking presence or absence at all.
Optional chaining (?.)
?. safely reaches into an Option or a value that might be none(). If the receiver is none(), the whole chain short-circuits to none() instead of crashing — no null-pointer exception, no manual nil-check. The result of a ?. chain is always an Option, so pair it with ?? when you need a plain value.
let name = user?.name // Option<User> -> Option<string> let age = find_user(1)?.age ?? -1 // chain with ?? for a default
Compare: this is the same idea as JavaScript/Kotlin/C#'s ?. — but in NOVA it composes with the same Option type used everywhere else in the language, rather than being a special case bolted onto nullable references.
Null-coalescing (??)
?? unwraps Some(x) to x, or evaluates and returns the right-hand side if the left is none(). The default expression is only evaluated when needed (short-circuit), so it is safe to put an expensive or side-effecting fallback on the right.
let s = get_score(-1) ?? 42 // none() -> 42, some(x) -> x let name = find_name(id) ?? "anonymous"
Key: ?? operates on Option (Some/none()); unwrap_or operates on Result (Ok/Err). Reach for ?? when the "failure" is really just absence — a lookup that legitimately might not have anything to find.
with / else — happy-path Result chaining
with binds several fallible expressions in sequence, stopping at the first Err. If every binding succeeds, the block body runs with all the unwrapped values in scope. If any binding fails, execution jumps straight to the mandatory else block instead. Under the hood this desugars to nested match over Result — with is a readability layer, not a new execution model.
fn sum2(s1: string, s2: string) -> int with a <- parse_int_safe(s1), b <- parse_int_safe(s2) return a + b else err return -1
Compare this to nesting two match blocks by hand: with collapses the "everything must succeed" pattern into one line per binding, and gives you exactly one place — the else block — to handle any failure, no matter which binding produced it.
Key: the else block is not optional — a with without an else is a compile error, because the compiler will not let a with chain silently drop the failure case.
defer — cleanup on all exit paths
defer schedules an expression to run when the enclosing function exits — no matter which return statement triggers the exit. Multiple defers in one function run in LIFO order (last deferred, first run), exactly like Go.
fn process_file(path) let f = open(path) defer close(f) let data = read_line(f) if data == "" return err("empty") // close(f) still runs ok(data)
Compare: Java/C++ use try/finally (or RAII destructors) for this; Python uses with context managers. NOVA's defer is the Go approach — one keyword, no block nesting, no extra type required, and it reads in the order the cleanup was acquired rather than being buried in a finally far from the resource it cleans up.
DO: Put defer close(f) immediately after the line that opens the resource — that way the cleanup is never forgotten, even as the function grows more return statements later.
Crash-safe cleanup — on_exit_send / cancel_on_exit_val
defer is a compile-time construct: the compiler inlines the deferred call at every one of the function's exit points when it generates code. That's exactly what makes it read so cleanly — but it also means defer only runs on exit paths the compiler actually wove it into. A panic doesn't exit through any of those paths — it unwinds by jumping straight to the enclosing task's fault handler, skipping every deferred call between the panic site and that handler. For a log line, a skipped defer is harmless. For a resource borrowed from a shared pool — a database connection, a worker slot, a rate-limit token — a skipped defer means that resource is gone for good: nobody sends it back, and the pool has one less connection for the rest of the program's life. on_exit_send and cancel_on_exit_val close that gap: they register the cleanup in a runtime-tracked registry that is drained on every exit path, including a panic.
fn borrow(pool, id) conn = recv(pool) on_exit_send(pool, conn) // safety net: goes back to `pool` even if this task crashes below print("worker {id} using conn {conn}") send(pool, conn) // normal path: return it ourselves cancel_on_exit_val(pool, conn) // ...then cancel the safety net — it already ran fn main() pool = channel() send(pool, 1) // one "connection" available in the pool borrow(pool, "A") borrow(pool, "B") conn = recv(pool) print("pool still holds conn {conn}")
worker B using conn 1
pool still holds conn 1
Line-by-line breakdown:
on_exit_send(pool, conn)— the momentborrowhas the connection, it registers "if I exit for any reason before this is cancelled, sendconnback topool." This runs immediately — it does not wait for a crash to "arm" itself.send(pool, conn)thencancel_on_exit_val(pool, conn)— the normal, successful path: the function returns the connection itself, then cancels the registered fallback so it doesn't fire a second time (which would send a connection that's already back in the channel).- If
borrowinstead panicked betweenrecvandcancel_on_exit_val— say, a bug in code that usesconn— the registeredon_exit_sendstill runs as the task unwinds, andpoolgets its connection back anyway. Adefer close(f)-style cleanup would NOT have run in that same scenario.
DO: Call on_exit_send immediately after receiving a resource from a shared channel-based pool or registry — right where you'd otherwise write defer — and pair it with cancel_on_exit_val once you've returned the resource normally. DON'T: Assume defer alone protects a pooled resource — it's compile-time and inlined at known exit points, so it's skipped entirely when a panic unwinds past it. Reach for on_exit_send specifically when permanently losing the resource (not just skipping one cleanup call) is the failure you're guarding against.
Trap: defer does not run if the function panics — use on_exit_send for cleanup that must survive a crash
defer is a compile-time, lexical construct: the compiler statically walks the ordinary return paths of a function and inserts the deferred call on each one it can see. A panic — an out-of-bounds index, unwrap() on None/Err, overflow from checked_add/checked_sub/checked_mul, or an explicit panic() call — does not take any of those paths. It unwinds the current task straight to that task's fault handler (contained if the task was spawned, fatal if it's the main task), skipping every pending defer in the call stack on the way. A defer close(f) guarding a pooled connection or a lock is exactly the kind of cleanup a panic mid-function will silently skip.
For cleanup that must run no matter how the task ends, register it with on_exit_send(chan, value) instead. Unlike defer, the registration is scoped to the whole TASK, not the current function call — it fires on both the normal exit path and the crashed/panicked exit path, sending value back on chan exactly once. Cancel it with cancel_on_exit(token) once you've released the resource yourself, so a normal return doesn't release it a second time.
fn make_pool(size, connect_fn) let ch = channel_bounded(size) for _ in 0..size send(ch, connect_fn()) ch fn with_conn(pool, f) let conn = recv(pool) // borrow a connection let token = on_exit_send(pool, conn) // safety net: conn goes back even if f(conn) panics let result = f(conn) // a defer here would NOT survive that panic cancel_on_exit(token) // normal path: cancel the net, we release it ourselves send(pool, conn) result let pool = make_pool(1, fn() 42) // one fake "connection" — just the int 42 spawn fn() with_conn(pool, fn(conn) panic("query failed")) // panics — contained to this task sleep(50) // let the spawned task finish unwinding print(recv(pool)) // the connection came back even though f(conn) panicked
DO: Reach for on_exit_send(pool, conn) — or the equivalent for a lock, a temp file, any resource a defer is currently "protecting" — anywhere that resource must come back even after an unexpected crash. Cancel it explicitly with cancel_on_exit(token) once you release the resource on the normal path. DON'T: treat defer some_cleanup() as a crash-proof finally block. It isn't — it only guards the ordinary return paths the compiler can see at compile time, and a panic bypasses every pending defer in the call stack on its way to the task's fault handler.
EXPR else FALLBACK — one-word error handling
The lightest-weight fallback form in NOVA: a statement of the shape value = FALLIBLE_EXPR else FALLBACK. The subject expression is evaluated exactly once; if it is Err, the else side runs instead. The fallback can be a plain value, or an "escape" statement like return that leaves the function entirely.
config = read_file_safe("config.txt") else "{}" // value fallback port = parse_int_safe(env("PORT")) else 8080 // ditto user = parse_json(json) else return err("bad json") // escape form
Key: EXPR else FALLBACK is statement-level only — you cannot nest it inside another expression like str(f() else 0). For that case, use unwrap_or(f(), 0) instead, which is a normal function call and composes anywhere.
try / catch / error — ambient error handling
NOVA has a second error-handling track alongside Result<T,E>: an ambient error flag, raised with error(...) and checked implicitly by try and catch. This is a separate mechanism from the Result-based ?/try/with shown above — think of it as NOVA's answer to exceptions, for the rare case where threading a Result through every call in a deep chain is more ceremony than the situation warrants.
// try re-raises on ambient error flag let content = try read_file("config.txt") // catch intercepts ambient errors let result = read_file("missing.txt") catch e => "default" // error() raises an ambient error if x < 0 error("x must be non-negative")
Key: the ambient-error track and the Result/? track are DIFFERENT mechanisms. A ?-propagated Err DOES bridge into the ambient error flag (so catch can intercept it), but the reverse is not automatic — an ambient error(...) does not silently become a Result unless something on the call path is written to expect one. Prefer Result + ? for anything that crosses a public function boundary; reach for try/catch/error for quick scripts or leaf-level code where a full Result signature is overhead you don't want.
-> T or E — fallible return type sugar
-> T or E in a function signature desugars to Result<T, E>. It keeps the success type AND the failure type visible in the signature without generic-bracket punctuation — useful when the error type is meaningful (not just a string) and you want callers to see it without reading the function body.
fn safe_divide(a: int, b: int) -> int or Error if b == 0 return err("division by zero") ok(a / b)
Compare: Rust writes fn safe_divide(a: i64, b: i64) -> Result<i64, Error> — functionally identical, but the angle-bracket generic syntax reads heavier for a construct this common. NOVA's T or E reads like the English sentence "returns an int, or an Error."
Result / Option combinator stdlib
match, try, and with cover the general case, but chaining several transformations onto ONE Result or Option is common enough that NOVA ships a small function-based combinator toolkit for it: import std/core/result and import std/core/opt. Each combinator takes the wrapped value first and a plain function second, so a chain of transformations reads left to right instead of nesting match blocks inside each other.
import std/core/result import std/core/opt fn parse_positive(s: string) -> int or string let n = try parse_int_safe(s) if n <= 0 return err("must be positive") ok(n) // result_map: transform the Ok value, pass an Err straight through untouched let doubled = result_map(parse_positive("21"), n => n * 2) print(doubled) // Ok(42) // result_and_then: chain a SECOND fallible step, only if the first one succeeded let chained = result_and_then(parse_positive("10"), n => parse_positive(str(n - 20))) print(chained) // Err(must be positive) // opt_map / opt_unwrap_or: the same shape, for Option instead of Result let score = opt_map(some(21), n => n * 2) print(score) // Some(42) print(opt_unwrap_or(none(), 0)) // 0
Err(must be positive)
Some(42)
0
The combinator families:
Result (std/core/result) | Option (std/core/opt) | Does |
|---|---|---|
result_is_ok(r) / result_is_err(r) | opt_is_some(o) / opt_is_none(o) | Boolean check without unwrapping |
result_unwrap_or(r, default) / result_unwrap_or_else(r, f) | opt_unwrap_or(o, default) | Extract, or fall back |
result_map(r, f) / result_map_err(r, f) | opt_map(o, f) | Transform the success (or error) case in place |
result_and_then(r, f) | opt_filter(o, pred) | Chain a second fallible step / keep only if a predicate holds |
Key gotcha: o.map(f) does not call the Option combinator — method-call syntax on an Option silently dispatches to the LIST map builtin instead (no type error, since .method(...) falls back to any function of the right free-function shape). Always call these BY NAME: opt_map(o, f), result_map(r, f) — never o.map(f) or r.map(f).
10. Collections
What is this? NOVA has several built-in collection types: lists (ordered sequences), dicts (key-value maps), sets (unique elements), and strings (text). This section covers the complete APIs for all of them, plus the Buffer type for efficient string building.
Multi-line collection literals
Every collection literal in this chapter — [...], {...}, and struct literals — can be written across multiple lines. Newlines inside the brackets are transparent to the parser (no trailing \ or other continuation marker needed), and a trailing comma right before the closing bracket is explicitly allowed. This is what makes long, hand-formatted lists and config-style dicts readable instead of one unbroken line.
// newlines between elements are just whitespace to the parser primes = [ 2, 3, 5, 7, 11, // trailing comma before the closing bracket is fine ] print(len(primes)) // 5 // same rule for dict literals — one key: value pair per line reads like a config file config = { "host": "0.0.0.0", "port": 8080, "debug": false, } print(config["port"]) // 8080
8080
DO: Add a trailing comma after the last element in a multi-line literal — it makes future diffs cleaner (adding a new last line doesn't touch the previous one) and matches the convention in Python, Rust, Go, and JS.
DON'T: Worry about a stray trailing comma breaking anything — a lone trailing comma right before ] or } is always allowed, on a single line or across many.
Lists — the complete API
nums = [3, 1, 4, 1, 5, 9, 2, 6] // Access print(nums[0]) // 3 (first element) print(nums[-1]) // 6 (last element, negative indices count from end) print(nums[-2]) // 2 (second to last) print(len(nums)) // 8 // Modify push(nums, 7) // append to end → [3, 1, 4, 1, 5, 9, 2, 6, 7] last = pop(nums) // remove and return last → last=7 insert(nums, 0, 99) // insert 99 at index 0 → [99, 3, 1, 4, 1, 5, 9, 2, 6] remove(nums, 99) // remove first occurrence of value 99 list_remove_at(nums, 0) // remove element at index 0 // Search print(5 in nums) // true print(contains(nums, 5)) // true (same as `in` keyword) print(find("hello world", "world")) // 6 (index of first match) // Sort and reverse sort(nums) // sort in place ascending → [1, 1, 2, 3, 4, 5, 6, 9] nums = reverse(nums) // returns a NEW reversed list — does not modify the original → [9, 6, 5, 4, 3, 2, 1, 1] // Slice — creates a NEW list, does not modify original sub = nums[1:4] // elements at index 1, 2, 3 → [6, 5, 4] // Concatenate a = [1, 2, 3] b = [4, 5, 6] c = a + b // [1, 2, 3, 4, 5, 6] // Join into string words = ["hello", "world"] print(join(words, " ")) // hello world print(join(words, ", ")) // hello, world
List mutators in depth — return values and edge cases
The mutators above modify a list in place, but two of them also hand back useful information: pop returns the element it removed, and remove returns whether it found anything to remove at all. Knowing these return values matters once you use these functions for real work — a task queue, an undo stack — rather than a one-off script.
queue = [] push(queue, "task-a") push(queue, "task-b") push(queue, "task-c") print(queue) // [task-a, task-b, task-c] next = pop(queue) // removes AND returns the LAST element print(next) // task-c print(queue) // [task-a, task-b] insert(queue, 0, "task-urgent") // insert at index 0 — everything else shifts right print(queue) // [task-urgent, task-a, task-b] found = remove(queue, "task-a") // removes the FIRST match by value print(found) // 1 — a match was found and removed print(queue) // [task-urgent, task-b] missing = remove(queue, "not-here") print(missing) // 0 — nothing matched, queue is unchanged
task-c
[task-a, task-b]
[task-urgent, task-a, task-b]
1
[task-urgent, task-b]
0
DO: Use push/pop together for a LIFO stack — both operate on the end of the list, so they're O(1). Check remove's return value (1 found-and-removed, 0 not-found) instead of assuming it always succeeds. DON'T: Use pop to implement a FIFO queue — it removes from the END, not the front. For first-in-first-out order, use a Deque (further down this chapter) with deque_pop_front, which is O(1); calling list_remove_at(xs, 0) repeatedly is O(n) per call because every remaining element has to shift left.
Functional list operations
nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] // map: apply a function to each element, return new list squares = map(nums, x => x * x) print(squares) // [1, 4, 9, 16, 25, 36, 49, 64, 81, 100] // filter: keep elements where the function returns true evens = filter(nums, x => x % 2 == 0) print(evens) // [2, 4, 6, 8, 10] // reduce: fold all elements into a single value total = reduce(nums, 0, (acc, x) => acc + x) print(total) // 55 // sum: shortcut for numeric reduce print(sum(nums)) // 55 // all_match / any_match: test a condition across all elements print(all_match(nums, x => x > 0)) // true (all positive) print(any_match(nums, x => x > 5)) // true (at least one > 5) print(all_match(nums, x => x % 2 == 0)) // false (not all even) // flatten: flatten a list of lists nested = [[1, 2], [3, 4], [5, 6]] flat = flatten(nested) print(flat) // [1, 2, 3, 4, 5, 6] // sort_by: sort with a custom key function words = ["banana", "apple", "cherry"] sorted_words = sort_by(words, a => len(a)) print(sorted_words) // [apple, banana, cherry] (sorted by length)
list_min / list_max, any / all, zip, index_of — more list operations
The functional operations above cover transforming and folding a list. These four cover the other common needs: pulling out an extreme value, testing plain truthiness across elements you already know are booleans, pairing two lists together, and finding the position of a value.
list_min / list_max — and the one-argument min(xs) / max(xs)
The two-argument min(a, b) / max(a, b) shown in Appendix A compares two individual values. When you have a whole list instead of two loose values, call list_min(xs) / list_max(xs) directly — or just call min(xs) / max(xs) with a single list argument, which NOVA rewrites to list_min/list_max automatically based on arity. Both spellings compile to the same code; use whichever reads better at the call site.
scores = [72, 95, 68, 88, 91] print(list_min(scores)) // 68 print(list_max(scores)) // 95 // min(xs) / max(xs) with ONE list argument rewrite to list_min/list_max — // same result, reads naturally next to the two-argument scalar form below print(min(scores)) // 68 print(max(scores)) // 95 // The two-argument form is a completely separate scalar overload print(min(72, 95)) // 72
95
68
95
72
DO: Use the one-argument min(xs)/max(xs) spelling for readability — NOVA resolves which overload you meant from the number of arguments, not from a separate function name. DON'T: Call list_min/list_max on an empty list without checking first — there's no natural "smallest value of nothing." Guard with len(xs) > 0 before reducing, the same discipline you'd apply to any fold over a possibly-empty sequence.
any(xs) / all(xs) — plain truthy checks
any_match/all_match above take a predicate and apply it to each element. any(xs)/all(xs) skip the predicate entirely — they test the raw truthiness of each element directly, the same way an if x would. Reach for these when the list already holds the booleans (or truthy/falsy values) you care about, rather than raw data you still need to test.
checks = [true, true, false] print(any(checks)) // true — at least one element is truthy print(all(checks)) // false — not every element is truthy flags = [1, 1, 1] print(all(flags)) // true — every nonzero int is truthy
false
true
DO: Always call any/all as free functions — any(xs), all(xs). DON'T: Write xs.any() or xs.all() as method calls — unlike every other function in this chapter, any/all do not support the .method() spelling; the compiled runtime symbols are nova_rt_any_truthy/nova_rt_all_truthy, and the method-call form fails to link. If you need a predicate instead of raw truthiness, use any_match(xs, pred)/all_match(xs, pred) instead — both of which DO support the method form.
zip — pairing two lists element-by-element
zip(xs, ys) (or xs.zip(ys)) walks two lists in lockstep and returns a new list of [a, b] pairs, one pair per position. It's the eager, list-returning counterpart to iter_zip from the next chapter: reach for zip when you already have two lists and want the paired result immediately as a list; reach for iter_zip only when the pairing is one stage in a larger lazy iterator pipeline.
names = ["Alice", "Bob", "Carol"] scores = [91, 84, 77, 100] // one extra element — ignored paired = zip(names, scores) print(paired) // [["Alice", 91], ["Bob", 84], ["Carol", 77]] print(len(paired)) // 3 — the length of the SHORTER input, not the longer one for pair in paired print("{pair[0]}: {pair[1]}")
3
Alice: 91
Bob: 84
Carol: 77
DO: Reach for zip when combining two parallel lists that came from the same source — column names with column values, IDs with results. DON'T: Assume the result is as long as the LONGER input — zip stops at the shorter list and silently drops the rest, exactly like Python's built-in zip(). Pad the shorter list first if you need every element from both.
index_of — finding the position of a value
xs.index_of(item) (or index_of(xs, item)) returns the index of the first element equal to item, or -1 if it isn't present — the same "-1 means not found" convention as the string function find(s, sub) shown earlier in this chapter, so code that handles both strings and lists can check for absence the same way in both places.
colors = ["red", "green", "blue", "green"] print(index_of(colors, "blue")) // 2 print(index_of(colors, "green")) // 1 — first match only; the one at index 3 is ignored print(index_of(colors, "purple")) // -1 — not present
1
-1
DO: Check for -1 explicitly before using the result as an index — i = index_of(xs, target); if i != -1 : .... DON'T: Pass the result straight into xs[i] unchecked: xs[-1] is a VALID negative index in NOVA (the last element), so an unchecked "not found" silently reads the wrong element instead of failing loudly.
sorted() / reversed() — expression-style aliases
sorted(xs) and reversed(xs) are parse-time aliases that read as ordinary expressions rather than statements: sorted(xs) gives you an ascending copy the same way xs.sort_by(x => x) does, and reversed(xs) gives you a reversed copy the same way reverse(xs) does. Both allocate a brand-new list and leave the original untouched — that's what makes them safe to drop directly into a for loop or a function argument, the way you would in Python.
scores = [88, 72, 95, 60] ascending = sorted(scores) print(ascending) // [60, 72, 88, 95] print(scores) // [88, 72, 95, 60] — untouched, sorted() does not mutate for line in reversed(["first", "second", "third"]) print(line) // third // second // first
[88, 72, 95, 60]
third
second
first
DO: Use sorted(xs)/reversed(xs) inline in a for loop or as a function argument — they're expressions, not statements, and both return brand-new lists, so the input is always safe to reuse afterward. DON'T: Reach for them expecting an in-place effect. Among NOVA's ordering builtins, only bare sort(xs) mutates in place — sort_by, sorted, and reversed all allocate and return a new list instead.
Dict operations — the complete API
d = {"a": 1, "b": 2, "c": 3}
// Access
print(d["a"]) // 1
// Check presence before access (always do this for unknown keys)
print("a" in d) // true
print(contains(d, "z")) // false
// Set / update
d["d"] = 4
// Delete
dict_delete(d, "a")
// Length
print(len(d)) // 3
// Keys and values as lists
ks = keys(d) // list of all keys
vs = values(d) // list of all values
// Iterate over key-value pairs
for k in keys(d)
print("{k} -> {d[k]}")
// Merge (second overwrites first on conflicts)
d1 = {"x": 1, "y": 2}
d2 = {"y": 3, "z": 4}
merged = dict_merge(d1, d2)
print(merged) // {"x": 1, "y": 3, "z": 4}Dict lookups with a default, iterating pairs, and del()
The bracket-access pattern above requires an explicit in check before every read of a possibly-missing key. Three more dict builtins fold that check into the call itself: get reads with a fallback in one step, items hands you keys and values together while iterating, and del is the method-call spelling of key removal.
inventory = {"apples": 12, "bananas": 6}
// get(d, k, default) — read-with-fallback in a single call, no `in` check needed
print(get(inventory, "apples", 0)) // 12 — key present, real value returned
print(get(inventory, "kiwis", 0)) // 0 — key missing, default returned instead
// Compare to a bare bracket read on a missing key — it also reads back 0,
// but now you can't tell if that's a REAL zero-stock count or "missing"
print(inventory["kiwis"]) // 0 — ambiguous
// items() — iterate key AND value together in one pass
for pair in inventory.items()
print("{pair[0]}: {pair[1]} in stock")
// del(k) — method-call spelling of key removal, equivalent to dict_delete(d, k)
inventory.del("bananas")
print("bananas" in inventory) // false0
0
apples: 12 in stock
bananas: 6 in stock
false
DO: Prefer get(d, k, default) over a bare bracket read whenever the key might be absent — it closes the "missing key silently reads back as 0" footgun in one call instead of a separate in check plus a read. DON'T: Reach for items() when you only need keys or only need values — keys(d)/values(d) each allocate a single flat list, while items() allocates a list of 2-element pairs; use it specifically when you need both together in the same loop.
String operations — the complete API
s = " Hello, World! " // Case conversion print(upper(s)) // " HELLO, WORLD! " print(lower(s)) // " hello, world! " // Trim whitespace print(trim(s)) // "Hello, World!" print(lstrip(s)) // "Hello, World! " print(rstrip(s)) // " Hello, World!" // Search print(find(s, "World")) // 9 (index of first match, -1 if not found) print(starts_with(s, " H")) // true print(ends_with(s, "! ")) // true // Replace print(replace(s, "World", "NOVA")) // " Hello, NOVA! " // Split and join print(split("a,b,c", ",")) // ["a", "b", "c"] print(join(["x", "y"], "-")) // "x-y" // Substring print(slice("hello world", 6, 11)) // "world" // Character access print(char_at("NOVA", 0)) // N print(char_at("NOVA", -1)) // A print(ord("A")) // 65 (ASCII code point) print(chr(65)) // A // Padding and repetition print(pad_left("42", 5, "0")) // 00042 print(pad_right("hi", 8, ".")) // hi...... print(center("NOVA", 10, "-")) // ---NOVA--- print(repeat("ab", 3)) // ababab // Type conversions print(str(42)) // "42" print(int("42")) // 42 print(float("3.14")) // 3.14
Buffer — efficient string building
A Buffer is a mutable, growable byte buffer. It is the correct way to build strings in a loop:
buf = buffer_create() buf_append(buf, "Hello") buf_append(buf, " ") buf_append(buf, "World") result = buf_to_str(buf) print(result) // Hello World print(buf_len(buf)) // 11 // Clear and reuse (avoids allocation) buf_clear(buf) buf_append(buf, "new content") print(buf_to_str(buf)) // new content
Why Buffer instead of result + piece?
// SLOW: O(n²) — each += copies the entire accumulated string result = "" i = 0 while i < 1000 result = result + "x" i = i + 1 // Total bytes copied: 1+2+3+...+1000 = 500,500 bytes // FAST: O(n) — buffer grows in place buf = buffer_create() i = 0 while i < 1000 buf_append(buf, "x") i = i + 1 result = buf_to_str(buf) // Total bytes copied: 1000 bytes
For 1000 iterations, buffer is ~500× faster. For 10,000 iterations, ~5,000× faster. The quadratic growth makes string concatenation in loops a real, visible performance problem at scale.
DO: Use buffer_create() + buf_append() + buf_to_str() when building strings in loops or from many pieces. DON'T: Use result = result + piece in a loop — it is O(n²) and becomes a visible performance problem above ~100 iterations.
buffer_create() isn't the only constructor. buffer() is a shorter alias for the same zero-argument constructor — purely a naming preference. buffer_cap(n) pre-allocates n bytes up front, which matters when you can estimate the final size: a plain buffer() still grows geometrically (allocate, copy, double) as it fills, while buffer_cap(n) skips those regrow steps entirely if your estimate is at or above the real size. And for the values appended most often in a hot loop — numbers and single characters — buf_append_int, buf_append_float, and buf_append_char write straight into the buffer without the intermediate str() allocation that buf_append(b, str(n)) would otherwise cost on every call.
// buffer_cap(n) pre-sizes the buffer when you know roughly how big the result will be out = buffer_cap(4096) // Typed appends skip the intermediate str() allocation buf_append(out, "score=") buf_append_int(out, 97) buf_append_char(out, 44) // ',' (ASCII 44) buf_append_float(out, 3.5) buf_append_char(out, 33) // '!' (ASCII 33) print(buf_str(out)) // buf_str is an alias for buf_to_str
DO: reach for buf_append_int/buf_append_float/buf_append_char over buf_append(b, str(n)) in tight loops — each typed append avoids an intermediate string allocation. Use buffer_cap(n) instead of buffer()/buffer_create() whenever you can estimate the final size. DON'T: assume buf_str and buf_to_str behave differently — they're the same function under two names; use whichever reads better at the call site.
Sets — unordered unique collections
// Create from a list — duplicates are silently removed s = set_from_list([1, 2, 3, 2, 1]) print(set_len(s)) // 3 (duplicates removed) // Add / remove set_add(s, 4) set_add(s, 2) // already exists — no effect print(set_len(s)) // 4 set_remove(s, 1) // Membership check print(set_has(s, 3)) // true print(set_has(s, 99)) // false // Convert to list items = set_to_list(s) print(items) // [2, 3, 4] (order may vary — sets are unordered)
4
true
false
[4, 2, 3]
range() — build a list of consecutive integers directly
range(n) returns a fully-built list of the integers from 0 up to (but not including) n — [0, 1, ..., n-1]. It has exactly the same [0, n) semantics as the 0..n range expression you'll see driving for loops and comprehensions throughout this tutorial — and, like 0..n, range(n) is eager: NOVA builds the whole list before you do anything with it. The difference is purely what you get back: range(n) hands you an ordinary list VALUE you can store in a variable, pass to a function, or chain into map/filter/sort_by; 0..n is inline syntax you write directly where a range is expected, most often right after for i in.
ten = range(10) print(ten) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(len(ten)) // 10 // Useful whenever you need the LIST itself, not just something to loop over squares = map(range(5), n => n * n) print(squares) // [0, 1, 4, 9, 16] // Same underlying list as writing 0..3 directly in the loop header for i in range(3) print(i) // 0, 1, 2
10
[0, 1, 4, 9, 16]
0
1
2
DO: Reach for range(n) when you want the sequence as a reusable, storable list — assign it to a variable, pass it to a function, or chain it into map/filter/sort_by. DON'T: Assume 0..n in a for loop is any lazier or cheaper than range(n) — both fully materialize the list of integers before iteration starts. For a genuinely lazy, streaming integer sequence, reach for iter_range(start, end) (next chapter) or a yield-based generator instead.
List comprehensions
List comprehensions create a new list by transforming and optionally filtering elements from an existing sequence. They are a concise alternative to writing map() and filter():
// Basic form: [expression for var in list] nums = [1, 2, 3, 4, 5] squares = [x * x for x in nums] print(squares) // [1, 4, 9, 16, 25] // With filter: [expression for var in list if condition] evens = [x for x in nums if x % 2 == 0] print(evens) // [2, 4] // Transform and filter together even_squares = [x * x for x in nums if x % 2 == 0] print(even_squares) // [4, 16] // From a range — NOVA ranges are exclusive on the right end, so 0..10 gives 0..9 (10 values) first_ten_squares = [i * i for i in 0..10] print(first_ten_squares) // [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] // String transformation words = ["hello", "world", "nova"] upper_words = [upper(w) for w in words] print(upper_words) // [HELLO, WORLD, NOVA] // Nested: flatten a 2D grid matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] flat = [x for row in matrix for x in row] print(flat) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
[2, 4]
[4, 16]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
["HELLO", "WORLD", "NOVA"]
[1, 2, 3, 4, 5, 6, 7, 8, 9]
Line-by-line breakdown (even_squares):
[x * x for x in nums if x % 2 == 0]— reads left to right: "make a list ofx * xfor eachxinnums, but only includexvalues wherex % 2 == 0." Processing: check 1 (odd, skip), check 2 (even, include 2*2=4), check 3 (odd, skip), check 4 (even, include 4*4=16), check 5 (odd, skip). Result: [4, 16].- Comprehensions are equivalent to
map(filter(nums, x => x % 2 == 0), x => x * x)but read more naturally.
DO: Use comprehensions for readable single-line transformations. DON'T: Use comprehensions when the transformation logic is complex enough to need multiple statements — write a for loop or a named function instead. The rule: if the expression fits on one readable line, use a comprehension.
Trap: a comprehension variable named after a builtin used to silently return the wrong data (fixed 2026-08-05)
A comprehension desugars to a lambda under the hood — [count[i] * 2 for i in 0..3] lowers to a closure that reads count as a free variable captured from the enclosing scope. NOVA registers roughly 1,328 builtins, and a lot of them are ordinary nouns a developer would naturally reach for as a local variable name: items, data, count, value, key, text, index, total, buffer, fields, line, input, output, args, chars. Until a fix landed on 2026-08-05, a same-named local lost that naming tie inside a comprehension or lambda capture: the closure body resolved the free variable to the BUILTIN instead of your local, and the comprehension silently produced the builtin's own return shape — frequently a row of zeros — instead of your real data. No crash, no type error: the affected parameter is almost always inferred as any, so the program compiled clean and just computed the wrong answer.
count = [10, 20, 30] // "count" also names a registered builtin doubled = [count[i] * 2 for i in 0..3] print(doubled)
DO: Pick ordinary, descriptive names for comprehension source lists freely — items, data, count, values all now correctly resolve to your local variable inside a comprehension or lambda, even though NOVA also ships a builtin under the same name; a local always shadows a same-named builtin today. DON'T: assume this guarantee if you cannot confirm which compiler build you're running. If a comprehension or a fn(...) lambda silently returns the wrong SHAPE of data — all zeros, all empty strings, all nulls — with no error at all, suspect a name collision with a builtin first. Renaming the local is a one-line fix and the fastest way to confirm the diagnosis.
Dict comprehensions
Dict comprehensions create a new dict from a sequence:
// Basic form: {key_expr: value_expr for var in list} nums = [1, 2, 3, 4, 5] squares_map = {x: x * x for x in nums} print(squares_map) // {1: 1, 2: 4, 3: 9, 4: 16, 5: 25} print(squares_map[3]) // 9 // Invert a dict (swap keys and values) original = {"a": 1, "b": 2, "c": 3} inverted = {v: k for k, v in original} print(inverted) // {1: "a", 2: "b", 3: "c"} // Build a frequency map from a list words = ["apple", "banana", "apple", "cherry", "banana", "apple"] freq = {} for w in words freq[w] = unwrap_or(freq[w], 0) + 1 print(freq) // {apple: 3, banana: 2, cherry: 1}
Set comprehensions
Set comprehensions build a set the same way list comprehensions build a list: {EXPR for VAR in ITER (if COND)?}. The result auto-deduplicates just like the {1, 2, 3} set literal — reach for a set comprehension whenever the transformation might produce repeated values and you only care about the distinct results.
words = ["the", "quick", "brown", "fox", "the", "lazy", "fox"] // basic form: {expression for var in iterable} lengths = {len(w) for w in words} print(set_len(lengths)) // 3 — distinct lengths are 3, 5, 4 (duplicates collapse) // with a filter: {expression for var in iterable if condition} short_unique = {w for w in words if len(w) <= 3} print(set_len(short_unique)) // 2 — "the" and "fox" (each counted once despite appearing twice)
2
Line-by-line:
{len(w) for w in words}— reads like a list comprehension, but the{...}delimiter combined with the first element having no:tells the parser this is a set, not a dict.wordshas 7 entries but only 3 distinct lengths (3, 5, 4) —set_len(lengths)is 3, not 7, because the comprehension deduplicates as it builds, exactly like callingset_addwith a value that's already present.{w for w in words if len(w) <= 3}—"the"and"fox"both appear twice in the source list but each contributes only once to the result set.
DO: Use a set comprehension when you need the DISTINCT results of a transformation, not every result — {email_domain(u) for u in users} to list which domains appear at all.
DON'T: Write {} expecting an empty set as a starting point for manual adds — bare {} always parses as an empty DICT. Start from set_from_list([]) if you need an empty set to build up by hand.
Nested structures — lists of structs
The most common real-world data structure: a list of structured records. This is what you get from a database, an API, or a CSV file:
type Product name: string price: float in_stock: bool products = [ Product { name: "Laptop", price: 999.0, in_stock: true }, Product { name: "Phone", price: 699.0, in_stock: true }, Product { name: "Tablet", price: 499.0, in_stock: false }, Product { name: "Monitor", price: 399.0, in_stock: true }, ] // Filter to in-stock products only available = [p for p in products if p.in_stock] print(len(available)) // 3 // Get just the names names = [p.name for p in products] print(names) // [Laptop, Phone, Tablet, Monitor] // Total value of in-stock inventory total = reduce(filter(products, p => p.in_stock), 0.0, (acc, p) => acc + p.price) print("in-stock total: {total}") // 2097.0 // Sort by price ascending sorted_by_price = sort_by(products, p => p.price) for p in sorted_by_price print("{p.name}: ${p.price}") // Monitor: $399.0 // Tablet: $499.0 // Phone: $699.0 // Laptop: $999.0
Line-by-line breakdown (sort_by):
sort_by(products, p => p.price)—sort_bytakes a list and a key function. The key function receives each element and returns the value to sort by — here the key isp.price, a float, sosort_bysorts ascending numerically by price.sort_bydoesn't know or care what the key function computes, only how to compare the keys it returns.- For descending order, negate the key:
p => -p.price.
Choosing the right collection type
NOVA has four built-in collection types. Picking the right one up front avoids costly refactors later and communicates intent clearly to the reader.
| Collection | Ordered? | Duplicates? | Key lookup? | Best for |
|---|---|---|---|---|
[1, 2, 3] list | Yes | Yes | By index O(1) | Sequences, stacks, queues, ordered results |
{"a": 1} dict | No (insertion order preserved) | Keys unique | By key O(1) avg | Lookup tables, JSON objects, caches, config |
{1, 2, 3} set | No | No | Membership O(1) avg | Unique elements, deduplication, fast membership tests |
"hello" string | Yes | Yes | By index O(1) | Text, immutable byte sequences |
// USE A LIST when order matters or you need indexed access log_lines = ["started", "processing", "done"] // ordered, duplicates OK // USE A DICT when you need to look up by a key user = {"name": "alice", "role": "admin"} // lookup by name O(1) // USE A SET when uniqueness is the point seen_ips = {"1.2.3.4", "5.6.7.8"} // auto-deduplicates if ip in seen_ips print("already seen") set_add(seen_ips, ip) // WRONG: using a list to test membership → O(n) every call // if ip in visited_list ← searches whole list every time // CORRECT: use a set → O(1)
- Appending to a list is O(1) amortised. Inserting at the front is O(n) — avoid it for large lists.
- Dict and set lookup is O(1) average but degrades to O(n) at very high load factors. NOVA's runtime keeps them below 75% load; you don't manage this manually.
- Testing
x in listscans the whole list — O(n). If you are checking membership repeatedly, convert to a set first:lookup = set(my_list). - For building large strings piece by piece, use a list of parts and
join("", parts)at the end. Repeated string concatenation with+copies the whole string each time — O(n²) total. The join pattern is O(n).
Spread operator (...)
The spread operator ... expands a list, dict, or struct in place. On lists it concatenates; on dicts it merges (later keys win when there's a collision); on structs it produces an immutable record update — a full copy with only the named fields changed.
let a = [1, 2, 3] let b = [4, 5, 6] let combined = [...a, ...b] // [1, 2, 3, 4, 5, 6] let base = {"color": "red", "size": 10} let overrides = {...base, "color": "blue"} // color overridden let p = Point(3, 4) let p2 = Point { ...p, x: 10 } // p2.y still equals p.y
When to use this: building a new list/dict from pieces without manual push/index loops, applying config overrides on top of defaults, and updating one or two fields of an immutable struct without hand-copying every other field.
Compare: JavaScript popularized this exact three-dot syntax for arrays and objects. NOVA extends the same syntax to structs, where it plays the role of Rust's Point { x: 10, ..p } functional update — same idea, spread comes first instead of last.
in / not in — membership testing
in tests membership and reads as English; not in is its negation. The same operator works across all three core collection types, with the check meaning whatever is natural for that type: element presence for lists, key presence for dicts, substring presence for strings.
assert(3 in [1, 2, 3, 4]) // list membership assert("name" in {"name": "Alice"}) // dict key check assert("hello" in "hello world") // substring test assert(6 not in [1, 2, 3]) // negated
Compare: Python has the identical in/not in operator across the same three collection kinds. Go and Java have no membership operator at all — you write a loop or reach for a library function (strings.Contains, Collection.contains). NOVA matches Python's ergonomics here.
DO: Prefer key in dict over checking whether a lookup returned some sentinel value — it is both clearer and avoids a whole class of "did I get the real value or the not-found marker" bugs.
Builtin data structures
Beyond list and dict, NOVA ships specialized collection types as builtins — no import, no external package, and each with a KAT-verified implementation. Reach for these instead of hand-rolling the same structure on top of a list or dict.
Priority Queue (min-heap)
A binary min-heap keyed by an explicit priority value — the lowest priority always comes out first. Use it for task scheduling, Dijkstra's algorithm, or any "process the most urgent thing next" problem.
let pq = pq_create() pq_push(pq, 5, "wash dishes") pq_push(pq, 1, "put out fire") pq_push(pq, 3, "reply to email") print(pq_peek(pq)) // "put out fire" (priority 1, lowest) print(pq_pop(pq)) // "put out fire" — removed and returned print(pq_len(pq)) // 2 print(pq_is_empty(pq)) // false
pq_peek(pq) returns the value waiting at the front of the queue; pq_peek_priority(pq) returns its priority number instead, without touching the queue. This matters when the priority itself is what you need to make a decision on — e.g. "is the next item urgent enough to interrupt what I'm doing right now?" — and you don't want to pop (and possibly have to push back) just to find out.
let pq = pq_create() pq_push(pq, 5, "wash dishes") pq_push(pq, 1, "put out fire") if pq_peek_priority(pq) <= 2 print("urgent: {pq_pop(pq)}") else print("nothing urgent right now")
DO: use pq_peek_priority(pq) to make a routing decision ("only preempt current work if priority <= 2") without paying for a pop-then-maybe-push-back round trip. DON'T: confuse it with pq_peek(pq) — that returns the value at the front; pq_peek_priority returns the number that put it there.
Deque (double-ended queue)
Push and pop from either end in O(1). Use it as a stack, a queue, a sliding window, or anywhere you need fast insertion/removal at both ends — something a plain list cannot do efficiently at the front.
let d = deque_create() deque_push_back(d, 1) deque_push_back(d, 2) deque_push_front(d, 0) print(deque_to_list(d)) // [0, 1, 2] print(deque_pop_front(d)) // 0 print(deque_pop_back(d)) // 2 print(deque_len(d)) // 1
Push/pop drive the double-ended usage pattern, but you often need to look without mutating: deque_front(d) and deque_back(d) peek at either end without removing anything, deque_get(d, i) indexes into the deque like a list (0-based, counted from the front), and deque_is_empty(d) is the O(1) guard to check before popping from a deque that might already be drained.
let d = deque_create() deque_push_back(d, "b") deque_push_back(d, "c") deque_push_front(d, "a") print(deque_is_empty(d)) // false print(deque_front(d)) // "a" — peek only, nothing removed print(deque_back(d)) // "c" — peek only, nothing removed print(deque_get(d, 1)) // "b" — index 1, counted from the front print(deque_len(d)) // 3 — still 3, none of the above popped anything
a
c
b
3
DO: call deque_is_empty(d) before deque_pop_front/deque_pop_back inside a drain-until-empty loop. DON'T: call deque_pop_front(d) just to look at the next item — that removes it; use deque_front(d) for a non-destructive peek.
LRU Cache
A fixed-capacity cache that evicts the least-recently-used entry when full. Use it for memoizing expensive computations, caching database query results, or any bounded-memory cache where you want automatic eviction instead of manual bookkeeping.
let cache = lru_create(2) // capacity: 2 entries lru_put(cache, "a", 1) lru_put(cache, "b", 2) lru_put(cache, "c", 3) // evicts "a" (least recently used) print(lru_has(cache, "a")) // false — evicted print(lru_get(cache, "b")) // 2 print(lru_len(cache)) // 2 print(lru_hits(cache)) // 1 print(lru_misses(cache)) // 0
lru_len(c) reports how many entries are currently cached; lru_cap(c) reports the ceiling the cache was created with. Query the capacity instead of hardcoding it a second time at every call site that logs or monitors cache pressure — the two values then stay in sync automatically even if the capacity is configured far away from where you report on it.
let cache = lru_create(100) lru_put(cache, "user:1", "Alice") lru_put(cache, "user:2", "Bob") print("{lru_len(cache)}/{lru_cap(cache)} slots used")
DO: read the capacity back with lru_cap(c) in monitoring/log lines instead of hardcoding the number you passed to lru_create. DON'T: hardcode the capacity in a second place — if the cache is ever resized, a hardcoded number silently goes stale while lru_cap(c) always reflects reality.
Counter
A frequency map with helpers built in — no manual "does the key exist yet, if not initialize to 0" dance. Use it for word-frequency counts, histogram building, or tallying any set of discrete events.
let c = counter_create() for word in ["the", "cat", "the", "dog", "the"] counter_inc(c, word) print(counter_get(c, "the")) // 3 print(counter_total(c)) // 5 (total events counted) print(counter_most_common(c, 2)) // [("the", 3), ("cat", 1)] or ("dog",1) — top 2
counter_inc(c, key) always adds exactly 1. counter_add(c, key, n) adds an arbitrary n in a single call — reach for it when you already have an aggregated quantity (merging two counters, importing a CSV column that stores counts rather than individual events) instead of looping counter_inc n times.
let c = counter_create() counter_inc(c, "clicks") // clicks: 1 counter_add(c, "clicks", 47) // bulk add — clicks: 48 counter_add(c, "views", 1200) // seed straight from an existing aggregate print(counter_get(c, "clicks")) // 48 print(counter_get(c, "views")) // 1200 print(counter_total(c)) // 1249
1200
1249
DO: use counter_add(c, key, n) when merging pre-aggregated counts (a log rotation summary, another counter's totals). DON'T: loop counter_inc n times to add n — that's n calls doing what counter_add does in one.
Ring Buffer
A fixed-capacity circular buffer — once full, pushing overwrites (or is rejected, depending on use) the oldest entry rather than growing. Use it for streaming data windows, fixed-size log tails, or audio/sensor sample buffers where memory must stay bounded.
let rb = ringbuf_create(3) // capacity: 3 ringbuf_push(rb, 1) ringbuf_push(rb, 2) ringbuf_push(rb, 3) print(ringbuf_is_full(rb)) // true print(ringbuf_pop(rb)) // 1 (oldest) print(ringbuf_len(rb)) // 2
ringbuf_len(rb) tracks how many entries are currently held; ringbuf_cap(rb) reports the fixed capacity the ring buffer was created with. Combine the two to compute fill level for monitoring — e.g. deciding a streaming window is close enough to full that a consumer needs to catch up.
let rb = ringbuf_create(5) ringbuf_push(rb, 1) ringbuf_push(rb, 2) print("{ringbuf_len(rb)} of {ringbuf_cap(rb)} slots filled") print(ringbuf_is_full(rb))
false
DO: compute fill percentage from ringbuf_len(rb) and ringbuf_cap(rb) for monitoring/alerting rather than hardcoding the capacity you passed to ringbuf_create a second time. DON'T: confuse ringbuf_cap (the fixed ceiling, set once at creation) with ringbuf_len (the live count, which changes on every push/pop) — they answer different questions.
Sorted Map
A dict that keeps its keys in sorted order and supports range queries. Use it whenever you need ordered iteration by key, or "give me everything between X and Y" — something a hash-based dict cannot do without sorting on every call. Keys are always string — smap_set/smap_get/smap_has/smap_del/smap_range are all typed to take a string key, so an integer (or any non-string) key is a compile-time type error.
let m = smap_create() smap_set(m, "30", "thirty") smap_set(m, "10", "ten") smap_set(m, "20", "twenty") print(smap_keys(m)) // ["10", "20", "30"] — sorted lexicographically, not insertion order print(smap_get(m, "20")) // "twenty" print(smap_range(m, "15", "30")) // entries with "15" <= key <= "30": twenty, thirty print(smap_has(m, "99")) // false
smap_values(m) returns just the values, in the same sorted-by-key order as smap_keys(m) — the two lists are index-aligned, so you can walk both together without re-deriving the order. smap_del(m, key) removes an entry and keeps the sorted structure balanced; it's the only way to shrink a sorted map short of recreating it from scratch.
let m = smap_create() smap_set(m, "banana", 2) smap_set(m, "apple", 5) smap_set(m, "cherry", 1) print(smap_values(m)) // [5, 2, 1] — apple, banana, cherry order smap_del(m, "banana") print(smap_has(m, "banana")) // false — removed print(smap_keys(m)) // ["apple", "cherry"] print(smap_len(m)) // 2
false
["apple", "cherry"]
2
DO: pair smap_keys(m) with smap_values(m) when you need both in sorted order — they're guaranteed index-aligned, so smap_keys(m)[i] and smap_values(m)[i] always describe the same entry. DON'T: pass a bare integer as a sorted-map key — every smap_* accessor requires a string key; convert first with str() if the natural key is numeric (e.g. smap_set(m, str(user_id), record)).
Arena Allocator
An arena is a bump allocator: instead of tracking and freeing each allocation individually, it hands out memory by advancing a pointer, and frees an entire region in a single O(1) operation. This is the exact mechanism Forge uses internally to give every HTTP request zero-GC-pause memory management — NOVA exposes the same primitive directly, so your own code can get the identical win for any batch-shaped workload: parse a request, process a chunk, run one simulation step, then throw away every intermediate allocation at once instead of paying per-object cleanup cost.
NOVA gives you two ways to use it. The thread-local bracket form is the one to reach for almost always — wrap a region of code between arena_enter() and arena_exit(), and every allocation made in between is freed together the instant you call arena_exit:
let mark = arena_enter() let processed = [transform(x) for x in batch] // allocations use the bump allocator let summary = summarize(processed) arena_exit(mark) // every allocation since arena_enter() is freed here, in one step print(summary)
Two lower-level functions round out the bracket form: set_arena_mode(flag) forces arena-mode allocation on or off for the current thread outside of an explicit bracket, and is_arena_mode() lets library code check whether it is currently running inside one — useful when a function is sometimes called inside a request handler's arena and sometimes standalone, and needs to decide whether returning a value that outlives the call is safe.
The explicit arena handle form trades the bracket's implicit scoping for a named, independently-controlled arena — useful when you need more than one arena alive at once, or when the region doesn't map cleanly onto a single block of code:
let a = arena_create() let block = arena_alloc(a, 1024) print(arena_used(a)) // 1024 — bytes consumed so far arena_reset(a) // rewind to zero; memory is kept and reused, not returned to the OS print(arena_used(a)) // 0 arena_free(a) // release the entire arena back to the OS
0
Use arena_reset when you'll immediately refill the same arena on the next iteration of a loop (it skips the OS free/allocate round trip); use arena_free when you're done with the arena for good.
DO: balance every arena_enter() with exactly one arena_exit(mark) — a missing exit leaks everything allocated since that enter. Nesting is safe as long as exits happen in the reverse order of the enters (stack discipline), the same rule as balanced brackets in any language. DON'T: let a value allocated inside an arena_enter()/arena_exit() bracket escape past the arena_exit call — anything you need after the bracket closes must be copied out first. This is precisely the bug class behind Forge's own "arena object not found in heap" error: a request-scoped value that outlived the request's arena.
| Data structure | Use it when... |
|---|---|
| Priority Queue | You always need the "most urgent" item next — schedulers, Dijkstra, event simulation |
| Deque | You need fast push/pop at BOTH ends — stacks, queues, sliding windows |
| LRU Cache | You need a bounded cache that evicts automatically — memoization, query result caching |
| Counter | You're tallying frequency of discrete keys — word counts, histograms, analytics |
| Ring Buffer | You need a fixed-memory rolling window — streaming samples, log tails |
| Sorted Map | You need keys in order, or range queries — leaderboards, time-series slices |
| Arena Allocator (bracket) | You want Forge's zero-GC-pause trick in your own code — batch processing, per-iteration scratch memory |
| Arena Allocator (handle) | You need more than one arena alive at once, or manual control over reset/free timing |
DO: Reach for these before building the same behavior on top of list/dict by hand — each one is KAT-verified against a known-answer test, so the edge cases (empty pop, eviction ties, wraparound) are already handled correctly.
11. Iterators and generators
What is this? Iterators are lazy sequences. Unlike lists, which compute and store all elements immediately, iterators produce elements one at a time on demand. This makes them memory-efficient for large or infinite sequences — you can process a billion items using O(1) memory instead of O(n).
Creating iterators
// From a list nums = [10, 20, 30, 40, 50] it = iter(nums) result = iter_collect(it) // collect all into a list print(result) // [10, 20, 30, 40, 50] // From a range (1 through 5 inclusive) it = iter_range(1, 6) // produces 1, 2, 3, 4, 5 (end is exclusive) result = iter_collect(it) print(result) // [1, 2, 3, 4, 5] // With step it = iter_range_step(0, 20, 5) // 0, 5, 10, 15 result = iter_collect(it) print(result) // [0, 5, 10, 15]
[1, 2, 3, 4, 5]
[0, 5, 10, 15]
Eager stepped range — range_step
iter_range_step above is lazy — it produces one element at a time as you pull from it. range_step(start, stop, step) is the eager version: it allocates the entire stepped sequence as a real list<int> immediately and hands it back, so you can index it, pass it to any list function, or store it directly — no iter_collect needed.
let evens = range_step(0, 10, 2) print(evens) // [0, 2, 4, 6, 8] let countdown = range_step(10, 0, -1) print(countdown) // [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Both bounds follow the same exclusive-of-stop rule as every other range in NOVA (Chapter 4): with a positive step it counts up while strictly less than stop; with a negative step it counts down while strictly greater than stop. That is why range_step(10, 0, -1) stops at 1, never reaching 0.
DO: Use range_step when you need the whole stepped sequence as a real list right away — passed to map/filter, stored in a struct, or returned from a function. DON'T: Use range_step over a huge range just to throw most of it away with iter_take afterward — that allocates the full list first. Use the lazy iter_range_step instead, so nothing beyond what you consume is ever computed.
Transforming iterators — map, filter, take, skip
These operations are lazy — they do not compute anything until you collect:
// iter_map: transform each element it = iter_map(iter_range(1, 6), x => x * x) print(iter_collect(it)) // [1, 4, 9, 16, 25] // iter_filter: keep matching elements it = iter_filter(iter_range(1, 11), x => x % 2 == 0) print(iter_collect(it)) // [2, 4, 6, 8, 10] // iter_take: take first N elements — efficient for large/infinite sequences it = iter_take(iter_range(0, 1000000), 5) print(iter_collect(it)) // [0, 1, 2, 3, 4] — only computed 5, not a million // iter_skip: skip first N elements it = iter_skip(iter_range(0, 10), 7) print(iter_collect(it)) // [7, 8, 9] // iter_zip: pair elements from two iterators a = iter(["a", "b", "c"]) b = iter_range(1, 4) pairs = iter_collect(iter_zip(a, b)) print(pairs) // [["a", 1], ["b", 2], ["c", 3]]
[2, 4, 6, 8, 10]
[0, 1, 2, 3, 4]
[7, 8, 9]
[["a", 1], ["b", 2], ["c", 3]]
DO: reach for iter_skip(it, n) when you want to skip the first n elements of a lazy pipeline. DON'T: go looking for iter_drop in the builtin table — it doesn't exist. The naming follows the phrase "skip N elements, then yield the rest," not the drop/skip split some other iterator libraries use.
More transforms — chain, enumerate, flat_map
Three more transforms round out the lazy toolkit. iter_chain(a, b) stitches two iterators end-to-end into a single lazy sequence — once a runs dry, pulling continues from b, without ever building the concatenated list up front. iter_enumerate(it) is the lazy counterpart to the eager enumerate(list) builtin from Chapter 2: it also produces [index, value] pairs, but pulls them one at a time as the surrounding pipeline is consumed, so it composes into a larger pipeline instead of materializing a whole list of pairs before the rest of the pipeline even starts. iter_flat_map(it, f) applies f to each element — where f returns another iterator — and flattens every one of those inner iterators into a single continuous stream, so you never need a separate flatten pass after mapping.
// iter_chain: two iterators back-to-back, lazily morning = iter(["breakfast", "coffee"]) evening = iter(["dinner", "tv"]) day = iter_chain(morning, evening) print(iter_collect(day)) // ["breakfast", "coffee", "dinner", "tv"] // iter_enumerate: lazy [index, value] pairs — composes into a bigger pipeline letters = iter(["a", "b", "c"]) tagged = iter_enumerate(letters) print(iter_collect(tagged)) // [[0, "a"], [1, "b"], [2, "c"]] // iter_flat_map: map each element to an inner iterator, then flatten them all groups = iter([[1, 2], [3, 4], [5]]) flat = iter_flat_map(groups, xs => iter(xs)) print(iter_collect(flat)) // [1, 2, 3, 4, 5]
[[0, "a"], [1, "b"], [2, "c"]]
[1, 2, 3, 4, 5]
DO: use iter_enumerate when you need indices threaded through the rest of a lazy pipeline (e.g. iter_enumerate(it) |> iter_filter(...)). DON'T: reach for the eager enumerate(list) builtin (Chapter 2) mid-pipeline — it takes and returns a materialized list, not an iterator handle, so mixing it in breaks the laziness and, per the mixing gotcha later in this chapter, may not even do what you expect.
Chaining operations — the real power of iterators
it = iter_range(1, 21) it = iter_filter(it, x => x % 3 == 0) // keep multiples of 3 it = iter_map(it, x => x * 10) // multiply each by 10 result = iter_collect(it) print(result) // [30, 60, 90, 120, 150, 180]
Line-by-line:
iter_range(1, 21)— produces numbers 1 through 20 lazily. No list created yet.iter_filter(it, fn(x) x % 3 == 0)— wraps the range. When an element is requested, the filter pulls from the range, checks divisibility by 3, and passes through only 3, 6, 9, 12, 15, 18. Others are silently skipped.iter_map(it, fn(x) x * 10)— wraps the filter. When an element is requested, it pulls from the filter and multiplies by 10.iter_collect(it)— pulls all elements through the entire pipeline. THIS is where computation happens. Each number flows through the entire chain one at a time: 1 (fails filter), 2 (fails filter), 3 (passes filter → mapped to 30), 4 (fails filter), ..., 18 (passes filter → mapped to 180).
Why this is better than lists: Without iterators, you'd need 3 lists: 20 elements for the range, 6 for the filter result, 6 for the map result. With iterators: each element flows through the pipeline one at a time — zero intermediate lists, zero wasted memory. For millions of items, this is the difference between "fits in memory" and "out of memory".
Reducing iterators — computing a single result
// Sum 1 to 100 without allocating a 100-element list total = iter_sum(iter_range(1, 101)) print(total) // 5050 (the Gauss sum) // Count numbers 1–100 divisible by 7 n = iter_count(iter_filter(iter_range(1, 101), x => x % 7 == 0)) print(n) // 14 (7,14,21,...,98) // Short-circuit: stops as soon as the first match is found has_big = iter_any(iter(nums), x => x > 100) // Short-circuit: stops as soon as the first non-match is found all_pos = iter_all(iter(nums), x => x > 0)
Generators via yield — the real single-pass stream primitive
Everything above in this chapter — iter_map, iter_filter, iter_take, and the rest — composes lazily over a value that's already an iterator. Fibers (below) give you full manual suspend/resume control, but you have to build the state machine and value-passing yourself. NOVA has a third option that sits between the two: any function whose body contains a yield expression is automatically compiled into a resumable coroutine — a real generator, with none of the fiber_create/fiber_resume boilerplate. This is NOVA's actual single-pass stream primitive: unlike map, filter, comprehensions, and the .. range expression — all of which are eager and fully materialize their result — a yield-based generator produces exactly one value at a time, suspending its own execution until the next value is asked for.
fn counter() -> int i = 0 while i < 5 yield i * i i = i + 1 return 0 // Lazy consumption: for..in pulls one value at a time, suspending counter() // between iterations instead of running it to completion up front collected = [] for v in counter() push(collected, v) print(collected) // [0, 1, 4, 9, 16] — 5 values, last one is 16 // Eager consumption: drain every value into a list right away g = gen_collect(counter()) print(g[2]) // 4 // Manual, one-value-at-a-time consumption g2 = counter() gen_next(g2) // advance to the first yielded value print(gen_value(g2)) // 0 gen_next(g2) // advance to the next yielded value print(gen_value(g2)) // 1
4
0
1
DO: Reach for a yield-based generator instead of a fiber when you're producing a plain sequence of values one at a time — it's the same suspend/resume machinery underneath (real fibers), but the compiler builds the coroutine state machine for you from an ordinary function body, instead of you wiring up fiber_create/fiber_resume by hand. DON'T: Expect a generator to stay suspended when you hand it to something eager — passing counter() through map/filter/a comprehension drains it completely first, since those are all eager. If you want laziness to survive through a whole pipeline, build the pipeline out of the iter_* combinators above instead.
Pulling one at a time, searching, and folding — iter_next, iter_find, iter_for_each, iter_reduce
Every consumer above is built on top of one lower-level primitive: iter_next(it) pulls exactly one value from the pipeline and returns it wrapped in an Option — some(value) while there's more to give, none() once the pipeline is exhausted. Reach for it directly when you need manual control that a fixed consumer can't give you — interleaving pulls from two different pipelines, stopping based on state that isn't expressible as a single predicate, or writing your own consumer that isn't in this list. iter_find(it, pred) is the value-returning sibling of iter_any: instead of a plain true/false, it short-circuits on the first element that satisfies pred and hands back that element itself (again as an Option — none() if nothing matched). iter_for_each(it, f) pulls every element through the pipeline purely for f's side effect — printing, appending to a buffer, incrementing a counter — chaining straight onto a pipeline instead of forcing an iter_collect first. iter_reduce(it, init, f) is the general-purpose fold that iter_sum and iter_count are just specialized cases of: starting from init, it combines the running accumulator with each element via f(acc, x) and returns the final accumulator.
// iter_next: pull elements one at a time, by hand it = iter([10, 20]) a = iter_next(it) print(unwrap(a)) // 10 — first pull b = iter_next(it) print(unwrap(b)) // 20 — second pull c = iter_next(it) print(is_none(c)) // true — the iterator is exhausted // iter_find: short-circuits, hands back the match itself (as an Option) nums = [3, 7, 12, 18, 25] found = iter_find(iter(nums), x => x % 2 == 0) print(unwrap(found)) // 12 — first even number; search stopped right there // iter_for_each: side-effecting consumption, chained straight onto a pipeline iter_for_each(iter_filter(iter(nums), x => x > 10), x => print(x)) // iter_reduce: general fold — sum/count are just special cases of this product = iter_reduce(iter([1, 2, 3, 4]), 1, (acc, x) => acc * x) print(product) // 24
20
true
12
12
18
25
24
Why iter_next matters even though you'll rarely call it directly: every other consumer in this chapter — iter_collect, iter_sum, iter_count, iter_any, iter_all, iter_find, iter_for_each, iter_reduce — is just a loop around iter_next that stops on none() and does something different with each some(value) along the way. Understanding iter_next is understanding the whole protocol; the rest of the family is convenience built on top of it.
Fibers — cooperative coroutines
Fibers are lightweight coroutines that can suspend and resume execution. Use them for state machines and generators:
fn counter() i = 0 while i < 5 fiber_yield() // suspend here, return control to caller i = i + 1 f = fiber_create(fn(z) counter()) while fiber_is_done(f) == 0 fiber_resume(f) print("done")
Fiber vs spawn: spawn creates a concurrent task that runs independently on the scheduler. fiber_create creates a cooperative coroutine that only runs when you explicitly call fiber_resume. Use spawn for concurrent work. Use fibers when you need manual control over execution — state machines, generators, cooperative scheduling. Context switching between fibers: under 5 microseconds per switch.
Iterator functions reference:
| Function | What it does |
|---|---|
iter_range(start, end) | Integers from start to end (exclusive) |
iter_range_step(s, e, step) | Range with custom step |
range_step(start, stop, step) | Eager version — allocates the full list immediately, no iter_collect needed |
iter(list) | Iterator over a list |
iter_map(it, f) | Transform each element |
iter_filter(it, pred) | Keep elements satisfying pred |
iter_take(it, n) | Take first n elements |
iter_skip(it, n) | Skip first n elements |
iter_collect(it) | Materialize to list (triggers computation) |
iter_zip(a, b) | Pair elements from two iterators |
iter_sum(it) | Sum all elements |
iter_count(it) | Count elements |
iter_any(it, pred) | True if any element satisfies pred (short-circuits) |
iter_all(it, pred) | True if all elements satisfy pred (short-circuits) |
iter_next(it) | Pull one element as an Option — some(value) or none() (every other consumer loops over this) |
iter_chain(a, b) | Concatenate two iterators lazily |
iter_enumerate(it) | Lazy [index, value] pairs |
iter_flat_map(it, f) | Map each element to an inner iterator, then flatten them all |
iter_reduce(it, init, f) | General fold: combine init with each element via f(acc, x) |
iter_find(it, pred) | First element satisfying pred, as an Option (short-circuits) |
iter_for_each(it, f) | Call f for each element's side effect |
Real-world example: processing a large dataset lazily
Imagine you have a log file with 50 million lines. Loading all 50M lines into a list would use gigabytes of RAM. With iterators, you process one line at a time — constant memory regardless of file size:
// Scenario: 50M line access.log, count 500-class errors, show first 10 content = read_file("access.log") lines = iter(split(content, "\n")) errors = iter_filter(lines, s => s matches "\" 5\\d\\d ") // First 10 errors — iter_take stops pulling after 10, no matter how big the log is sample = iter_collect(iter_take(errors, 10)) for line in sample print(line)
Why this matters: Without iter_take, the program would read all 50 million lines looking for errors, even if the first 10 errors all appear in the first 10,000 lines. iter_take(errors, 10) stops pulling from the file stream as soon as 10 matches are found — it processes exactly as much data as needed, no more.
Generating Fibonacci numbers with a fiber
An infinite Fibonacci sequence — an iterator that never terminates, where you take as many elements as you need:
fn fib_generator() a = 0 b = 1 loop fiber_yield() // suspend, return a to caller next = a + b a = b b = next // Get first 10 Fibonacci numbers f = fiber_create(fn(z) fib_generator()) results = [] i = 0 while i < 10 fiber_resume(f) push(results, i) // in practice, yield sends value back via channel i = i + 1
The fiber suspends at fiber_yield() after each iteration. The caller resumes it with fiber_resume(f) when it needs the next value. This is how Python's yield generators work — NOVA fibers are the same concept, made explicit with fiber_create and fiber_resume.
Iterator comparison: NOVA vs Python vs JavaScript
| Concept | NOVA | Python | JavaScript |
|---|---|---|---|
| Create from list | iter(xs) | iter(xs) | xs[Symbol.iterator]() |
| Transform elements | iter_map(it, f) | map(f, it) | it.map(f) (array) |
| Filter elements | iter_filter(it, pred) | filter(pred, it) | it.filter(pred) (array) |
| Take first N | iter_take(it, n) | itertools.islice(it, n) | No built-in |
| Collect to list | iter_collect(it) | list(it) | [...it] |
| Coroutine/generator | fiber_create + fiber_yield | def gen(): yield x | function* gen() { yield x; } |
| Infinite sequences | Fiber + loop | itertools.count() | Generator function |
Key difference from Python: Python's map() and filter() return iterators. NOVA separates the list-based versions (map(list, fn)) from the iterator-based versions (iter_map(it, fn)). This makes the lazy vs. eager distinction explicit in the code — no surprises.
DO: keep the two families mentally separate — every iter_* function takes and returns an iterator handle; every eager builtin (map, filter, reduce, and friends from Chapter 2) takes and returns a real list. DON'T: hand an iterator handle to an eager builtin, e.g. map(iter(nums), f) — it will not iterate the handle for you. An iterator handle is not a list, so the eager builtin silently treats it as a single opaque element (a one-element "list" containing the handle), and you get one wrong result back instead of an error telling you what went wrong. If you're partway through a lazy pipeline and need an eager function, call iter_collect(it) first to materialize a real list, then hand that list to map/filter/reduce.
12. File I/O (Input/Output) and paths
What is this? File I/O is how programs read data from files on disk and write data back. Every program that saves settings, reads configuration, processes data files, or writes logs needs File I/O. NOVA provides simple one-call functions for small files and streaming functions for large files too big to load into memory.
Reading and writing files
write_file("hello.txt", "Hello, NOVA!\nSecond line.\n") content = read_file("hello.txt") print(content) // Hello, NOVA! // Second line. append_file("log.txt", "Event happened at {time_ms()}\n") if file_exists("hello.txt") print("file exists")
Second line.
file exists
Line-by-line:
write_file("hello.txt", "...")— Creates or overwriteshello.txtwith the given string. Warning: if the file already exists, all previous contents are permanently lost. The\nis a newline character.read_file("hello.txt")— Reads the entire file into a single string. If the file doesn't exist, returns an error. For large files (gigabytes), use line-by-line reading (below).append_file("log.txt", "...")— Adds text to the END of the file without erasing existing contents. If the file doesn't exist, it creates it. Perfect for log files.file_exists("hello.txt")— Returnstrueif the file exists,falseotherwise. Always check before reading if the file might not exist.
Compare to Python: Python needs with open("file", "r") as f: content = f.read(). NOVA: read_file("file"). No context managers, no with blocks, no mode strings. The simple one-call form is the default.
File metadata
print(file_size("hello.txt")) // size in bytes (e.g., 27) print(file_mtime("hello.txt")) // last modified time (Unix timestamp) print(is_file("hello.txt")) // true (it's a regular file) print(is_dir("hello.txt")) // false (it's not a directory)
1751289600
true
false
Reading line by line — for large files
For large files (megabytes or gigabytes), reading the entire file with read_file would use too much memory. Read one line at a time instead — O(1) memory regardless of file size:
f = file_open("large.txt") loop line = file_read_line(f) if file_eof(f) break print(trim(line)) // trim removes the trailing \n file_close(f)
Line-by-line:
file_open("large.txt")— Opens the file and returns a file handle. Unlikeread_file, this does NOT read the entire file into memory.file_read_line(f)— Reads the next line from the file. Each call advances the position.file_eof(f)— Returnstruewhen there are no more lines (End Of File).trim(line)— Removes the trailing\ncharacter that each line includes.file_close(f)— Closes the file handle. Always close files when done — open files consume OS resources.
// Alternative: read all lines at once (for smaller files) lines = split(read_file("data.txt"), "\n") for line in lines if len(trim(line)) > 0 process(line)
Directory operations
// Create directories mkdir("mydir") mkdir_p("path/to/nested/dir") // like mkdir -p: creates all parent dirs // List directory contents files = list_dir(".") for f in files print(f) // Walk directory recursively — finds files in subdirectories too fn find_nova_files(dir) for entry in dir_walk(dir) if ends_with(entry, ".nova") print("Found: {entry}") find_nova_files(".") // Remove remove_file("hello.txt") remove_dir("mydir") // must be empty copy_file("source.txt", "dest.txt") rename_path("old.txt", "new.txt")
data.csv
output.txt
readme.md
src
Path operations
// Join path components (uses correct separator for current OS) // path_join takes exactly TWO components — nest calls to join 3+ p = path_join(path_join("src", "main"), "app.nova") print(p) // src/main/app.nova (or src\main\app.nova on Windows) // Extract parts of a path print(path_name("src/main/app.nova")) // app.nova print(path_parent("src/main/app.nova")) // src/main print(path_ext("src/main/app.nova")) // .nova // Current directory and PATH lookup print(cwd()) print(which("git")) // /usr/bin/git (or wherever it is)
DO: Use read_file()/write_file() for small files. Use file_open()/file_read_line() for large files. DON'T: Read a 2GB file with read_file() — it will try to load the entire file into memory at once. Use line-by-line reading instead.
Memory-mapped files — zero-copy random reads
A memory-mapped file lets you treat a file on disk as if it were already sitting in memory, without loading it into a NOVA string or list first. The OS pages pieces of the file in and out as you touch them, so mmap_open is the tool for the cases where read_file's "load the whole thing" model does not fit: files bigger than you want to hold in RAM, or workloads that only touch a small, scattered fraction of a huge file — a database engine reading a handful of index pages out of a multi-gigabyte data file, or a log analyzer jumping to specific byte offsets found by a prior index pass.
m = mmap_open("data/log.bin") size = mmap_len(m) print("file is {size} bytes") first = mmap_byte(m, 0) last = mmap_byte(m, size - 1) print("first byte: {first}, last byte: {last}") mmap_close(m)
first byte: 137, last byte: 10
Line-by-line breakdown:
mmap_open(path)— maps the file into the process's address space and returns a handle. Unlikefile_open, no data is read yet — the mapping is set up, but bytes are only actually paged in from disk the first time you touch them.mmap_len(m)— the file's size in bytes, read from the filesystem, not from anything loaded into memory.mmap_byte(m, i)— reads a single byte at offseti. This is bounds-checked: reading at or pastmmap_len(m)sets an error, the same safety guarantee as list indexing.mmap_close(m)— unmaps the file. Always close a mapping when done — like a file handle, it holds an OS resource open until released.
Compare to the line-by-line reading pattern earlier in this chapter: file_open/file_read_line streams the file forward sequentially and is the right tool for "process every line once." mmap_open is the right tool for random access — jumping to arbitrary byte offsets without paying for a seek-and-read syscall each time, because the mapped pages stay cached by the OS across repeated accesses.
DO: Reach for mmap_open when you need random-access reads into a file too large to hold comfortably in memory, or when the same file is read from many different offsets over the program's lifetime. DON'T: Use mmap_byte in a tight loop to read a file sequentially byte-by-byte — that is what file_read_line or read_file is for; mmap shines at scattered, repeated, or partial access, not linear scans of small files.
Safe file reading with error handling
File I/O always fails in the real world: wrong path, missing permissions, disk full. NOVA's read_file returns a Result, so you are forced to decide what happens on failure. The try keyword propagates errors upward; unwrap_or provides a default value when you don't need fine-grained error handling.
// Pattern 1: propagate errors with try fn read_config() contents = try read_file("config.toml") // returns Err if file missing contents // Pattern 2: provide a fallback with unwrap_or port_str = unwrap_or(read_file("port.conf"), "8080") print("Starting on port {port_str}") // Pattern 3: match on the error to give different responses fn load_data(path) match read_file(path) Ok(text) => parse_lines(text) Err(msg) => print("Warning: could not read {path}: {msg}") [] // return empty list as default fn parse_lines(text) filter(split(text, "\n"), fn(line) len(trim(line)) > 0) // Pattern 4: writing a log file — append mode fn log_event(msg) timestamp = time_ms() line = "[{timestamp}] {msg}\n" match append_file("app.log", line) Ok(_) => null Err(msg) => print("ERROR: could not write to log: {msg}") log_event("server started") log_event("first request received")
Line-by-line breakdown:
contents = try read_file("config.toml")—trychecks the result. Ifread_filereturnsErr(msg),tryimmediately returns that error from the enclosing function. If it returnsOk(text),tryunwraps the text and assigns it tocontents. This is equivalent to Rust's?operator.unwrap_or(read_file("port.conf"), "8080")— if the file exists, use its contents; if not, use the string"8080". This is the right tool for configuration values with sensible defaults.Err(msg) => []— the match arm returns an empty list when the file can't be read. The function signature stays the same whether the file exists or not — the caller always gets a list back, never a crash.append_file("app.log", line)— writes to the file in append mode (creates the file if it doesn't exist, adds to the end otherwise). ReturnsOk(null)on success orErr(msg)on failure.Ok(_) => null— the wildcard inOk(_)discards the success value (which isnullfor write operations that don't return data). The match arm producesnullto satisfy the exhaustiveness requirement.
DO: Use try when you want errors to propagate to the caller. Use unwrap_or when you have a sensible default value. Use match when you need different behavior for specific errors. DON'T: Call unwrap() on file reads in production code — file errors are normal, not exceptional, and an unwrap crash gives users no useful message.
Processing CSV data
CSV (Comma-Separated Values) is one of the most common data formats for files. NOVA has a built-in csvx module, but it's instructive to see how to parse it manually using read_file, split, and struct construction. This pattern applies to any line-oriented text format.
// people.csv: // name,age,city // Alice,30,New York // Bob,25,London // Carol,35,Sydney type Person name: string age: int city: string fn parse_csv(path) text = try read_file(path) lines = split(text, "\n") people = [] // Skip header row (index 0) and empty trailing lines for i in 1..len(lines) - 1 line = trim(lines[i]) if len(line) == 0 then continue parts = split(line, ",") if len(parts) != 3 then continue // skip malformed lines person = Person \{ name: trim(parts[0]), age: int(trim(parts[1])), city: trim(parts[2]) \} push(people, person) ok(people) fn main() match parse_csv("people.csv") Ok(people) => for p in people print("{p.name} ({p.age}) — {p.city}") Err(msg) => print("Failed to load: {msg}") // Output: // Alice (30) — New York // Bob (25) — London // Carol (35) — Sydney
Line-by-line breakdown:
text = try read_file(path)— reads the entire file.trypropagates the error to the caller if the file doesn't exist, soparse_csvreturns anErrin that case.lines = split(text, "\n")— splits the full text into individual lines. On Windows files the lines may end with\r\n;trim()on each line handles the carriage return.for i in 1..len(lines) - 1— starts at index 1 to skip the header row (name,age,city). NOVA ranges are exclusive on the right end (half-open:a..bincludesa, excludesb), so this iteratesi = 1 .. len(lines) - 2— it deliberately stops one short of the final index. That last skipped element is the empty stringsplit()produces after the trailing\nthat most text files end with. Theif len(line) == 0: continueguard below handles the case where there is no trailing newline (solinesis one element shorter and nothing needs skipping) — between the two, every real data row is processed regardless of how the file ends.if len(line) == 0 then continue— skips blank lines. Files often end with a trailing newline, which produces an empty string as the last element oflines.thenis NOVA's shorthand for a single-statement if body on one line — not a colon, which NOVA reserves for type annotations and dict/struct literals.if len(parts) != 3: continue— skips malformed lines. A robust CSV parser would log these; for a simple script, skipping is fine.age: int(trim(parts[1]))— converts the string"30"to the integer30.trimremoves any spaces around the value.push(people, person)— appends the parsed struct to the accumulator list.ok(people)— the last expression in the function: wraps the list in a success result. The caller usesmatchto handle both the success and error cases.
13. Regular expressions (regex)
What is this? A regular expression (regex) is a pattern that describes a set of strings. Instead of searching for an exact string like "hello", you can search for a PATTERN like "any word followed by a number" or "a valid email address." Used everywhere for validation, search, replace, and parsing log files.
The matches keyword — testing patterns
// Basic matching — does the string contain the pattern? print("hello123" matches "\\d+") // true (contains digits) print("hello" matches "\\d+") // false (no digits) print("hello world" matches "world") // true (literal match) // Anchored matching (^ = start of string, $ = end of string) print("2024" matches "^\\d+$") // true (entire string is digits) print("hi2024" matches "^\\d+$") // false (doesn't start with digit)
false
true
true
false
Pattern syntax reference
| Pattern | Meaning | Example |
|---|---|---|
. | Any character | "a.c" matches "abc", "aXc" |
\d | Digit 0-9 | "\\d+" matches "42" |
\w | Word char (letter, digit, _) | "\\w+" matches "hello_42" |
\s | Whitespace | "\\s+" matches " " |
* | Zero or more of previous | "ab*c" matches "ac", "abc", "abbc" |
+ | One or more of previous | "ab+c" matches "abc" but NOT "ac" |
? | Zero or one of previous | "ab?c" matches "ac" and "abc" |
[abc] | Character class | "[abc]at" matches "cat", "bat" |
^ | Start of string | "^hello" anchors at start |
$ | End of string | "world$" anchors at end |
IMPORTANT: Backslashes must be doubled in NOVA strings because \ is a string escape character. Write "\\d+" not "\d+". Write "\\w+" not "\w+".
Regex functions
// regex_find: extract the first match result = regex_find("price is 42 dollars", "\\d+") print(result) // 42 empty = regex_find("no numbers here", "\\d+") print(empty) // (empty string — no match) // regex_replace: replace matches (all occurrences) print(regex_replace("hello world", "world", "NOVA")) // Output: hello NOVA print(regex_replace("foo123bar", "\\d+", "NUM")) // Output: fooNUMbar // regex_split: split string by pattern parts = regex_split("one:two:three", ":") print(parts) // ["one", "two", "three"] // Split on any whitespace (one or more spaces/tabs) parts = regex_split("a1b2c3d", "\\d") print(parts) // ["a", "b", "c", "d"]
hello NOVA
fooNUMbar
["one", "two", "three"]
["a", "b", "c", "d"]
Real-world examples
// Validate email address (basic pattern) fn is_email(s) s matches "^[\\w.+-]+@[\\w-]+\\.[\\w.]+$" print(is_email("user@example.com")) // true print(is_email("not-an-email")) // false // Extract all numbers from text fn extract_numbers(text) parts = regex_split(text, "[^\\d]+") filter(parts, s => len(s) > 0) nums = extract_numbers("There are 3 cats and 12 dogs in 2 houses") print(nums) // ["3", "12", "2"] // Sanitize input — remove non-alphanumeric characters fn sanitize(s) regex_replace(s, "[^a-zA-Z0-9 ]", "") print(sanitize("Hello! @#$ World <script>")) // Hello World script
false
["3", "12", "2"]
Hello World script
DO: Double-escape backslashes: "\\d+" not "\d+". DON'T: Use regex for simple operations like find(), starts_with(), replace() — the string builtins are faster for literal matching.
Parsing log files
Log parsing is one of the most common real-world uses for regex. Here is a full example that parses Apache-style web server logs:
// Example log line: // 192.168.1.1 - - [28/Jun/2025:12:00:00] "GET /api/users HTTP/1.1" 200 1234 fn parse_log_line(line) // Extract IP address at the start of the line ip = regex_find(line, "^(\\d+\\.\\d+\\.\\d+\\.\\d+)") // Extract HTTP method (GET, POST, etc.) method = regex_find(line, "\"(GET|POST|PUT|DELETE|PATCH) ") // Extract HTTP status code (3 digits after the closing quote) status = regex_find(line, "\" (\\d{3}) ") {"ip": ip, "method": method, "status": status} // Count error responses (status codes starting with 5) fn count_errors(log_file) content = read_file(log_file) lines = split(content, "\n") error_lines = filter(lines, s => s matches "\" 5\\d\\d ") len(error_lines) print(count_errors("access.log"))
Pattern breakdown:
"^(\\d+\\.\\d+\\.\\d+\\.\\d+)"— IP address pattern: four groups of digits separated by literal dots.\\.means literal dot (a bare.means "any character").\\d+means one or more digits."\"(GET|POST|PUT|DELETE|PATCH) "— Alternation with|: match any one of the listed methods. The\\"is a literal quote character."\" 5\\d\\d "— Match a space, the digit 5, then any two digits, then a space. This matches all 5xx status codes (500, 503, etc.).
URL and IP validation
// IP address validation fn is_ipv4(s) s matches "^(\\d{1,3}\\.){3}\\d{1,3}$" print(is_ipv4("192.168.1.1")) // true print(is_ipv4("999.x.1.1")) // false (regex is structural — use int parsing for range check) print(is_ipv4("not an ip")) // false // Extract all URLs from a block of text fn find_urls(text) // Match http:// or https:// followed by non-whitespace parts = regex_split(text, "\\s+") // split on any whitespace filter(parts, s => s matches "^https?://") // keep only URLs text = "Visit https://example.com or https://novachan.org for details" urls = find_urls(text) print(urls) // ["https://example.com", "https://novachan.org"] // Password strength: at least 8 chars, has digit, has letter fn is_strong_password(p) has_length = len(p) >= 8 has_digit = p matches "\\d" has_letter = p matches "[a-zA-Z]" has_length and has_digit and has_letter print(is_strong_password("abc123!")) // false (7 chars) print(is_strong_password("abcdef12")) // true (8 chars, letter+digit)
Regex compared across languages
| Operation | NOVA | Python | JavaScript | Go |
|---|---|---|---|---|
| Does pattern match? | s matches "pattern" | bool(re.search("pattern", s)) | /pattern/.test(s) | regexp.MustCompile(...).MatchString(s) |
| Extract first match | regex_find(s, "pat") | re.search("pat", s).group() | s.match(/pat/)[0] | re.FindString(s) |
| Replace all | regex_replace(s, "pat", sub) | re.sub("pat", sub, s) | s.replaceAll(/pat/g, sub) | re.ReplaceAllString(s, sub) |
| Split on pattern | regex_split(s, "pat") | re.split("pat", s) | s.split(/pat/) | re.Split(s, -1) |
| Named groups | Not yet | (?P<name>...) | (?<name>...) | (?P<name>...) |
NOVA's advantage: The matches keyword integrates regex into the language itself rather than requiring a function call. if s matches "\\d+" reads exactly like English. Python, Go, and Java require importing a module and compiling the pattern first. In performance-sensitive code you can pre-compile patterns; for one-off validations, inline patterns are cleaner.
14. JSON (JavaScript Object Notation)
What is this? JSON is the most common data format for exchanging information between programs, APIs, and services. Almost every web API sends and receives JSON. NOVA has built-in JSON encoding and decoding — no imports needed for basic operations.
Encoding to JSON
"Encoding" means converting a NOVA value into a JSON-formatted string:
print(json_encode(42)) // 42 print(json_encode("hello")) // "hello" print(json_encode(true)) // true print(json_encode([1, 2, 3])) // [1,2,3] print(json_encode({"a": 1})) // {"a":1}
"hello"
true
[1,2,3]
{"a":1}
Line-by-line:
json_encode(42)— integers become JSON numbers.json_encode("hello")— strings become JSON strings (wrapped in double quotes, special chars auto-escaped).json_encode(true)— booleans becometrueorfalse.json_encode([1, 2, 3])— lists become JSON arrays:[1,2,3].json_encode({"a": 1})— dicts become JSON objects:{"a":1}.
Decoding from JSON
"Decoding" means converting a JSON-formatted string back into a NOVA value:
data = json_decode("{\"name\": \"Alice\", \"age\": 30}") print(data["name"]) // Alice print(data["age"]) // 30 list_data = json_decode("[1, 2, 3]") print(list_data[1]) // 2 // Nested JSON — access nested values with chained [] config = json_decode('{"server":{"host":"localhost","port":8080}}') print(config["server"]["port"]) // 8080
30
2
8080
Line-by-line:
json_decode("{\"name\": \"Alice\", \"age\": 30}")— parses the JSON string into a NOVA dict. The\"are escaped double quotes inside the string. The result has two keys:"name"and"age".data["name"]— accesses the value for key"name". Returns"Alice".data["age"]— returns30. JSON numbers are automatically converted to NOVA integers or floats.
What NOT to do
// DON'T build JSON by string concatenation — breaks on special characters name = "Alice \"Bob\" Carol" bad_json = "{\"name\": \"" + name + "\"}" // BROKEN — the quotes inside name corrupt the JSON // DO use json_encode to build JSON safely — auto-escapes everything good_json = json_encode({"name": name}) // CORRECT — produces: {"name":"Alice \"Bob\" Carol"}
Struct to/from JSON — automatic
import forge type User name: string age: int email: string u = User { name: "Alice", age: 30, email: "alice@example.com" } print(to_json(u)) // {"name":"Alice","age":30,"email":"alice@example.com"}
Why this is special: In most languages you write custom serialization code (Java's @JsonProperty, Python's json.dumps(obj.__dict__), Go's json.Marshal). In NOVA, the compiler knows the struct's fields and types, so to_json() just works for any struct — including nested structs, structs containing lists, and any depth of nesting.
Reading and writing JSON files
// Read a config file config = json_decode(read_file("config.json")) host = config["host"] port = int(config["port"]) print("Connecting to {host}:{port}") // Update and write back config["last_run"] = datetime_format(datetime_timestamp(), "%Y-%m-%dT%H:%M:%SZ") write_file("config.json", json_encode(config))
15. Date and time
What is this? Get the current time, format timestamps, parse dates, do date arithmetic, and measure elapsed time for performance profiling.
Computers represent time as a single number: milliseconds since January 1, 1970 00:00:00 UTC — called the Unix epoch. All date functions convert between this raw number and human-readable components.
Current time
// Milliseconds since epoch ms = time_ms() print(ms) // 1719561600000 (example) // Current datetime as formatted string now = datetime_now() print(now) // "2026-06-28 12:00:00" // Unix timestamp (seconds since epoch) ts = datetime_timestamp() print(ts) // 1719561600
Extracting components
ts = datetime_timestamp() print(datetime_year(ts)) // 2026 print(datetime_month(ts)) // 6 print(datetime_day(ts)) // 28 print(datetime_hour(ts)) // 12 print(datetime_minute(ts)) // 0 print(datetime_second(ts)) // 0 print(datetime_weekday(ts)) // 6 (0=Sunday, 6=Saturday)
6
28
12
0
0
6
Formatting and parsing
ts = datetime_timestamp() print(datetime_format(ts, "%Y/%m/%d")) // "2026/06/28" print(datetime_format(ts, "%Y-%m-%d %H:%M")) // "2026-06-28 12:00" // Parse a date string to timestamp ts2 = datetime_parse("2025-06-15 12:30:00", "") print(datetime_year(ts2)) // 2025 print(datetime_month(ts2)) // 6 print(datetime_day(ts2)) // 15 // ISO 8601 also works ts3 = datetime_parse("2024-12-25T08:00:00", "")
2026-06-28 12:00
2025
6
15
Format specifiers follow strftime convention: %Y = 4-digit year, %m = 2-digit month (01–12), %d = 2-digit day (01–31), %H = 24-hour hour (00–23), %M = minute (00–59), %S = second (00–59).
Date arithmetic
ts = datetime_parse("2025-06-15 12:00:00", "") // Add days next_week = datetime_add_days(ts, 7) print(datetime_day(next_week)) // 22 // Add hours later = datetime_add_hours(ts, 3) print(datetime_hour(later)) // 15 // Difference between two timestamps (in seconds) ts1 = datetime_parse("2025-06-15 12:00:00", "") ts2 = datetime_parse("2025-06-16 14:30:00", "") diff = datetime_diff(ts2, ts1) print(diff) // 95400 (26.5 hours in seconds)
15
95400
Measuring elapsed time
start = time_ms() // ... do work ... elapsed = time_ms() - start print("Took {elapsed}ms") // Nanosecond precision for micro-benchmarks t0 = clock_ns() // ... tight loop ... print("Took {clock_ns() - t0}ns") // Sleep — despite the name, sleep() takes MILLISECONDS, not seconds sleep(250) // suspend the current green task for 250ms
Token expiry and TTL checking
A very common real-world pattern: check whether something (an API token, a session, a cached value) has expired. NOVA timestamps are just integers — comparing two timestamps is just integer subtraction.
fn make_token(user_id, ttl_seconds) issued_at = datetime_timestamp() expires_at = issued_at + ttl_seconds {"user": user_id, "issued": issued_at, "expires": expires_at} fn is_valid(token) now = datetime_timestamp() now < token["expires"] // true = still valid, false = expired fn seconds_until_expiry(token) now = datetime_timestamp() token["expires"] - now // negative = already expired // Use it tok = make_token("alice", 3600) // expires in 1 hour print(is_valid(tok)) // true print(seconds_until_expiry(tok)) // ~3600 issued_str = datetime_format(tok["issued"], "%Y-%m-%d %H:%M") print("Token issued at: {issued_str}")
Line-by-line:
issued_at + ttl_seconds— Timestamps are just seconds-since-epoch integers. Adding 3600 seconds to the current timestamp gives the expiry timestamp exactly 1 hour from now. No date library needed — it is just addition.now < token["expires"]— Comparing timestamps is comparing integers. If now is less than the expiry, the token is valid. If now is equal or greater, it has expired.token["expires"] - now— A negative result means the token expired that many seconds ago. A positive result tells you how many seconds remain. This is how every JWT library internally represents expiry.
Scheduling — check if a job should run
A common pattern for cron-like task scheduling: store the last-run timestamp, check elapsed time, run if enough time has passed.
fn should_run(last_run_ts, interval_seconds) now = datetime_timestamp() now - last_run_ts >= interval_seconds fn run_scheduler(tasks) // tasks = list of {name, interval_sec, last_run, handler} loop for task in tasks if should_run(task["last_run"], task["interval_sec"]) task["handler"]() task["last_run"] = datetime_timestamp() sleep(1000) // check every second // Set up tasks tasks = [ {"name": "heartbeat", "interval_sec": 30, "last_run": 0, "handler": fn() print("ping")}, {"name": "cleanup", "interval_sec": 3600, "last_run": 0, "handler": fn() print("cleaning...")} ] spawn fn() run_scheduler(tasks)
The key insight: last_run = 0 means "never run." The first check is now - 0 >= interval, which is always true for any interval less than 50+ years — so every task runs on the first tick. After each run, last_run is updated to now, so the task waits a full interval before running again.
Comparing and sorting by date
// Timestamps are integers — comparison is just integer comparison ts1 = datetime_parse("2025-01-01 00:00:00", "") ts2 = datetime_parse("2025-06-15 00:00:00", "") print(ts1 < ts2) // true — January is before June print(ts1 == ts2) // false — different timestamps // Sort a list of events by date (oldest first) events = [ {"name": "deploy", "ts": datetime_parse("2025-06-15 14:00:00", "")}, {"name": "commit", "ts": datetime_parse("2025-06-15 13:30:00", "")}, {"name": "review", "ts": datetime_parse("2025-06-15 11:00:00", "")} ] sorted_events = sort_by(events, fn(e) e["ts"]) for e in sorted_events formatted = datetime_format(e["ts"], "%H:%M") print("{formatted} — {e[\"name\"]}") // 11:00 — review // 13:30 — commit // 14:00 — deploy
Because timestamps are just integers, all standard comparison operators work directly: <, >, ==, !=. And sort_by(events, fn(e) e["ts"]) sorts by the integer timestamp value — no custom comparator needed beyond extracting the field.
Date and time compared across languages
| Operation | NOVA | Python | Go | Java |
|---|---|---|---|---|
| Current timestamp | datetime_timestamp() | datetime.now().timestamp() | time.Now().Unix() | Instant.now().getEpochSecond() |
| Format a date | datetime_format(ts, "%Y-%m-%d") | dt.strftime("%Y-%m-%d") | t.Format("2006-01-02") | LocalDate.format(DateTimeFormatter.ISO_DATE) |
| Parse a date | datetime_parse(s, "") | datetime.strptime(s, fmt) | time.Parse(layout, s) | LocalDateTime.parse(s, formatter) |
| Add days | datetime_add_days(ts, 7) | dt + timedelta(days=7) | t.Add(7 * 24 * time.Hour) | date.plusDays(7) |
| Compare two dates | ts1 < ts2 (integer comparison) | dt1 < dt2 (operator overload) | t1.Before(t2) | date1.isBefore(date2) |
| Elapsed time | time_ms() - start | (time.time() - start) * 1000 | time.Since(start).Milliseconds() | Duration.between(start, now).toMillis() |
Why NOVA's date API is simpler: Every NOVA timestamp is a plain integer (Unix seconds). Adding 7 days is just ts + 7 * 86400. Comparing two dates is just ts1 < ts2. Python, Go, and Java all have special date types with operator overloading or method calls — NOVA's model is lower ceremony for 90% of use cases.
Go's unusual format pattern: Go uses a reference time of "Mon Jan 2 15:04:05 MST 2006" — you format dates by writing that specific reference time in the format you want. NOVA uses strftime specifiers like %Y/%m/%d, which most developers already know from Python and C.
16. Testing and benchmarking
What is this? NOVA's built-in testing framework. Write automated checks that the computer runs for you — no external library, no configuration file. If something breaks, the test tells you immediately what went wrong and where.
Writing tests
test_run takes a test name and a zero-argument function. An anonymous closure written with the fn() ... keyword form can only hold a single expression — but a multi-statement body can still be written inline as a call argument, using the fat-arrow block-lambda form instead (() => followed by an indented block; see the note below). For a one-line check, write an fn() closure inline; for anything with setup steps, either use a () => ... block lambda or define a small named test function and pass it by name:
fn test_list_operations() items = [1, 2, 3] push(items, 4) assert_eq(len(items), 4) assert_eq(items[3], 4) fn main() test_run("addition works", fn() assert_eq(2 + 3, 5)) test_run("string length", fn() assert_eq(len("hello"), 5)) test_run("list operations", test_list_operations) test_summary()
ok string length (1 assertions)
ok list operations (2 assertions)
All tests passed: 4 assertions in total
Line-by-line:
test_run("addition works", fn() ...)— takes a test name and a zero-argument function. If the function runs without assertion failures, the test passes and printsok <name> (<N> assertions). If any assertion fails, it printsFAIL <name> (<pass> passed, <fail> failed)to stderr instead.assert_eq(2 + 3, 5)— checks first argument equals second. Passes silently or fails loudly.test_run("list operations", test_list_operations)—test_list_operationsis a plain top-level function name, passed as a value (no parens, nofnwrapper needed — a named function IS already a zero-argument callable). Use this form whenever a test needs more than one statement.test_summary()— must be called at the end. PrintsAll tests passed: N assertions in totalon success, orTest results: P passed, F FAILED, T totalto stderr if anything failed. Without it, you won't see the final report line.
DO: Keep inline fn() assert_eq(...) closures written with the fn keyword to a single expression — that form has no block-body syntax. DO: For a multi-statement closure inline in a call, use the fat-arrow block-lambda form instead: test_run("name", () => with the body indented on the following line(s) is a real, capturing, multi-statement closure — no fn keyword and no separate named function required. DO: Extract anything you want to reuse or name into a named fn test_xxx() and pass the name to test_run. DON'T: Write test_run("name", fn() with the body on the following indented line(s) — the fn keyword's body is always a single expression, so this exact spelling is a parse error; use () => instead.
Assertion functions
| Function | What it checks | When to use |
|---|---|---|
assert_eq(a, b) | a equals b | Most comparisons: assert_eq(result, 42) |
assert_ne(a, b) | a does not equal b | Ensure values differ: assert_ne(password, "") |
assert_true(cond) | condition is truthy | Boolean results: assert_true(is_ok(r)) |
assert_false(cond) | condition is falsy | assert_false(is_empty(list)) |
assert_near(a, b, tol) | |a-b| < tol (floats) | Floats: assert_near(0.1+0.2, 0.3, 0.0001) |
assert_approx(actual, expected, tol) | |actual-expected| <= tol (floats) | Floats: assert_approx(sin(3.14159), 0.0, 0.001) |
assert_contains(col, el) | collection contains element | Lists: assert_contains(names, "Alice") |
assert(cond, msg) | condition is true | Custom error message: assert(age >= 0, "age must be positive") |
Approximate equality — assert_near vs assert_approx
Floating-point results almost never come out bit-for-bit equal even when a computation is textbook-correct — accumulated rounding inside sin, 0.1 + 0.2, or a chain of divisions produces a value that is off by a tiny fraction from the mathematically exact answer. assert_eq compares exactly, so it fails on results that are correct in every practical sense. NOVA ships two independent tolerance-based assertions for this: assert_near(a, b, tol) (used above) and assert_approx(actual, expected, tol). They are separate, independently registered builtins with the same purpose rather than aliases of one another — and the comparison they use differs at the edge: assert_near fails unless the difference is strictly less than tol, while assert_approx only fails when the difference exceeds tol (so a difference exactly equal to the tolerance passes assert_approx but fails assert_near). Pick one name, understand which comparison it uses, and stay consistent within a file.
fn test_trig_identities() assert_approx(sin(3.14159), 0.0, 0.001) // sin(pi) ~ 0, within epsilon assert_approx(cos(0.0), 1.0, 0.0001) fn main() test_run("trig identities hold within epsilon", test_trig_identities) test_summary()
All tests passed: 2 assertions in total
DO: Use assert_near or assert_approx for every floating-point comparison in a test — never assert_eq. DON'T: Pick a tolerance out of habit. A tolerance that's safe for a single operation (0.0001) can be far too tight for a result built from hundreds of accumulated floating-point operations — size the tolerance to how much rounding error the computation can actually accumulate.
Testing Result types
fn test_ok_result() r = ok(42) assert_true(is_ok(r)) assert_eq(unwrap(r), 42) fn test_err_result() r = err("something broke") assert_true(is_err(r)) assert_eq(unwrap_err(r), "something broke") fn main() test_run("ok result", test_ok_result) test_run("err result", test_err_result) test_summary()
Benchmarking
// Simple timing — run N times for reliable measurement N = 100_000 start = time_ms() i = 0 while i < N result = expensive_fn(i) i = i + 1 elapsed = time_ms() - start print("{N} iterations in {elapsed}ms = {elapsed * 1000 / N}us each") // Using bench_run (reports iterations/sec) bench_run("fibonacci", 1000, fn() fibonacci(20))
DO: Give each test a descriptive name: "user login with wrong password returns error" not "test 1". DON'T: Forget to call test_summary() at the end — without it, you won't see the pass/fail report.
nova bench — production-mode benchmarking from the command line
bench_run() above measures from inside an already-running program — useful when you want a benchmark number folded into your own test output, or you are comparing several functions within one process. nova bench <file> is a separate, coarser instrument: point it at a whole file and it builds that file at -O2 — the same optimization level nova build uses, deliberately, because a benchmark run at -O0 would measure the unoptimized path nobody actually ships — then times N full runs of the resulting binary end to end (process start included) and reports the minimum, mean, and maximum wall-clock time across those runs. Because it measures the compiled, optimized binary as a whole process rather than a hot loop inside an already-warm process, it is the number you would actually quote in a performance report.
nova bench fib.nova
nova bench fib.nova -n 50 # override the default 10 iterationsRunning 10 iterations...
min: 3.1ms
mean: 3.4ms
max: 4.0ms
DO: Raise -n well past the default 10 for anything with real variance — I/O, allocation-heavy code, or a machine shared with other processes — until min/mean/max stop moving between runs. DON'T: Compare a nova bench number against a bench_run() number from inside a program — one measures a fresh process launch through an -O2 binary end to end, the other measures a hot loop inside a process that is already warmed up; they answer different questions.
A complete test file
// math_test.nova fn factorial(n) if n <= 1 then return 1 n * factorial(n - 1) fn fibonacci(n) if n <= 1 then return n fibonacci(n - 1) + fibonacci(n - 2) fn main() test_run("factorial of 0", fn() assert_eq(factorial(0), 1)) test_run("factorial of 5", fn() assert_eq(factorial(5), 120)) test_run("float division", fn() assert_near(10.0 / 3.0, 3.333, 0.001)) test_run("string upper", fn() assert_eq(upper("nova"), "NOVA")) test_run("fibonacci(10)", fn() assert_eq(fibonacci(10), 55)) test_summary()
ok factorial of 5 (1 assertions)
ok float division (1 assertions)
ok string upper (1 assertions)
ok fibonacci(10) (1 assertions)
All tests passed: 5 assertions in total
Table-driven tests
Table-driven testing encodes all test inputs and expected outputs as a list of structs, then loops over the list to run each case. Adding a new test case is one new row — no new function, no new test block, no searching through code for the right insertion point.
fn factorial(n) if n <= 1 then return 1 n * factorial(n - 1) type FactCase input: int expected: int cases = [ FactCase \{ input: 0, expected: 1 \}, FactCase \{ input: 1, expected: 1 \}, FactCase \{ input: 5, expected: 120 \}, FactCase \{ input: 10, expected: 3628800 \}, ] fn main() for tc in cases test_run("factorial({tc.input})", fn() assert_eq(factorial(tc.input), tc.expected)) test_summary()
Line-by-line breakdown:
type FactCase— a struct holds one row of the table. Named fields make the table self-documenting:input: 5, expected: 120is clear. An anonymous list like[5, 120]forces the reader to remember which position means which thing.cases = [...]— the test table. Every case lives here. To add a new case, add one line. The loop picks it up automatically — no registration, no annotation, no separate test registration call.for tc in cases— iterates every row. Each iteration callstest_runwith the current row's data. If one fails, the test name shows which input caused the failure."factorial({tc.input})"— string interpolation generates a distinct name per row:"factorial(0)","factorial(5)","factorial(10)". The test report shows these as separate entries, so failures are immediately identifiable.assert_eq(factorial(tc.input), tc.expected)— the single assertion per row.assert_eqprints both the actual and expected values if they differ.
Vs Go: Go popularized this pattern with t.Run() in a for _, tc := range cases loop. NOVA's version is structurally identical but without type annotations — the compiler infers the element type from the list literal.
Testing error paths
Every function that can fail should have tests for both the success path and the failure path. An untested error path is code that runs in production without ever being verified — when it eventually fails, it fails with no context and no prior warning.
fn divide(a, b) if b == 0 then return err("division by zero") ok(a / b) fn find_min(xs) if len(xs) == 0 then return err("empty list has no minimum") m = xs[0] for x in xs if x < m then m = x ok(m) fn test_divide_by_zero() r = divide(10, 0) assert_true(is_err(r)) assert_eq(unwrap_err(r), "division by zero") fn test_find_min_empty() r = find_min([]) assert_true(is_err(r)) assert_eq(unwrap_err(r), "empty list has no minimum") fn main() test_run("divide 10 by 2", fn() assert_eq(divide(10, 2), ok(5))) test_run("divide by zero returns error", test_divide_by_zero) test_run("empty list has no minimum", test_find_min_empty) test_run("find_min of [3,1,4,1,5]", fn() assert_eq(find_min([3, 1, 4, 1, 5]), ok(1))) test_summary()
Line-by-line breakdown:
assert_eq(divide(10, 2), ok(5))— compares the wholeResultincluding its wrapper. This is stricter than extracting the integer first: it verifies the function produces a properOk(5), not just a bare 5 via some unintended code path.assert_true(is_err(r))— checks that the result is an error without asserting the exact message. Useful when the message wording might change in future refactors and you care about the failure category, not the string.assert_eq(unwrap_err(r), "division by zero")— pins the exact error message.unwrap_errextracts the string fromErr(msg). Safe here because we already confirmedis_err(r)on the line above.find_min([])— empty input is the most common source of runtime crashes. Test it explicitly for every function that takes a list or collection parameter.
DO: Write at least one error-path test for every function that returns a Result. DON'T: Test only the happy path — an untested error path is code that can fail silently in production with no prior validation.
Testing panics — assert_throws
Not every failure in NOVA is a Result. Division by zero, an out-of-bounds index, and overflow-checked arithmetic like checked_add all fail by panicking — unwinding immediately instead of returning an Err value for the caller to inspect. A test suite that only covers Result-returning functions never exercises these paths. assert_throws(fn, expected_msg) closes that gap: it calls fn() inside a protected frame, catches the panic, and fails the test if either nothing panicked, or something panicked with a message that does not contain expected_msg as a substring.
fn test_overflow_panics() assert_throws(fn() checked_add(9223372036854775807, 1), "overflow") fn main() test_run("checked_add panics on overflow", test_overflow_panics) test_summary()
All tests passed: 1 assertions in total
DO: Wrap the panicking call in a zero-argument fn() ... closure, exactly like every other test_run body in this chapter — assert_throws(checked_add(MAX, 1), "overflow") would evaluate checked_add eagerly while building the argument list, and the panic would crash the whole test run instead of being caught by assert_throws. DON'T: Assume any word that seems related to the failure will match. assert_throws does a plain substring check against the real panic text, so a guessed message that isn't literally contained in it fails the assertion even though the code did panic — see the TAP example below, where "divide" does not match the actual division-by-zero panic text.
Testing concurrent code
Testing spawned tasks requires observing their results from the test body. Channels are the natural synchronization mechanism in NOVA: the spawned task sends its result on a channel, and the test receives and asserts on it. No join handles, no callbacks, no sleep timers guessing when a task will finish.
fn sum_range(from, to, ch) total = 0 for i in from..to total += i send(ch, total) fn test_spawn_sends_result() ch = channel() spawn { send(ch, 42) } result = recv(ch) assert_eq(result, 42) fn test_parallel_workers_sum() ch = channel() spawn { sum_range(1, 6, ch) } // 1+2+3+4+5 = 15 (range end is exclusive) spawn { sum_range(6, 11, ch) } // 6+7+8+9+10 = 40 (range end is exclusive) a = recv(ch) b = recv(ch) assert_eq(a + b, 55) fn producer(ch) for i in 1..6 send(ch, i) fn test_bounded_channel() ch = channel_bounded(3) spawn { producer(ch) } total = 0 for _ in 1..6 total += recv(ch) assert_eq(total, 15) fn main() test_run("spawn sends result on channel", test_spawn_sends_result) test_run("parallel workers sum 1..10", test_parallel_workers_sum) test_run("bounded channel delivers all 5 items", test_bounded_channel) test_summary()
Line-by-line breakdown:
test_run("spawn sends result on channel", test_spawn_sends_result)— a named top-level function is used here for reuse and readability. The equivalent inline formtest_run("spawn sends result on channel", () => ...)(fat-arrow block lambda, body indented below) would also work for a multi-statement body — only thefn() ...keyword form is restricted to a single expression (see the DO/DON'T note earlier in this chapter).ch = channel()— creates an unbounded channel inside the test function. The spawned task captures it by closure and writes to it; the test body reads from it. No global state, no shared mutable variables.spawn { send(ch, 42) }— spawns a green task. The braces delimit the task body when it fits on one line. The task runs concurrently with the test body on the same green scheduler.result = recv(ch)— the synchronization point. The test body parks here until the spawned task sends. The scheduler runs the spawned task, which callssend, which unparks the test body to receive the value.a = recv(ch) ... b = recv(ch)— collects two results from two workers. Arrival order is not guaranteed — the test checksa + b == 55, which is order-independent. Never assert the arrival order of independent workers.channel_bounded(3)— bounded channel with capacity 3 (channel(), the keyword-based unbounded constructor used elsewhere, takes no arguments — a numeric capacity needs this separate function). When full,sendparks the sender task until the receiver creates space. The test verifies all 5 items arrive even though the channel holds at most 3 at once.produceris its own named function becausespawn { ... }braces also only hold a single expression — a multi-statement task body needs a named function too, called from inside the braces.
Key insight: Channels are the only synchronization mechanism needed to test concurrent code in NOVA. If you need the result of a spawned task, put a channel between the task and the test assertion — the same pattern used in production code. No test-specific concurrency APIs to learn.
Test coverage tracking
Coverage tells you which lines your tests actually exercised — not just whether the tests you wrote pass, but whether your test suite as a whole touches the code you think it touches. NOVA's coverage API is deliberately low-level and manual: cov_mark(file, line) records one hit at a source location, and it is up to you (or a small preprocessing tool) to decide where those calls go. Unlike a coverage-aware compiler flag in some other toolchains, NOVA's compiler does not auto-insert a cov_mark at every line — instrumentation is something you add on purpose, typically at the top of each function under test, or generated from a build step.
fn add(a, b) cov_mark("math.nova", 2) a + b fn subtract(a, b) cov_mark("math.nova", 7) a - b fn test_add() assert_eq(add(2, 3), 5) fn main() test_run("add works", test_add) test_summary() print(cov_get("math.nova", 2)) // 1 — add() ran once, via test_add print(cov_get("math.nova", 7)) // 0 — subtract() was never called by any test print(cov_report()) cov_export_lcov("coverage.lcov") // import into genhtml / codecov / coveralls
cov_get(file, line) reads back the hit count for one instrumented location — 0 after a full test run is the signal a coverage-driven CI gate is actually looking for: a mark with zero hits means every test you have manages to avoid that line entirely. cov_report() returns a human-readable summary across every mark recorded so far. cov_export_lcov(path) writes the same data in LCOV format — the format nearly every coverage visualizer (genhtml, Codecov, Coveralls) already knows how to turn into an annotated, line-by-line HTML view of your source. cov_reset() clears every recorded hit; call it between independent test suites (unit vs. integration, say) so one suite's coverage doesn't inflate another's report.
DO: Place cov_mark calls at the entry of each function you actually care about covering, or generate them from a @test hook so instrumentation and test registration stay in one place. DON'T: Expect coverage numbers "for free" the way go test -cover or pytest --cov auto-instruments every line by rewriting the binary — NOVA's cov_mark is manual instrumentation, so a function with zero cov_mark calls in it will never appear in cov_report() at all, covered or not.
TAP output for CI
TAP (Test Anything Protocol) is a plain-text, line-oriented test result format that predates almost every modern CI system and is still understood natively by Jenkins, GitHub Actions test-reporter plugins, prove, and tap-spec. test_run's human-readable ok name (N assertions) output is great for a terminal but isn't valid TAP — it can't be piped into a generic TAP consumer. test_run_tap(name, fn, id) produces the exact format those tools expect: it runs fn(), catches any panic, and prints ok <id> - <name> or not ok <id> - <name>, using the numeric id you supply rather than an auto-incrementing counter — so tests can be run in any order, or as a filtered subset, without the id sequence breaking.
print("1..2") // TAP plan header: declares exactly 2 tests will follow test_run_tap("addition", fn() assert_eq(1 + 1, 2), 1) test_run_tap("division by zero", fn() assert_throws(fn() 1 / 0, "divide"), 2)
ok 1 - addition
not ok 2 - division by zero
The second line reads not ok even though 1 / 0 genuinely panics — the assertion inside the wrapped test still fails, because assert_throws is checking for the literal substring "divide" inside the real panic message, and NOVA's actual division-by-zero panic text does not contain that exact substring. This is the same substring-match footgun called out in the previous section: a panic happening is not enough for assert_throws to pass — the expected-message argument has to actually appear in the real text.
DO: Print the "1..<N>" plan header before the first test_run_tap call, with N matching the exact number of tests that will run — prove and similar TAP consumers treat a missing or mismatched plan count as a failure of the whole suite, even if every individual test line says ok. DON'T: Mix test_run and test_run_tap output in the same stream — test_run's ok name (N assertions) lines are not valid TAP syntax and will desync a TAP parser reading the combined output.
Semantic versioning utilities
Dependency resolution and package management need a way to compare version strings that respects semver's numeric structure, not string ordering — "1.9.0" must sort before "1.10.0" even though a plain character-by-character string compare would get it backwards. NOVA ships a small, self-contained semver toolkit as compiler builtins: no import, no external package.
| Function | Signature | What it does |
|---|---|---|
semver_parse(str) | string -> [int, int, int] | Splits "2.3.1" into [major, minor, patch] |
semver_compare(a, b) | (string, string) -> int | Returns -1, 0, or 1 — numeric, not lexicographic |
semver_satisfies(version, constraint) | (string, string) -> bool | Checks a version against a constraint like ">=1.0.0" |
semver_format(parsed) | [int, int, int] -> string | Renders a parsed triple back to "major.minor.patch" |
semver_compatible(a, b) | (string, string) -> int | 1 when a and b are compatible under semver rules (same major version) |
fn test_semver_functions() parsed = semver_parse("2.3.1") assert_eq(parsed, [2, 3, 1]) assert_eq(semver_compare("1.0.0", "2.0.0"), -1) assert_true(semver_satisfies("1.5.3", ">=1.0.0")) assert_eq(semver_format(parsed), "2.3.1") assert_eq(semver_compatible("1.2.0", "1.5.0"), 1) // same major version -- compatible fn main() test_run("semver parse, compare, satisfy, format, compatible", test_semver_functions) test_summary()
All tests passed: 5 assertions in total
DO: Route every version comparison through semver_compare or semver_satisfies, even for what looks like a simple check — version strings are not safe to compare with </> as plain strings. DON'T: Reach for a regex or a hand-rolled split(".") to parse a version string — semver_parse already does it, and hand-rolled parsing tends to break the moment a version string has an unexpected format.
nova test — running your whole suite from the command line
Every test file so far has been run one at a time: nova run test_utils.nova, then nova run test_auth.nova, and so on. That does not scale past a handful of files — you would have to remember and re-type every test file's name each time you want to know whether anything broke. nova test automates the discovery step: it looks for every file matching *_test.nova in the tests/ directory and in the current directory, builds and runs each one, and rolls the pass/fail counts up across all of them into a single report. This is the command a CI pipeline should call — not a maintained list of individual nova run invocations someone has to remember to update every time a new test file is added.
nova test
ok factorial of 0 (1 assertions)
ok factorial of 5 (1 assertions)
Running tests/string_test.nova...
ok string upper (1 assertions)
Running auth_test.nova...
ok valid login rejects wrong password (1 assertions)
3 files, 4 assertions, 0 failures
DO: Name every test file with the _test.nova suffix and put it in tests/ or leave it next to the module it tests in the project root — both locations are auto-discovered. DON'T: Expect nova test to find a file named the other way around, like test_math.nova — the discovery rule matches the _test.nova suffix, not a test_ prefix.
nova cov — per-line coverage reporting
A green nova test run proves the lines your tests reached behave correctly — it says nothing about the lines they never reached. nova cov <file> (alias nova coverage) builds the target with coverage instrumentation, runs it, and on exit reports which lines actually executed, broken down per function. It is the fastest way to find a dead branch, an error path nothing ever triggers, or a whole function no test calls at all.
nova cov math_test.nova
factorial() 6/6 lines 100%
fibonacci() 4/5 lines 80% (line 12 not covered)
Total: 10/11 lines covered (90.9%)
DO: Run nova cov once a feature "feels done," specifically to find the branches your own tests never exercised — exactly the blind spot a passing nova test run cannot reveal on its own. DON'T: Treat 100% line coverage as proof of correctness — it proves every line ran at least once, not that every input case was tested; a boundary bug can hide on a line that always executes with the same value.
17. Processes and channels
What is this? NOVA's concurrency model. spawn launches a green task — a lightweight coroutine with its own stack (~32KB), costing ~1 microsecond to create. channel() creates a typed queue. Tasks communicate exclusively through channels; they never share memory. No locks, no mutexes, no data races — by construction, not by discipline. This is the same concurrency model as Erlang/BEAM and Go goroutines, but with an important addition: channels are typed, so the compiler verifies that the sender and receiver agree on the data shape at compile time — runtime message-shape mismatches are impossible.
Green tasks vs OS threads — under the hood
NOVA's spawn creates a green task (sometimes called a coroutine, fiber, or lightweight thread). This is fundamentally different from a POSIX thread or a Java thread. Understanding the distinction explains why NOVA can run tens of thousands of concurrent tasks with low overhead.
| Property | OS thread | NOVA green task |
|---|---|---|
| Stack size | 1–8 MB (fixed, pre-allocated) | ~32 KB (fixed per task) |
| Creation cost | ~10–50 µs (kernel syscall) | ~1 µs (userspace only) |
| Max practical count | ~1,000–10,000 (limited by RAM) | ~100,000+ (limited by task stack × count) |
| Context switch cost | ~1–10 µs (kernel mode switch) | ~100 ns (register save/restore only) |
| Scheduler | OS kernel (preemptive) | NOVA runtime (cooperative, work-stealing N=1) |
| Blocking I/O | Blocks the whole thread | Parks the task; carrier thread runs others |
| Shared memory | Any data is sharable (races possible) | No sharing — values copied on send |
Parking on I/O is the key mechanism: when a green task calls tcp_recv, sleep, or any I/O operation, the NOVA runtime parks that task (suspends it without blocking a thread) and immediately runs the next runnable task. When the I/O completes, the runtime unparks the task and it continues. This is why you can have 10,000 concurrent TCP connections served by a single OS thread — each connection is a parked green task waiting for data, costing zero CPU while idle.
The mental model: each spawn is like adding a task to a to-do list. NOVA's runtime works through the list, running each task until it either finishes or parks on I/O. When a parked task's I/O is ready, it gets added back to the list. No OS thread is ever blocked waiting — the OS thread is always doing useful work.
Spawning a task
// Spawn an inline block — runs concurrently spawn print("I run in a green task") sleep(100) print("Done") print("main continues immediately") // Spawn a function call fn background_job(n) sleep(n) print("job {n} done") spawn { background_job(500) } spawn { background_job(200) } // Both run concurrently. Output order: 200 before 500.
Channels — sending and receiving
// channel() creates an unbounded queue ch = channel() // Producer task spawn for i in 1..5 send(ch, i * i) // deep-copies value into channel // Consumer (main task) for _ in 1..5 v = recv(ch) // blocks until a value arrives print(v) // 1, 4, 9, 16 (1..5 is exclusive of 5, so i = 1,2,3,4)
4
9
16
Multiple producers, one channel (fan-in)
fn worker(id, results) for i in 0..2 // exclusive range: i = 0, 1 -- 2 items per worker send(results, "worker-{id} item-{i}") fn main() results = channel() spawn { worker(1, results) } spawn { worker(2, results) } spawn { worker(3, results) } for _ in 0..6 // 3 workers × 2 items = 6 total -- must match EXACTLY or recv() hangs forever print(recv(results))
worker-1 item-1
worker-2 item-0
worker-2 item-1
worker-3 item-0
worker-3 item-1
(The interleaving of workers 1/2/3 varies run to run — three green tasks are racing to send, and the scheduler does not guarantee an order. The important invariant is the total COUNT: exactly 6 messages come out no matter what order they arrive in.)
DO: Make the receive count match the send count exactly — count how many messages will actually be sent (here: workers × items-per-worker) and use that as the loop bound. DON'T: Guess or over-estimate the receive count. recv(ch) blocks until a value is available — if you call it one time more than anything will ever send, that green task parks forever. This is the single most common concurrency bug in channel-based code: a silent, permanent hang with no error message.
Request-reply pattern (ping/pong)
// Each request carries its own reply channel fn database_actor(inbox) loop req = recv(inbox) result = do_query(req["sql"]) send(req["reply_to"], result) fn query(actor, sql) reply = channel() send(actor, {"sql": sql, "reply_to": reply}) recv(reply) // wait for response fn main() inbox = channel() spawn { database_actor(inbox) } rows = query(inbox, "SELECT * FROM users") print(rows)
Select — receive from whichever arrives first
a = channel() b = channel() spawn sleep(100) send(a, "from a") spawn sleep(200) send(b, "from b") v = select(a, b) // blocks on both; returns whichever arrives first print(v) // "from a" (arrived after 100ms)
Closing a channel — close(ch)
A producer has no built-in way to tell a consumer "I'm done sending" — the patterns earlier in this chapter work around that by having both sides agree on an exact count in advance (spawn N tasks, receive exactly N times). close(ch) is the alternative for streams whose length isn't known ahead of time: it marks the channel as finished. Any send() or try_send() issued after close returns -1 / 0 instead of enqueuing — the value is silently discarded, not queued — and once every value already in the channel has been drained, receive(ch)/recv(ch) stops parking and returns -1 as a sentinel instead of hanging forever. This turns an unknown-length producer into something a plain loop can consume safely, with no count negotiated up front.
ch = channel() spawn for i in 1..4 send(ch, i * i) // 1, 4, 9 close(ch) // no more values are coming // Drain until the close sentinel arrives -- no count needed up front total = 0 loop v = receive(ch) if v == -1 break total = total + v print(total) // 1 + 4 + 9 = 14
DO: Use close(ch) for streams where the consumer doesn't know the item count ahead of time — log tailing, paginated API results, any producer whose length depends on runtime data. DON'T: Rely on the -1 sentinel if -1 is a value your channel legitimately carries — receive(ch) cannot distinguish "the channel sent -1" from "the channel closed." For data that can be negative, wrap payloads in a small dict (e.g. {"done": false, "value": v}) or stick with the exact-count pattern used earlier in this chapter.
Non-blocking channel operations — try_recv and try_send
Every receive/send call seen so far in this chapter parks the calling green task until data is available or room frees up — the right default, since parking is nearly free on the cooperative scheduler. But some code genuinely cannot afford to park: a task juggling several channels in one loop, or a health check that would rather report "nothing yet" than freeze. try_recv(ch) and try_send(ch, value) are the non-blocking counterparts — they check the channel exactly once and return immediately either way. try_recv(ch) returns a 2-element list [got, value]: got == 1 means value was actually dequeued; got == 0 means the channel was empty right then (and value is meaningless, always 0). try_send(ch, value) returns 1 if the value was enqueued, 0 if the channel is full (bounded, at capacity) or already closed — either failure discards the value rather than retrying it.
ch = channel() send(ch, 1) send(ch, 2) send(ch, 3) // Drain everything currently queued, without ever parking total = 0 got = try_recv(ch) while got[0] == 1 total = total + got[1] got = try_recv(ch) print(total) // 6 // try_send never blocks, even on a full bounded channel bc = channel_bounded(1) ok1 = try_send(bc, 7) // 1 -- there was room ok2 = try_send(bc, 8) // 0 -- full; 8 is dropped, not queued print(ok1) print(ok2)
1
0
DO: Reach for try_recv/try_send in a task that must service several channels or sources in one loop without letting any single one stall it — a scheduler, a multiplexer, a best-effort metrics tick. DON'T: Use try_send as a cheaper channel_bounded — a value try_send drops because the channel was full is gone permanently, with no error raised. If losing a message would be a bug, use send() (which parks and backpressures) instead of polling with try_send in a retry loop.
Why NOVA's model is safer than alternatives
| Language | Concurrency model | Data race possible? | Learning curve |
|---|---|---|---|
| NOVA | Green tasks + channels | No — deep-copy on send | Minimal — just spawn/send/recv |
| Go | goroutines + channels | Yes — shared memory + mutex needed | Low |
| Rust | threads + Arc/Mutex or channels | No — enforced by borrow checker | High (lifetimes) |
| Python | threads + GIL | Yes — but GIL limits parallelism | Low (but asyncio adds complexity) |
| Java | threads + synchronized/locks | Yes — deadlock and race common | High (locks are complex) |
| Erlang | actors + message passing | No — same model as NOVA | Moderate |
Key insight: In NOVA, send(ch, data) deep-copies data into the channel. The sender and receiver never touch the same memory. Data races are structurally impossible — there is no shared memory to race on. You get concurrent safety with zero syntax overhead.
Data race proof — Python vs NOVA
Here is a real data race in Python that produces wrong results every time (the final count should be 200,000 but isn't):
# Python — this code has a data race import threading counter = 0 def increment(): global counter for _ in range(100000): counter += 1 # NOT atomic: reads, increments, writes — race here! t1 = threading.Thread(target=increment) t2 = threading.Thread(target=increment) t1.start(); t2.start() t1.join(); t2.join() print(counter) # Should be 200000 — actually ~143287 or some random wrong number
The bug: counter += 1 is three operations: read counter, add 1, write back. If two threads both read at the same moment (both see 50), both write 51 — the second write overwrites the first. One increment is silently lost.
The equivalent NOVA code cannot have this bug — by design:
fn main() ch = channel() // Each task has its OWN independent counter — no sharing spawn fn() count = 0 i = 0 while i < 100000 count = count + 1 i = i + 1 send(ch, count) spawn fn() count = 0 i = 0 while i < 100000 count = count + 1 i = i + 1 send(ch, count) a = recv(ch) b = recv(ch) print(a + b) // Always exactly 200000 — no race possible
Why NOVA is safe: Each green task has its own count variable. There is no shared memory. The final addition happens in the main task after both subtotals arrive via channels. The result is always correct. There is nothing to synchronize — there is nothing shared.
Ownership transfer — why send is a deep copy
When you send a complex value (a list or dict) over a channel, NOVA deep-copies it:
data = [1, 2, 3] ch = channel() spawn fn() received = recv(ch) push(received, 4) // modifying the COPY print("task sees: {received}") // [1, 2, 3, 4] send(ch, data) print("main sees: {data}") // [1, 2, 3] — unchanged
task sees: [1, 2, 3, 4]
Line-by-line:
send(ch, data)— NOVA deep-copiesdatabefore putting it in the channel. The originaldatais completely untouched.received = recv(ch)— the spawned task gets a completely independent copy: a new list[1, 2, 3]with its own memory.push(received, 4)— the spawned task modifies its own copy. This does NOT affectdatain the main task.print("main sees: {data}")— prints[1, 2, 3]. Unchanged. The main task never even knows the spawned task modified anything.
This deep-copy design is why NOVA requires zero locks, zero mutexes, zero synchronized keywords. There is simply nothing shared to protect.
send_move — when the compiler skips the copy
send(ch, value) deep-copies by contract — that guarantee is what makes cross-task sharing structurally impossible, and it never changes. But a full copy is only NECESSARY when the sender still has another use for value after the send. When the compiler can prove value's last use in the function is exactly this send call — nothing after it reads the variable again — it silently lowers the call to send_move: the already-built value is transferred directly into the channel instead of copied. Same source code, same observable behavior on both ends, zero-copy exactly when it's provably safe.
fn build_report(rows) report = [] for r in rows push(report, r * 2) report // a freshly-built list fn produce(ch) data = build_report([1, 2, 3, 4, 5]) send(ch, data) // data is never read again after this line — // the compiler lowers this to send_move, not a copy fn consume(ch) result = recv(ch) print(result) // [2, 4, 6, 8, 10] ch = channel() spawn { produce(ch) } consume(ch)
Compare a version where data IS used again after the send — the same source pattern, but now a real copy is required and the compiler leaves the call as an ordinary deep-copying send:
fn produce_and_log(ch) data = build_report([1, 2, 3]) send(ch, data) print("sent: {data}") // data is used AGAIN — a real deep copy is required here
DO: Write the natural, straight-line version of producer code — build the value, then send it — and let the compiler find the last-use optimization on its own; there is no special syntax to request it. DON'T: Try to force send_move by restructuring code around it — it is a compiler-proven optimization, not a keyword. The moment your code genuinely reuses the value after send, a real copy is correct, and the compiler will still produce one.
Bidirectional communication — the request/response pattern
When a task needs to send a request and get a specific response back, use two channels: one for each direction. This is simpler than embedding a reply channel in the message:
request_ch = channel() response_ch = channel() // Server task — runs in a loop, processing requests spawn fn() loop msg = recv(request_ch) if msg == "quit" then break send(response_ch, upper(msg)) // convert to uppercase and reply // Client: send requests, receive responses send(request_ch, "hello") print(recv(response_ch)) // HELLO send(request_ch, "world") print(recv(response_ch)) // WORLD send(request_ch, "quit") // shut the server task down
request_ch— sends messages TO the server task.response_ch— receives answers FROM the server task. Two channels, two directions, no confusion.- The server task runs a
loop, processing one request at a time. When it receives"quit", it breaks and the task ends. - The pattern is synchronous from the client's perspective:
sendthenrecv. If you need multiple in-flight requests, embed a per-request reply channel in the message (see the actor pattern above).
Receive with timeout — don't block forever
When waiting on a channel, you sometimes need to give up if no data arrives within a deadline:
ch = channel() // Nobody sends on this channel result = recv_timeout(ch, 1000) // wait up to 1000ms if result == null print("timed out — no data arrived") else print("received: {result}")
recv_timeout(ch, ms) blocks for at most ms milliseconds. If data arrives, it returns the value. If the timeout expires, it returns null. Essential for production code: a server handler that blocks forever waiting for a dead upstream service will exhaust your green task pool.
Channels vs mutexes vs actors — concurrency model comparison
| Mechanism | Language | How it works | Problems |
|---|---|---|---|
| Mutexes / locks | C, C++, Java, Go | Shared memory; lock before access | Deadlocks, priority inversion, forgotten unlocks, race conditions when locks are missed |
| Async/await | Python, JavaScript, Rust, C# | Cooperative coroutines; explicit await at every suspension point | Colored functions: async infects callers all the way up. Non-async callers can't use async functions. |
| Goroutines + channels | Go | Lightweight threads; CSP channels for communication | Goroutines can still share memory through pointers. Channels are not enforced — you can bypass them. |
| Actors | Erlang, Akka, Elixir | Isolated processes; message passing only | Messages are untyped dictionaries. No compile-time guarantee that sender and receiver agree on shape. |
| Green tasks + channels | NOVA | Green tasks own their data; channels copy values on send | None by construction: no shared memory = no races. Typed channels = compiler checks both ends agree. |
NOVA's design deliberately takes Go's "share by communicating" slogan and enforces it structurally. In Go, you CAN bypass channels and share memory through pointers — the language only recommends against it. In NOVA, values passed between tasks are deep-copied on send, making aliasing across task boundaries physically impossible at the language level.
18. Advanced concurrency
What is this? Advanced patterns built on spawn/channel: bounded channels for backpressure, parallel map for data-parallel work, supervisors for fault tolerance, and timeouts.
Bounded channels (backpressure)
// Unbounded: channel() — send never blocks ch1 = channel() // Bounded: channel_bounded(N) — send blocks when N items queued ch2 = channel_bounded(10) // producer pauses when consumer falls behind
Use bounded channels for streaming pipelines: if the consumer is slow, backpressure propagates upstream and the producer naturally slows down. Without bounds, a fast producer fills memory unboundedly.
Channel-as-mutex — mutual exclusion without lock objects
A channel_bounded(1) pre-loaded with one token is a mutex. recv acquires the lock (blocks if another task holds it); send releases it (wakes exactly one waiter). No OS lock objects, no synchronized blocks, no try/finally ceremony — the channel itself is the entire lock.
// Create a mutex: a bounded(1) channel holding one token let mtx = channel_bounded(1) send(mtx, 1) // pre-load the token — "unlocked" // Acquire: take the token (blocks if another task holds it) recv(mtx) // ... critical section ... // Release: return the token (wakes one waiting task) send(mtx, 1)
Crash-safe mutex with on_exit_send: if a task panics while holding the lock, defer send(mtx, 1) would NOT run — defer is compile-time and a panic jumps past it. Use on_exit_send instead:
// 5 forks on a table, each a channel-mutex let forks = [fork_new() for _ in 0..5] let done = channel() fn fork_new() -> channel<int> let f = channel_bounded(1) send(f, 1) f fn philosopher(id, first, second, done) for meal in 0..3 recv(first) // pick up fork (acquire) on_exit_send(first, 1) // crash-safe: returns fork even on panic recv(second) on_exit_send(second, 1) sleep(1) // eat send(second, 1) // put down fork (release) cancel_on_exit_val(second, 1) // cancel safety net — already returned send(first, 1) cancel_on_exit_val(first, 1) send(done, id) // Spawn 5 philosophers — resource ordering (min/max) prevents deadlock for i in 0..5 let li = i let ri = (i + 1) % 5 spawn philosopher(i, forks[min(li, ri)], forks[max(li, ri)], done) for _ in 0..5 recv(done) // all 5 finish — no deadlock, no starvation
Line-by-line breakdown:
fork_new()— creates achannel_bounded(1)and pre-loads one token. This is a mutex.recv(first)— picks up a fork. Blocks if another philosopher holds it.on_exit_send(first, 1)— registers "if this task crashes, send1back tofirst." The fiber trampoline drains this on both normal exit and panic — unlikedefer, which only runs on normal exit.cancel_on_exit_val(first, 1)— after the normalsend(first, 1)release, cancel the safety net so the fork isn't returned twice.forks[min(li, ri)]— resource ordering: always pick up the lower-numbered fork first. This breaks the circular-wait cycle that causes deadlock.
This is not a trick — it is the idiomatic NOVA pattern for mutual exclusion, and std/sync/mutex.nova wraps it as mutex_new()/lock()/unlock(). What makes it more powerful than a traditional mutex:
- A channel-mutex is a value — you can pass it to a function, store it in a struct, put it in a list. Java's
synchronizedis a keyword bound to an object's monitor; Go'ssync.Mutexis a struct that must not be copied. A NOVA channel-mutex is just a channel handle — it composes freely. - You can select/timeout on lock acquisition —
select_timeout(mtx, 500)gives "try to acquire, give up after 500ms." Java needs a completely different API (tryLock(timeout)) for this; NOVA uses the sameselect_timeoutthat works on any channel. - Change the capacity and it becomes a semaphore —
channel_bounded(N)with N tokens pre-loaded is a counting semaphore. Same primitive, different capacity.std/sync/semaphore.novawraps this assem_new(n)/sem_acquire()/sem_release().
The Dining Philosophers in 40 lines: five philosophers, five forks (each a channel-mutex), resource ordering to prevent deadlock, on_exit_send for crash-safe fork release — see leetcode/1226_the_dining_philosophers.nova for the complete working solution with a crash-recovery test.
DO: Use channel_bounded(1) when you need mutual exclusion between green tasks — it composes with select/select_timeout and is crash-safe when paired with on_exit_send (see the crash-safe cleanup section). DON'T: Use defer send(mtx, 1) as the release — defer is compile-time and does not run if the task panics, silently leaking the lock forever. Use on_exit_send(mtx, 1) instead, and cancel it with cancel_on_exit_val(mtx, 1) after the normal release.
Tradeoffs vs a real futex: an uncontended channel-mutex is heavier than an OS futex (a channel round-trip vs a single atomic compare-and-swap), and channel-mutexes cannot express reentrant or reader-writer locking (those need separate primitives — std/sync/rwlock.nova). For hot-path counters where even a channel is too heavy, use atomics.
Parallel map (pmap)
// Concurrently fetch all URLs, collect results in order urls = ["http://a.com", "http://b.com", "http://c.com"] bodies = pmap(urls, url => http_get(url)) print(len(bodies)) // 3 — results in same order as input // CPU-parallel: square all numbers using all cores nums = range(1000000) squares = pmap(nums, x => x * x)
Supervisor pattern — fault-tolerant tasks
There is no single spawn_monitor convenience call — supervision is built from the two primitives you already have: spawn returns a task ID (pid), and monitor(pid) gives you a channel that reports back when that task exits:
pid = spawn fn() risky_db_operation() mon = monitor(pid) status = receive(mon) // blocks until the task exits; non-zero means it crashed if status != 0 log_error("task failed with status {status}", null)
Select with timeout — manual approach
The manual approach uses two channels — one for the result and one for a timer — and spawns two tasks to race them:
result_ch = channel() timeout_ch = channel() spawn { send(result_ch, slow_query()) } spawn sleep(500) send(timeout_ch, "timeout") answer = select(result_ch, timeout_ch) if answer == "timeout" print("Timed out after 500ms") else print("Got: {answer}")
Fan-out pattern — parallel work collection
The fan-out pattern spawns multiple tasks to do work concurrently, then collects all results. This is NOVA's answer to Go's goroutine fan-out, Python's asyncio.gather(), and Java's ExecutorService.invokeAll():
fn main() result_ch = channel() for i in 0..5 spawn fn() answer = i * i send(result_ch, answer) total = 0 for i in 0..5 total = total + receive(result_ch) print("sum of squares: {total}") // 0 + 1 + 4 + 9 + 16 = 30
Line-by-line breakdown:
result_ch = channel()— creates a single shared channel. All spawned tasks will send their results on this one channel. The channel has no capacity limit (unbounded), so sends never block.for i in 0..5— iterates i = 0, 1, 2, 3, 4. This spawns 5 tasks (NOVA ranges are exclusive on the right end (half-open: includesa, excludesb) — to get exactly N tasks, range up to N, not N-1). Each task runs in parallel as a green task — they all execute concurrently on the cooperative scheduler.spawn fn()— creates a new green task. The closure captures a copy ofiat the moment of creation. This is critical: if closures captured by reference, all tasks would see the samei(the final value 4 after the loop). NOVA's capture-by-value makes this bug impossible.answer = i * i— each task computes its own square. Task 0 computes 0, task 1 computes 1, task 2 computes 4, task 3 computes 9, task 4 computes 16. They run concurrently, so the order they send results may differ.send(result_ch, answer)— each task sends its result to the shared channel.sendis non-blocking here because the channel is unbounded.- Second
for i in 0..5— collects exactly 5 results (one per spawned task). We receive the right number because we spawned exactly 5 tasks — both loops must use the SAME bound, or you either hang waiting for a result that never comes, or leave an unread result in the channel. Order of results may vary — task 3 might finish before task 1, but the total is always correct. total = total + receive(result_ch)—receiveblocks until a task sends. If all tasks are still running,receiveparks the current task and resumes it when a result arrives. The scheduler gives CPU time to the spawned tasks in the meantime.print("sum of squares: {total}")— prints0 + 1 + 4 + 9 + 16 = 30. The// 0 + 1 + 4 + 9 + 16 = 30comment is for clarity; the actual output is just30.
Why this is better than a sequential loop: A sequential loop computing squares and summing would take time proportional to N. Fan-out runs all N tasks concurrently — if each task were doing real work (HTTP requests, database queries, file reads), the total time would be approximately the time of the SLOWEST task, not the sum of all tasks. For N=5 HTTP requests each taking 200ms, sequential takes 1000ms; fan-out takes ~200ms.
Fan-out with N workers — practical pattern:
// Fan-out: fetch N URLs concurrently, collect all responses fn fetch_all(urls) ch = channel() n = len(urls) for url in urls spawn fn() body = http_get(url) // parks while waiting for response send(ch, body) results = [] for i in 0..n // exclusive range — 0..n gives exactly n iterations, matching the n spawned tasks push(results, receive(ch)) results urls = ["http://api.example.com/a", "http://api.example.com/b", "http://api.example.com/c"] responses = fetch_all(urls) print("fetched {len(responses)} responses")
DO: Use fan-out when you have N independent operations that can run concurrently (HTTP requests, DB queries, file reads). DON'T: Spawn more tasks than you have results to collect — if you spawn 5 tasks but only receive 4 times, the 5th receive never happens and the unread result stays in the channel forever (a channel leak).
Monitors — watching for task completion
A monitor is a channel that receives a notification when a spawned task finishes (whether it completed normally or crashed). This is the building block for fault-tolerant supervision:
ch = channel() pid = spawn fn() send(ch, 42) // send the result mon = monitor(pid) // create a monitor for this task result = receive(ch) // get the task's output status = receive(mon) // get notified when task exits print(result) // 42 print(status) // exit status
0
Line-by-line breakdown:
pid = spawn fn() ...—spawnreturns the task ID (pid) of the newly created task. Normally you discard it, but here we capture it so we can monitor the task.mon = monitor(pid)— Creates a monitor channel for the given task ID. This is a special channel that will automatically receive an exit message when the task finishes.result = receive(ch)— Waits for the task to send its result onch.status = receive(mon)— Waits for the monitor to deliver an exit notification. This tells you the task finished.
Why monitors matter: Without monitors, you have no way to know when a task finishes or if it crashed. Monitors let you write code that knows exactly when workers are done — essential for fan-out patterns and supervision trees.
panic — deliberately crashing a task
Every crash a monitor reports — an out-of-bounds index, an unwrap() on an Err, a division by zero — ultimately goes through one mechanism: panic(message). It's also a builtin you can call directly, and doing so is the normal way to fail fast on an invariant your code can't recover from ("this branch should be unreachable", "config is malformed beyond repair"). panic() is scoped to the CURRENT TASK, not the whole process: calling it inside a spawned task unwinds and terminates only that task, while every other task — including main — keeps running untouched. The one exception is calling panic() in the main/root task itself, or letting a panic propagate out of a task nobody is monitoring: there, no supervisor is left to observe the crash, so the runtime prints a backtrace and the whole program exits. This is exactly why the fan-out and supervision patterns earlier in this chapter always pair spawn with monitor() — monitoring is what turns an unhandled crash into a recoverable event instead of a silent task disappearance.
fn risky_worker() panic("disk full") pid = spawn risky_worker() mon = monitor(pid) status = receive(mon) // 1 -- panic() inside the task crashed it print(status)
DO: Use panic(message) for conditions that indicate a genuine bug or unrecoverable state — not for expected, recoverable failures (parse errors, missing files), which should return a Result via err(...) instead (Chapter 9) so the caller can handle them with match/?/with. DON'T: Call panic() in the main task expecting the rest of your program to survive — if there's no spawn/monitor between the panic and your program's entry point, the crash takes the whole process down.
exit_reason — retrieving the crash message
monitor()'s status tells you only THAT a task exited and whether it was normal (0) or a crash (1) — a boolean-ish signal, not a diagnosis. exit_reason(pid) fills that gap: called after a monitor has already reported the exit, it returns the actual message string — exactly what was passed to panic(), or the runtime's own wording when the crash came from unwrap() or another builtin invariant (e.g. "unwrap called on Err/None"). For a task that returned normally, exit_reason is always the literal string "normal". This is the piece that turns a monitor from "something broke" into a log line a human — or a supervisor's retry policy — can actually act on.
fn crasher() panic("boom") fn good_worker() return 0 p1 = spawn crasher() m1 = monitor(p1) receive(m1) print(exit_reason(p1)) // boom p2 = spawn good_worker() m2 = monitor(p2) receive(m2) print(exit_reason(p2)) // normal
normal
DO: Call exit_reason(pid) only AFTER receiving that task's exit status from its monitor — the two are meant to be read together, status first, reason second. DON'T: Skip monitor() and call exit_reason() speculatively on a task you merely spawned — there's nothing to report until the runtime has actually observed and recorded that task's exit.
Supervision pattern — watch multiple workers
Use monitor() in a loop to supervise multiple concurrent workers and collect their results:
fn worker(id, result_ch) // ... do work ... send(result_ch, "worker {id} done") fn supervisor() result_ch = channel() for i in 0..3 pid = spawn fn() worker(i, result_ch) monitor(pid) // watch each worker // Collect all results for i in 0..3 print(receive(result_ch)) supervisor()
worker 1 done
worker 2 done
Line-by-line breakdown:
result_ch = channel()— A shared channel where all workers send their results.for i in 0..3— Spawns 3 workers (i = 0, 1, 2 — NOVA ranges are exclusive on the right end).pid = spawn fn() worker(i, result_ch)— Spawns each worker as a green task.spawnreturns the task ID.monitor(pid)— Registers a monitor for each worker. If any worker crashes, the supervisor can detect it via the monitor channel.- Second
forloop — Collects one result per worker. Since we spawned 3 workers, we receive 3 messages.
Compare to Erlang: This pattern is identical to Erlang's supervisor/worker pattern. NOVA's process model is deliberately Erlang-shaped — fault tolerance is a first-class design goal, not an afterthought. A crashed worker sends an exit notification to its monitor, which can decide to restart it.
DO: Use monitor(pid) whenever you spawn a task that must either succeed or be detected as failed. DON'T: Spawn tasks and just hope they finish — if a task crashes silently, your program will hang on receive(result_ch) forever.
Process linking — process_link, process_monitor, process_demonitor, process_exit_notify
monitor(pid) (above) is deliberately simple: one call, one permanent channel, no way to change your mind. These four lower-level primitives are the raw building blocks underneath that simplicity, for the cases monitor() can't cover. process_monitor(watcher, target) is a cancellable variant of monitor(): instead of a channel, it returns an integer reference, and process_demonitor(ref) revokes exactly that registration by reference — something monitor() offers no way to do once it's set up. process_link(a, b) registers a bidirectional link record between two process IDs, the building block Erlang-style supervisors use to say "these two processes' fates are tied together." process_exit_notify(pid, reason) is the explicit notification hook: unlike monitor(), which the runtime fires automatically the instant a task exits, nothing calls process_exit_notify for you — your own supervision code decides when, and with what reason, to notify a pid's watchers. That's what makes these primitives suited to protocols monitor() can't express: several independently-cancellable watchers on the same target, or application-defined reason values instead of just a crash message.
fn worker() sleep(50) watcher = self_pid() target = spawn worker() ref = process_monitor(watcher, target) // returns a cancellable int reference print(ref > 0) // true -- registration succeeded process_demonitor(ref) // cancel it -- nothing will ever notify this ref again process_link(watcher, target) // register a bidirectional link record between the two
DO: Reach for process_monitor/process_demonitor when a watch needs a teardown path — a request that timed out, a worker being retired, anything where you might change your mind about watching. monitor(pid) has no equivalent cancellation. DON'T: Expect process_link or process_exit_notify to automatically cascade a crash the way monitor()/exit_reason() do — in NOVA's runtime these four are explicit registration/notification primitives that YOUR supervision code drives end-to-end; for zero-extra-wiring "tell me when this task exits," use monitor(pid) instead.
recv_timeout — non-blocking receive with deadline
recv_timeout(ch, ms) is the clean way to add a timeout to any receive. It waits at most ms milliseconds and returns null if nothing arrives in time:
ch = channel() // Nobody sends on this channel result = recv_timeout(ch, 1000) // wait up to 1000ms if result == null print("timed out after 1 second") else print("got: {result}")
Real-world pattern — request with timeout:
fn fetch_with_timeout(url, timeout_ms) ch = channel() spawn fn() body = http_get(url) send(ch, body) result = recv_timeout(ch, timeout_ms) if result == null "error: timeout after {timeout_ms}ms" else result print(fetch_with_timeout("http://example.com", 5000))
Line-by-line breakdown:
spawn fn() { body = http_get(url); send(ch, body) }— The HTTP request runs in a separate green task. If the server is slow, the spawned task parks until the response arrives — but crucially, the main task is not blocked.recv_timeout(ch, timeout_ms)— The main task waits for the result channel, but gives up aftertimeout_msmilliseconds. Returnsnullon timeout.- The
if result == nullcheck — handles both the timeout case (returns the error message) and the success case (returns the body).
Compare to Go: Go uses a select statement with a time.After() channel for timeouts. NOVA's recv_timeout is simpler — one function call instead of a two-case select with a timer channel. Same semantics, less ceremony.
select_timeout — multi-channel select with deadline
select_timeout(ch1, ch2, ms) waits for whichever channel delivers first, but gives up and returns null if neither delivers within the timeout:
ch1 = channel() ch2 = channel() spawn fn() sleep(300) send(ch1, "from service A") result = select_timeout(ch1, ch2, 500) // wait up to 500ms if result == null print("no data from either channel within 500ms") else print("got: {result}") // "from service A" — arrives at 300ms, before 500ms timeout
Line-by-line breakdown:
spawn fn() { sleep(300); send(ch1, "from service A") }— Service A responds in 300ms.result = select_timeout(ch1, ch2, 500)— Wait for EITHERch1orch2, giving up after 500ms. Since service A responds in 300ms (before the 500ms timeout),resultgets"from service A".- If both services were down or slow (>500ms),
resultwould benull.
Real-world use case — fastest-of-N services: Send the same request to multiple redundant services and take the first response. If any service is down, the timeout prevents your code from hanging:
ch_primary = channel() ch_backup = channel() spawn fn() { send(ch_primary, http_get("http://primary-db/data")) } spawn fn() { send(ch_backup, http_get("http://backup-db/data")) } // Use whichever responds first, timeout after 3 seconds data = select_timeout(ch_primary, ch_backup, 3000) if data == null print("all services unavailable") else print("got data: {data}")
async / await — OS thread pool
async and await are ordinary built-in functions in NOVA, not keywords — there is no colored function-coloring problem, no "async all the way down." async(fn() ...) submits a closure to a real OS thread pool and returns a future immediately; await(future) blocks the calling task until that future resolves. Use this — not spawn — for genuinely CPU-bound work (compression, image processing, crypto) that would otherwise hog the cooperative scheduler that green tasks share.
let f = async(fn() compute_heavy(data)) let result = await(f) // Await multiple let futures = [async(fn() task1()), async(fn() task2())] let results = await_all(futures) // Or race: first to finish let winner = await_any(futures) // [index, value]
DO: Reach for async/await when the work is CPU-bound and would block the cooperative scheduler. DON'T: Use it for I/O-bound work (HTTP calls, DB queries, file reads) — spawn and channels already park cheaply on I/O without consuming an OS thread; wrapping I/O in async just burns a thread-pool slot for no benefit.
pfilter / pfor — parallel data operations
pfilter and pfor are data-parallel operations: the input is split into chunks, each chunk runs on its own thread, and — for pfilter — results are reassembled in the original order. Use these for large, uniform, CPU-bound batches where pmap's per-item overhead (Chapter 18) isn't worth paying and you just want "do this over the whole array, using every core."
let evens = pfilter(nums, fn(x) x % 2 == 0) // parallel filter pfor(0, 1000, fn(i) process(data[i])) // parallel for
pfor is fire-and-forget — it runs process(data[i]) for every i in the range and returns once all chunks finish; it has no return value of its own, so it is for side effects (mutating a shared buffer under the hood, writing to a file, updating a counter), not for building a new collection — use pfilter or pmap when you need a result back.
Erlang-style mailbox API
Every task has an implicit mailbox — a lower-ceremony alternative to explicitly creating and passing a channel() around. self_pid() gets the current task's own ID, mailbox_of(pid) gets another task's mailbox to send to, send_msg(mailbox, msg) delivers a message, and recv_msg() blocks on your own mailbox — no channel value needs to be threaded through function calls at all.
let me = self_pid() let worker = spawn do_work() send_msg(mailbox_of(worker), [me, "compute", data]) let reply = recv_msg() // blocks on own mailbox
Compare to Erlang/OTP's self(), Pid ! Msg, and receive — this API is deliberately shaped the same way, right down to sending your own pid inside the message so the recipient knows where to reply. Use explicit channel()s (Chapter 17) when a pipeline has a fixed, known shape; use mailboxes when tasks address each other dynamically by pid, the way Erlang actors do.
Selective receive — pattern-matching your own mailbox
recv_msg() above takes whatever message is next, no matter its shape — strict first-in-first-out. NOVA also has a second, more powerful receive form: a keyword, not a function call, that pattern-matches against the calling task's OWN mailbox with real match arms, exactly like Erlang's receive ... after. Each arm is a pattern — optionally guarded with if — followed by => and a body; the FIRST arm whose pattern matches the OLDEST queued message wins. Critically, a message that matches NO arm in the current receive block isn't lost or pushed to the back of the queue — it's deferred into a save-queue and retried against the NEXT receive the task executes. This is true selective receive: a task always processes messages in the order its code cares about, not the order they happened to arrive in.
fn worker(parent) receive 1 => send_msg(parent, 100) receive 2 => send_msg(parent, 200) me = self_pid() w = spawn worker(me) wb = mailbox_of(w) send_msg(wb, 2) // arrives first -- but no arm in the FIRST receive matches 2, so it's deferred send_msg(wb, 1) // matches the first receive's only arm print(recv_msg()) // 100 -- "1" ran even though "2" arrived earlier print(recv_msg()) // 200 -- the deferred "2" is now matched by the second receive
200
Arms can carry a guard, and a trailing after MS clause runs its own body if no arm matches within MS milliseconds instead of parking forever — after 0 polls once and returns immediately, a non-blocking drain check:
fn responder(parent) receive n if n > 0 => send_msg(parent, n * n) n if n <= 0 => send_msg(parent, 0) after 1000 send_msg(parent, -1) // no message arrived within 1 second me = self_pid() w = spawn responder(me) send_msg(mailbox_of(w), 6) print(recv_msg()) // 36
DO: Use selective receive when one task's protocol has several distinct message shapes that can legitimately arrive out of order (a server handling both fire-and-forget "cast" messages and reply-expecting "call" messages — the classic gen_server split) — the save-queue means you never hand-write your own re-queue logic. DON'T: Reach for it in a simple, single-shape producer/consumer pipeline — re-scanning the save-queue on every receive has a real cost per deferred message, so an explicit channel() (Chapter 17) is both cheaper and easier to read when there's only ever one message shape in flight.
mailbox_len and try_recv_msg
Two more pieces round out the mailbox API. mailbox_len(pid) reports how many messages are currently queued and UNREAD in a task's mailbox — your own, via self_pid(), or another task's — without consuming any of them; a queue-depth read for supervisors that want to notice a worker falling behind before its mailbox grows unbounded. try_recv_msg() is the non-blocking counterpart to recv_msg(): it returns [got, value] immediately instead of parking, mirroring try_recv(ch)'s shape from Chapter 17 — got == 1 means a message was actually dequeued (FIFO order), got == 0 means the mailbox was empty right then.
me = self_pid() send_msg(me, 7) send_msg(me, 8) print(mailbox_len(me)) // 2 -- two unread messages queued t1 = try_recv_msg() print(t1) // [1, 7] -- oldest message first t2 = try_recv_msg() print(t2) // [1, 8] t3 = try_recv_msg() print(t3) // [0, 0] -- empty, and it did not block print(mailbox_len(me)) // 0
[1, 7]
[1, 8]
[0, 0]
0
DO: Use mailbox_len as a load signal in a supervisor loop deciding whether to shed work or spin up another worker. DON'T: Use mailbox_len to decide whether it's "safe" to call recv_msg() without parking — the count can change between your check and your call (another task can send in between). If you need a non-blocking read, call try_recv_msg() directly rather than checking mailbox_len() first.
Atomic operations
Atomics give you a lock-free counter or flag that multiple tasks can update concurrently without a channel or a mutex. Use them for hot-path counters and compare-and-swap (CAS) algorithms where the overhead of a full channel round-trip would dominate the actual work.
let counter = atomic_new(0) atomic_add(counter, 1) let prev = atomic_cas(counter, 1, 5) // CAS: prev==1, counter now 5 let val = atomic_get(counter) // 5
atomic_cas(cell, expected, new) atomically sets cell to new only if it currently holds expected, and always returns what the cell held just before the attempt — the standard building block for lock-free retry loops (increment-if-still-equal, optimistic updates).
DO: Use atomics for a single shared number or flag under contention (request counters, feature-flag toggles, spinlock-free state machines). DON'T: Reach for atomics to coordinate multi-step protocols or pass data between tasks — that's what channels and the mailbox API exist for; atomics only make ONE value safe to touch concurrently.
atomic_set(a, val) — unconditional overwrite
Alongside atomic_get/atomic_add/atomic_cas there is a fourth atomic primitive: atomic_set. It atomically overwrites the cell with a new value and returns nothing — there is no "expected" value to compare against, so it is simpler and cheaper than atomic_cas for the case where you just want to force a known state rather than conditionally update one.
let flag = atomic_new(0) atomic_set(flag, 1) // unconditional overwrite -- no comparison, no old value returned print(atomic_get(flag)) // 1 // Reset a shared counter between batches let processed = atomic_new(500) atomic_set(processed, 0) print(atomic_get(processed)) // 0
0
DO: Use atomic_set for a plain overwrite — resetting a counter between batches, publishing a flag once you already know its new value. DON'T: Reach for atomic_set when the update depends on the current value — two tasks calling atomic_set concurrently silently overwrite each other with no signal that a write was lost. If the new value depends on the old one, use atomic_cas in a retry loop instead.
sched_spawn and sched_spawn_on — the primitive under spawn
spawn is convenient sugar over the scheduler's actual entry point: sched_spawn(closure). Calling it directly matters when you need its sibling, sched_spawn_on(carrier_id, closure), which pins a task to a SPECIFIC carrier — an OS worker thread in a multi-carrier run — instead of letting the work-stealing scheduler place it wherever's free. Framework code that owns its own affinity story reaches for this: Forge's HTTP server schedules each accepted connection with sched_spawn_on so that requests on the same connection stay on the same carrier for cache locality, rather than migrating between cores mid-request. Everyday application code should keep using plain spawn — sched_spawn/sched_spawn_on exist for the framework and scheduler layer underneath it.
fn handle_conn(id) print("handling connection {id}") // sched_spawn is what spawn expands to -- same effect, called directly sched_spawn(fn(z) handle_conn(1)) // Pin this one to carrier 0 specifically, instead of letting the scheduler choose sched_spawn_on(0, fn(z) handle_conn(2))
handling connection 2
(As with plain spawn, the order the two tasks actually print in is not guaranteed — only that both run concurrently.)
DO: Reach for sched_spawn_on when you're building scheduler-aware framework code that needs carrier affinity (a connection pool, a per-core shard). DON'T: Use sched_spawn/sched_spawn_on in everyday application code in place of spawn — you gain a carrier-id parameter and nothing else; plain spawn already delegates to sched_spawn for you.
ws_init and ws_shutdown — bringing up the multi-core scheduler
By default NOVA runs a single-carrier (N=1) cooperative scheduler — every green task multiplexes onto ONE OS thread, which is exactly what lets single-process code erase all refcounting overhead and run at C speed. ws_init(n) opts into the multi-core work-stealing scheduler: it brings up an n-worker pool of OS carrier threads and returns the worker count it actually started with, so subsequent spawn/sched_spawn calls distribute across real cores instead of interleaving on one. ws_shutdown() tears the pool back down. This is the explicit, in-code counterpart to setting a carrier-count environment variable before launch — reach for ws_init when the decision to go multi-core needs to be made by the running program itself (a server sizing its pool to the machine it's actually deployed on), not by whoever happens to set an environment variable.
n = ws_init(4) // bring up a 4-worker pool print(n) // 4 -- confirms the pool started with exactly 4 workers ch = channel() i = 0 while i < 10 c = ch spawn send(c, 1) i = i + 1 received = 0 while received < 10 recv(ch) received = received + 1 print(received) // 10 -- all 10 tasks ran across the 4-worker pool ws_shutdown() // tear the pool back down
10
DO: Call ws_init once, early, near your program's entry point — before spawning the tasks you actually want distributed across cores; anything spawned earlier runs on the default single-carrier scheduler. Pair it with exactly one matching ws_shutdown() at teardown. DON'T: Call ws_init from inside a hot loop, or call it more than once without an intervening ws_shutdown() — it brings up a real OS thread pool every time it runs; treat it as a one-time startup cost, not a per-task toggle.
Advanced concurrency summary
| Primitive | Use for | What it returns |
|---|---|---|
channel_bounded(N) | Backpressure — slow producer when consumer is behind | Bounded channel (send blocks at capacity N) |
channel_bounded(1) + token | Mutual exclusion (channel-as-mutex) | recv = acquire, send = release |
on_exit_send(ch, val) | Crash-safe cleanup — resource returned even on panic | Cancellation token (int) |
cancel_on_exit_val(ch, val) | Cancel a registered on_exit_send after normal release | 1 if found, 0 if not |
pmap(list, fn) | CPU/IO parallelism over a collection | Results in same order as input |
pfilter(list, fn) | Parallel filter — predicate tested on all cores | Matching elements in original order |
pfor(start, end, fn) | Parallel for — run body across all cores | Nothing (side-effects only) |
pid = spawn ...; monitor(pid) | Restart-on-crash for a single task | A channel; delivers an exit message if the task crashes |
monitor(pid) | Get notified when a task exits | A channel; receive(mon) blocks until task finishes |
recv_timeout(ch, ms) | Receive with a deadline — one channel | The value, or null on timeout |
select(ch1, ch2) | Take first from either channel — no timeout | Whichever value arrives first |
select_timeout(ch1, ch2, ms) | Take first from either channel — with deadline | Whichever value arrives first, or null on timeout |
19. Modules
What is this? As your program grows, you will want to split your code into multiple files for organization. Modules are NOVA's way of organizing code across files. Each .nova file is a module, and you can use functions from other modules by importing them. Think of modules like rooms in a house: each room (file) has its own furniture (functions), and you can visit any room by importing it.
Importing modules
import forge // use all of forge's functions import csvx // CSV parsing module // You can import and use the module's functions directly serve_app(app, 8080) // from forge
Creating your own module
A module is simply a .nova file with functions in it. There is no special module declaration, no export keyword. Any function defined at the top level is automatically available to importers:
Step 1: Create the module file — math_utils.nova:
// math_utils.nova — this is a module file fn square(x) x * x fn cube(x) x * x * x
Step 2: Import and use it — main.nova (in the same directory):
// main.nova import math_utils print(square(5)) // 25 print(cube(3)) // 27
Line-by-line:
import math_utils— Findsmath_utils.novain the same directory. All its functions (square,cube) become available in this file.print(square(5))— Calls thesquarefunction defined inmath_utils.nova. You do NOT need to writemath_utils.square(5)— the functions are imported directly into your namespace.
Constructing an imported struct or enum directly
What is this? A struct or enum defined in another module can be constructed directly by name — Point(3, 4) works the same whether Point is defined in your own file or imported from shapes.nova. You do not need to write a wrapper function in the defining module just so importers have something to call.
// shapes.nova type Point x: int y: int enum Direction North() South() // main.nova import shapes fn main() let p = Point(3, 4) // bare constructor, no shapes.Point(...) needed print("{p.x}/{p.y}") // 3/4 let d = North() match d North() => print("heading north") // heading north South() => print("heading south")
heading north
Line-by-line: both Point(3, 4) and North() are bare constructor calls into names that only exist because import shapes pulled them in — no shapes.Point(...) qualification, and no helper function defined on the shapes.nova side purely to hand back a constructed value. match on an imported enum is exhaustive across the module boundary too: forgetting the South() arm above would be a compile error in main.nova, exactly as if Direction had been defined locally. Default parameter values on imported functions also carry across the boundary — you can omit a trailing defaulted argument on a call into another module the same way you would on a local call.
DO: Construct imported structs and enums directly wherever you'd construct a local one — there's no ceremony tax for a type living in a different file. DON'T: Reach for a mk_point(x, y)-style wrapper function out of habit — it was a workaround for a real limitation, and today it's just an extra indirection that does nothing a bare constructor doesn't already do.
Private helpers with the _ prefix
Every top-level name in a module is public by default — there's no export keyword to opt in, and no public keyword either. To keep a helper function or module-level constant internal — an implementation detail other files shouldn't import and depend on — prefix its name with _. NOVA's module system treats any top-level identifier starting with _ as file-private: it never leaves the file, and import mymodule does not pull it in, even though every OTHER top-level name in that same file is imported flat.
// validators.nova — one PUBLIC entry point, two PRIVATE helpers fn validate_email(email) // public — importable if not(_has_at_sign(email)) return err("email must contain @") if not(_has_valid_domain(email)) return err("email domain looks invalid") ok(email) fn _has_at_sign(s) // private — helper, never meant to be imported contains(s, "@") fn _has_valid_domain(s) // private — helper, never meant to be imported parts = split(s, "@") len(parts) == 2 and contains(parts[1], ".")
// main.nova import validators print(validate_email("alice@example.com")) // Ok(alice@example.com) // validators._has_at_sign("x") // ERROR — _has_at_sign was never imported
DO: Prefix any function that exists purely to support another function in the SAME file with _ — it tells both the compiler and the next reader "this is an implementation detail, not part of the module's API." DON'T: Rely on the underscore for actual security or data hiding — it's a visibility convention enforced at import time, not a runtime access-control mechanism; code inside the SAME file can still call _has_at_sign directly.
Module resolution — where NOVA looks
When you write import mymodule, NOVA looks in this order:
- Same directory as the current file —
import utilslooks forutils.novanext to your program $NOVA_HOME/lib/—import forgelooks for$NOVA_HOME/lib/forge.nova(the standard library)
DO: Split large programs into modules. Put related functions together (all database functions in db.nova, all authentication in auth.nova). DON'T: Create circular imports (file A imports file B which imports file A) — this is a compile error. DON'T: Put everything in one file — large files slow compilation and are harder to navigate.
Standard library modules
NOVA ships with a rich standard library in $NOVA_HOME/lib/. All of these are available with a simple import:
| Module | What it provides |
|---|---|
forge | Web framework (REST, WebSocket, SSE, middleware) |
forge_crypto | SHA-256/512, HMAC, AES, ChaCha20, Ed25519, X25519 |
csvx | CSV parsing and generation |
bignum | Arbitrary-precision integers |
complexnum | Complex number arithmetic |
rational | Exact rational number arithmetic |
matrixx | Matrix operations (multiply, invert, determinant) |
prng | Pseudo-random number generators (PCG, Xorshift) |
uuid | UUID v4 generation |
urlx | URL parsing and encoding |
strx | Extended string operations |
collx | Collection utilities |
bitset | Efficient bit set operations |
basex | Base encoding (base32, base58, base64) |
corex | Core utilities (extended assert, functional helpers) |
getin | Nested data access helpers (safe deep field access) |
setops | Set operations (union, intersection, difference) |
graphemex | Unicode grapheme cluster operations |
deflatex | DEFLATE/zlib compression and decompression |
pvecx | Persistent (immutable) vector data structure |
Building a multi-module project
Real applications split code across many modules. Each module focuses on ONE thing. Here is the file layout and import structure for a small web application:
// Project file layout: // my_app/ // main.nova — entry point, imports everything // auth.nova — authentication functions // db.nova — database access functions // utils.nova — shared helper utilities
// utils.nova — shared utilities used by multiple modules fn log_event(msg) print("[{datetime_now()}] {msg}") fn sanitize_input(s) trim(s) // remove leading/trailing whitespace
// auth.nova — authentication functions import utils type Session user_id: int token: string fn login(username, password) let clean_name = sanitize_input(username) // from utils // ... verify password against db ... log_event("login: {clean_name}") // from utils Session(1, "abc123") fn logout(session) log_event("logout: user {session.user_id}") // from utils null
// db.nova — database access import sqlitex fn get_user(id) let db = sqlitex.db_open("app.db") let rows = sqlitex.db_query(db, "SELECT * FROM users WHERE id = ?", [id]) if len(rows) == 0 return null rows[0]
// main.nova — entry point, orchestrates everything import forge import auth import db import utils // Route handlers are named top-level functions: an anonymous fn(req) can only hold a // single expression, so anything with more than one statement needs a name. // The shape of a login POST body — narrower than any stored type, so from_json // has an exact target instead of an untyped dict. type LoginRequest username: string password: string fn handle_login(req: Request) -> Response let body: LoginRequest = from_json(req.body) let session = login(body.username, body.password) // from auth forge.resp_json(200, session) fn handle_get_user(req: Request) -> Response let id = int(req.params["id"]) let user = get_user(id) // from db if user == null return forge.resp_error(404, "not found") forge.resp_json(200, user) fn main() let app = forge.app() forge.post(app, "/login", handle_login) forge.get(app, "/users/:id", handle_get_user) log_event("App started on port 8080") // from utils forge.serve_app(app, 8080)
Why this structure works:
utils.novahas no imports — it only provides primitives. It can be imported by anyone without causing circular dependencies.auth.novaimportsutils— authentication logic needs logging. It does NOT importdb— that separation keeps auth testable without a real database.db.novaimportssqlitex(stdlib) — database logic is isolated here. If you switch from SQLite to Postgres later, you only changedb.nova.main.novaimports everything — it is the "assembly" layer. It wires auth and db together through the HTTP routes. It knows about everything; the other modules know only about what they need.
DO: Organize modules around responsibilities, not types. A db.nova (everything database-related) is better than user_struct.nova + user_queries.nova. DON'T: Create circular imports — if auth.nova needs db.nova and db.nova needs auth.nova, extract shared types into a third module (types.nova) that both can import. DON'T: Let utility modules grow too large — split utils.nova into string_utils.nova and logging_utils.nova once it exceeds ~200 lines.
Using modules for testing — isolated unit tests
Modules make unit testing straightforward. You can test each module in isolation — no need to start the whole server:
// test_utils.nova — tests for utils.nova import utils fn main() // Test sanitize_input assert(sanitize_input(" hello ") == "hello") assert(sanitize_input("") == "") assert(sanitize_input("no-spaces") == "no-spaces") print("All utils tests passed!")
nova run test_utils.nova # run just the utils tests nova run test_auth.nova # run just the auth tests nova run main.nova # run the whole app
Each test file imports only the module it tests. Tests are just NOVA programs — no test framework needed. The convention is test_modulename.nova next to modulename.nova.
Module namespacing and naming conventions
In NOVA, the file name is the module name. There are no explicit namespace declarations or package statements. When you import a module, you get direct access to all its public functions with no prefix required.
// Project layout: // main.nova <-- entry point // auth.nova <-- authentication logic // db.nova <-- database helpers // models.nova <-- shared data types // models.nova type User id: int name: string email: string // auth.nova import models fn login(email, password) // ... verify credentials ... ok(User \{ id: 1, name: "Alice", email: email \}) fn logout(user_id) // ... invalidate session ... ok(null) // db.nova import models fn find_user(id) // ... query database ... ok(User \{ id: id, name: "Bob", email: "bob@example.com" \}) fn save_user(user) // ... insert or update ... ok(user.id) // main.nova — imports both, calls functions directly by name import auth import db fn main() result = login("alice@example.com", "secret") match result Ok(user) => print("Logged in: {user.name}") save_user(user) Err(msg) => print("Login failed: {msg}")
Line-by-line breakdown:
import authandimport db— brings both modules into scope.login(),logout(),find_user(), andsave_user()are all available directly. Noauth.login()prefix. This is the opposite of Python'sfrom auth import loginpattern — in NOVA, everything is imported flat.import modelsin multiple files — each file that needsUserimports models. The compiler deduplicates: theUsertype is compiled once, shared everywhere. No diamond-import conflict.
Naming conventions:
- Module files: lowercase, no underscores preferred:
auth.nova,db.nova,models.nova - Functions: lowercase with underscores:
find_user,login,save_user - Types and enum variants: UpperCamelCase:
User,LoginResult,Circle - Constants: UPPER_SNAKE_CASE:
MAX_RETRIES,DEFAULT_PORT
DO: Design modules so function names are globally unique — auth.nova exports login(), db.nova exports query(). DON'T: Have two modules export a function with the same name — the last import wins, which creates a silent shadowing bug. If you need disambiguation, rename one at the call site: auth_login = login after importing auth, then import db.
The forge web framework — a module in depth
Forge is NOVA's built-in web framework. It is a single import forge away and provides routing, middleware, WebSocket, Server-Sent Events, and static file serving. The five most-used functions are shown below, followed by a complete minimal REST API.
import forge // --- The five core forge functions --- // 1. forge.app() -- create a router app = forge.app() // 2. forge.get(app, path, handler) -- register a GET handler forge.get(app, "/hello", fn(req) "Hello, World!") // 3. forge.post(app, path, handler) -- register a POST handler // (more than one statement, so a NAMED function -- an anonymous fn(req) is one expression only) fn handle_echo(req) body = req.body // raw request body — a Request field, not a function call "You sent: {body}" forge.post(app, "/echo", handle_echo) // 4. req.query -- read a query parameter (a dict, filled in by the router) fn handle_search(req) q = req.query["q"] "Searching for: {q}" forge.get(app, "/search", handle_search) // 5. forge.serve(app, port) -- start the server (blocks) forge.serve(app, 8080)
Now a complete, runnable Todo List REST API using all five plus JSON responses:
import forge type Todo id: int text: string done: bool let todos = [] let next_id = [1] // a 1-element list so create_todo below can mutate it in place // GET /todos -- list all todos as JSON fn list_todos(req: Request) -> Response forge.resp_json(200, todos) // POST /todos -- create a new todo. The local var is named qtext, not text -- // NOVA's module-level names share one flat symbol space, so a local literally // named `text` would resolve to the builtin fn text(status, body) instead. fn create_todo(req: Request) -> Response let qtext = req.query["text"] if len(qtext) == 0 return forge.resp_error(400, "text is required") let todo = Todo(next_id[0], qtext, false) push(todos, todo) next_id[0] = next_id[0] + 1 forge.resp_json(201, todo) // GET /todos/:id -- get one todo by id fn get_todo(req: Request) -> Response let id = int(req.params["id"]) let found = filter(todos, fn(t) t.id == id) if len(found) == 0 return forge.resp_error(404, "todo not found") forge.resp_json(200, found[0]) fn main() let app = forge.app() forge.get(app, "/todos", list_todos) forge.post(app, "/todos", create_todo) forge.get(app, "/todos/:id", get_todo) print("Todo API running on http://localhost:8080") forge.serve_app(app, 8080)
Line-by-line breakdown:
let todos = []andlet next_id = [1]— module-level state (in-memory storage). For a production app, replace these with a database usingimport sqlitexor an external DB driver.next_idis a 1-element list rather than a bare int specifically so a handler function can mutate it in place withnext_id[0] = next_id[0] + 1.forge.app()— creates a new router. All routes are registered on this object. A single NOVA program can create multiple apps on different ports.list_todos,create_todo,get_todo— named top-level handler functions, registered withforge.get/forge.postby passing the function itself (no wrappingfn(req) ...needed when the handler is already exactly the right shape). Each takes a typedreq: Requestand returns aResponse.forge.resp_json(status, x)serializesxto JSON, setsContent-Type: application/json, and sets the given status code — it works on ANY value (struct, list, dict), not just dicts.req.query["text"]— reads thetextquery parameter from the URL. ForPOST /todos?text=Buy+milk, this returns"Buy milk"(the+decodes to a space).queryis a plaindictfield onRequest, already parsed by the router — missing keys give an empty string, so always checklen(qtext) == 0for required fields.forge.resp_error(400, "text is required")— returns a 400Responsewith a structured error body. The handler returns early withreturn forge.resp_error(...)to short-circuit the rest of the handler.req.params["id"]— reads a path parameter. The route"/todos/:id"captures any value in that position into theparamsdict —GET /todos/42givesreq.params["id"] == "42"(always a string; convert withint()).forge.serve_app(app, 8080)— starts the HTTP server for an app built withforge.app()/forge.get/forge.post, and blocks until the process is killed. This is the last statement inmain(). The server is green-task-aware: each incoming request runs in its own green task, so thousands of concurrent requests are handled with a single OS thread. (There's also a lower-levelforge.serve(port, handler)that takes a raw handler function directly instead of an app — not what you want once you're using the router.)
Testing the API: With the server running, use any HTTP client:
// curl http://localhost:8080/todos // [] // curl -X POST "http://localhost:8080/todos?text=Buy+milk" // {"id":1,"text":"Buy milk","done":false} // curl -X POST "http://localhost:8080/todos?text=Write+tests" // {"id":2,"text":"Write tests","done":false} // curl http://localhost:8080/todos // [{"id":1,"text":"Buy milk","done":false},{"id":2,"text":"Write tests","done":false}] // curl http://localhost:8080/todos/1 // {"id":1,"text":"Buy milk","done":false} // curl http://localhost:8080/todos/99 // todo not found (status 404)
Module naming conventions
NOVA module names follow a simple convention. Getting this right matters because the import name must exactly match the file name (without the .nova extension).
| Rule | Good | Bad — why |
|---|---|---|
| Lowercase snake_case | auth_utils | AuthUtils — won't import as import AuthUtils |
| Short, descriptive names | db, auth, users | my_app_database_helpers — verbose |
| No hyphens | email_validator | email-validator — not a valid identifier |
| Domain-aligned | payments, search | misc, utils — invites dumping everything in |
One responsibility per module. A module named utils is a warning sign — it usually means unrelated functions were dumped together. When a module grows over 300 lines, consider splitting it. Signs it is time to split: the module imports more than 3 other modules, or functions in it do not call each other. Circular imports (A imports B which imports A) are a compile error in NOVA — they signal that the two modules have grown too coupled and should be refactored into a third shared module that both depend on.
20. Networking: TCP and UDP
What is this? TCP (Transmission Control Protocol) is the foundation of internet communication. When you visit a website, send an email, or call an API, your computer uses TCP. TCP ensures data arrives reliably, in the correct order, and without corruption. NOVA has built-in TCP and UDP support — no external libraries or imports needed. NOVA's networking is green-aware: tcp_recv parks the current green task (not the OS thread), so your server can handle thousands of connections with just one OS thread.
Complete echo server + client example
fn run_server(port) server = tcp_listen(port) print("Server listening on port {port}") client = tcp_accept(server) // blocks until a client connects data = tcp_recv(client) // read data from client tcp_send(client, "ECHO:" + data) // send response tcp_close(client) tcp_close(server) port = 19876 // Spawn server in a separate task so client code can run concurrently spawn fn() run_server(port) sleep(100) // give server time to start listening // Client connects sock = tcp_connect("127.0.0.1", port) tcp_send(sock, "hello") reply = tcp_recv(sock) tcp_close(sock) print(reply) // ECHO:hello
ECHO:hello
Server-side line-by-line:
server = tcp_listen(port)— Creates a TCP listener that waits for incoming connections on the specified port. Like opening a shop and putting up an "open" sign.client = tcp_accept(server)— Blocks until a client connects. When a client connects, returns a connection object.data = tcp_recv(client)— Reads data sent by the client. Blocks until data arrives. Returns it as a string.tcp_send(client, "ECHO:" + data)— Sends the response back to the client.tcp_close(client)/tcp_close(server)— Always close connections when done — otherwise you leak OS file descriptors.
Client-side line-by-line:
sock = tcp_connect("127.0.0.1", port)— Connects to the server at localhost on the specified port. Returns a connection object.tcp_send(sock, "hello")— Sends the string "hello" to the server.reply = tcp_recv(sock)— Waits for the server's response. Returns"ECHO:hello".
Production server — spawn per connection
fn handle_client(conn) loop data = tcp_recv(conn) if len(data) == 0 break // client disconnected tcp_send(conn, "ECHO:" + data) tcp_close(conn) fn main() server = tcp_listen(8080) loop conn = tcp_accept(server) spawn fn() handle_client(conn) // each client gets its own green task
Each client gets its own green task. Green tasks are cheap (~1µs to spawn, ~32KB stack), so you can handle thousands of concurrent clients with one OS thread. No thread pool configuration needed.
UDP (User Datagram Protocol)
UDP is faster than TCP but doesn't guarantee delivery or order. Used for real-time applications (games, DNS, streaming) where speed matters more than reliability:
// UDP server sock = udp_bind(9999) data = udp_recv(sock) print("received: {data}") // UDP client udp_send("127.0.0.1", 9999, "hello via UDP")
udp_recv_from — replying to whichever peer sent
A UDP socket is connectionless: a single udp_bind file descriptor can receive datagrams from any number of different senders, and the protocol itself carries no notion of "the connection" the way TCP does. udp_recv throws away exactly the information you need to reply — who sent this — which is fine for a client that already knows the one server it's talking to, but useless for building a UDP server that must answer whichever peer just spoke. udp_recv_from(fd, bufsize) solves this by returning the sender's address alongside the payload, as a three-element list [data, addr, port]. It is a list, not a struct — access the pieces by index (reply[0], reply[1], reply[2]), not by field name.
// UDP echo server — replies to whoever sent the last datagram fn run_echo_server(port) sock = udp_bind(port) packet = udp_recv_from(sock, 1024) data = packet[0] from_addr = packet[1] from_port = packet[2] udp_send(sock, from_addr, from_port, "echo: {data}") port = 9500 client_port = 9501 spawn fn() run_echo_server(port) sleep(100) // give the server time to bind client = udp_bind(client_port) udp_send(client, "127.0.0.1", port, "ping") reply = udp_recv_from(client, 1024) print(reply[0]) // the payload print(reply[1]) // the server's address — who we got the reply from
127.0.0.1
DO: Use udp_recv_from instead of udp_recv for any UDP socket that serves more than one peer — a discovery service, a game server with multiple players, a DNS server. DON'T: Confuse the list order — index 0 is the data, 1 is the address, 2 is the port. It's a plain list, so nothing stops you from swapping them by mistake and shipping a server that silently replies to the wrong index. DON'T: Call plain udp_recv and then try to figure out the sender some other way — the information is only available at the moment of the receive call, via udp_recv_from.
TCP API reference
| Function | What it does | Returns |
|---|---|---|
tcp_listen(port) | Start listening for connections | A listener object |
tcp_accept(listener) | Wait for a client to connect (parks green task) | A connection object |
tcp_connect(host, port) | Connect to a remote server | A connection object |
tcp_send(conn, data) | Send a string over the connection | nothing |
tcp_recv(conn) | Receive a string (blocks until data arrives) | The received string |
tcp_send_bytes(conn, bytes) | Send raw bytes | nothing |
tcp_recv_bytes(conn) | Receive raw bytes | The received bytes |
tcp_close(conn) | Close the connection | nothing |
Connection metadata — peer address and port
Once tcp_accept hands you a connection, you often need to know who connected before you've read a single byte of application data — for access-control decisions, per-IP rate limiting, or just structured logging that lets you correlate requests to a client during an incident. tcp_peer_addr(fd) and tcp_peer_port(fd) read this straight off the underlying socket (the OS already knows it from the TCP handshake), with no protocol-level round-trip required.
fn run_server(port) server = tcp_listen(port) client = tcp_accept(server) ip = tcp_peer_addr(client) cport = tcp_peer_port(client) log_info("accepted connection from {ip}:{cport}", null) tcp_close(client) tcp_close(server) port = 19877 spawn fn() run_server(port) sleep(100) sock = tcp_connect("127.0.0.1", port) sleep(100) // let the server print before we tear the connection down tcp_close(sock)
DO: Log tcp_peer_addr for every accepted connection in production servers — it's the first thing you'll want during an abuse investigation or an outage post-mortem. DON'T: Treat tcp_peer_addr as an authentication mechanism. IP addresses are trivially shared behind NAT and corporate proxies, and anything sitting in front of your server (a load balancer, a reverse proxy) will report its own address, not the original client's, unless you also parse an X-Forwarded-For header at the application layer. Use it for logging and coarse rate-limiting, never as a security boundary on its own.
Polling a single socket without blocking
tcp_recv blocks (parks the green task) until data arrives or the connection closes — there is no built-in way to say "wait, but give up after N milliseconds." tcp_wait_readable(fd, timeout_ms) is that missing piece: it returns true as soon as the socket has data ready to read, or false once the timeout elapses with nothing arriving. This is the tool for bounding how long you'll wait for a client's first byte — a classic defense against a slow or malicious client that opens a connection and then sends nothing, tying up a green task indefinitely (the "Slowloris" attack pattern).
port = 19878 spawn fn() server = tcp_listen(port) conn = tcp_accept(server) if tcp_wait_readable(conn, 300) // wait up to 300ms for the first byte data = tcp_recv(conn) print("got: {data}") else print("no data within 300ms, closing") tcp_close(conn) tcp_close(server) sleep(100) sock = tcp_connect("127.0.0.1", port) // connects but never sends anything sleep(600) tcp_close(sock)
DO: Use tcp_wait_readable to bound how long an accepted connection can sit idle before you've received anything from it, and close it if the deadline passes. DON'T: Reach for tcp_wait_readable in a loop across many sockets — polling N connections one at a time is O(N) work per tick and defeats the purpose of an event-driven design. For many concurrent sockets, use the io_poll_* multiplexer (below), which reports every ready socket from a single call.
Socket options and non-blocking I/O
socket_option(fd, name, value) is a thin, direct wrapper over the OS's setsockopt — it lets you tune low-level TCP behavior that the high-level tcp_* builtins don't expose a dedicated function for. Two options come up constantly in real systems: TCP_NODELAY disables Nagle's algorithm, which by default buffers small writes for tens of milliseconds hoping to coalesce them into fewer packets — great for bulk throughput, actively harmful for latency-sensitive protocols like a chat message or a game state update, where you want every small write to hit the wire immediately. SO_REUSEADDR lets a listener rebind to a port that's still in the OS's TIME_WAIT state from a previous run — without it, restarting a crashed or redeployed server can fail with "address already in use" for a minute or more.
listener = tcp_listen(9600) ok = socket_option(listener, "SO_REUSEADDR", 1) // survive restarts without "address in use" print(ok) sock = tcp_connect("127.0.0.1", 9600) socket_option(sock, "TCP_NODELAY", 1) // send small writes immediately, don't batch them
io_set_nonblocking(fd) is a related but different tool: it switches a socket from blocking mode (reads/writes wait until they can complete) to non-blocking mode (they return immediately, even with nothing to report). Ordinary NOVA code almost never needs this directly — tcp_recv/tcp_accept/tcp_connect already park the calling green task without blocking the underlying OS thread, so you get non-blocking-I/O-class scalability for free. io_set_nonblocking exists for the case where you're bypassing that and driving a socket by hand through io_poll_* (below) — a raw non-blocking reactor needs the fd itself to never block, since the poll loop is what decides when it's safe to read.
DO: Set SO_REUSEADDR on every listener you write, especially in development — it eliminates the "address already in use" restart friction entirely. DON'T: Call io_set_nonblocking on a socket you're still driving through tcp_recv/tcp_send/tcp_accept. Those builtins have a documented contract of "block (park) until there's a result" — flip the underlying fd to non-blocking out from under them and that contract breaks: a read can now return early with nothing, which the calling code isn't expecting. Only combine io_set_nonblocking with sockets you're managing yourself through io_poll_*.
TCP vs UDP — choosing the right protocol
| Property | TCP | UDP |
|---|---|---|
| Delivery guarantee | Yes — data always arrives or connection errors | No — packets can be lost silently |
| Ordering guarantee | Yes — data arrives in the same order sent | No — packets can arrive out of order |
| Speed overhead | Higher — connection setup, ACKs, retransmission | Lower — fire-and-forget, no handshake |
| Connection model | Point-to-point streams | Individual datagrams, can be broadcast |
| Use when | Files, HTTP, API calls, chat — correctness matters | Games, DNS, video streaming, telemetry — speed matters |
| NOVA functions | tcp_listen/accept/connect/send/recv | udp_bind/send/recv |
Rule of thumb: Use TCP for everything unless you have a specific, measured performance reason to use UDP. The reliability overhead of TCP is rarely the bottleneck in real applications. The main legitimate uses of UDP are: DNS lookups, real-time games (where a stale packet is worse than a lost one), and video streaming (where a 1-frame glitch is better than pausing to retransmit).
Building a multi-client chat server
A chat server is the canonical multi-client TCP example. Each client sends messages; the server broadcasts to all connected clients. The key insight is using a shared channel to coordinate the broadcaster:
// Simple chat server — broadcasts every message to all clients fn handle_reader(conn, broadcast_ch, name) loop msg = tcp_recv(conn) if len(msg) == 0 break // client disconnected send(broadcast_ch, "{name}: {msg}") send(broadcast_ch, "{name} left the chat") fn handle_writer(conn, my_ch) loop msg = recv(my_ch) if msg == "__disconnect__" break tcp_send(conn, msg + "\n") tcp_close(conn) fn main() server = tcp_listen(6000) broadcast_ch = channel() // all messages flow through here clients = [] // per-client channels for delivery client_count = 0 // Broadcaster task: receives on broadcast_ch, fans out to all clients spawn fn() loop msg = recv(broadcast_ch) for ch in clients send(ch, msg) // Accept loop: each new client gets a reader+writer green task pair loop conn = tcp_accept(server) client_count += 1 name = "client{client_count}" my_ch = channel() push(clients, my_ch) spawn fn() handle_reader(conn, broadcast_ch, name) spawn fn() handle_writer(conn, my_ch)
Architecture explanation:
- Each client has two green tasks: a reader (listens on TCP, sends to broadcast channel) and a writer (listens on its own channel, sends to TCP).
- The broadcaster task loops forever receiving from
broadcast_chand forwarding to every client's personal channel. This is the "fan-out" pattern — one input, many outputs. - Using a shared channel for coordination means no locks needed. Green tasks communicate by sending values, not by sharing memory. This is Erlang's actor model applied to TCP networking.
- When a client disconnects,
tcp_recvreturns an empty string, and the reader sends a "left the chat" notification then exits. The writer's channel still exists but will never get the"__disconnect__"sentinel (a production implementation would send it).
Compare to Python asyncio: A Python equivalent requires asyncio.gather, async def, await, and explicit event loop management. NOVA's version uses plain fn, plain spawn, and plain send/recv. No async/await ceremony — the green task scheduler handles concurrency transparently.
Heartbeat — detecting disconnected clients
A dead client (crashed process, closed laptop) doesn't send a TCP FIN packet — the connection just goes silent. Without a heartbeat, your server accumulates dead connections indefinitely. The heartbeat pattern: periodically send a ping, and if you don't get a pong back within a deadline, close the connection:
fn handle_with_heartbeat(conn) alive_ch = channel() // reader signals "still alive" through here // Reader task: forward data, signal alive spawn fn() loop data = tcp_recv(conn) if len(data) == 0 then break send(alive_ch, true) // data received = still alive process(data) // Heartbeat task: check for activity every 30 seconds loop sleep(30000) // wait 30 seconds (sleep() takes milliseconds) result = recv_timeout(alive_ch, 1) if result == null // No activity in 30 seconds — client is probably dead log_warn("client idle for 30s, closing connection", null) tcp_close(conn) break // Activity received — reset the timer (next loop iteration)
Line-by-line:
alive_ch = channel()— A channel that signals "I saw data from the client." Every time the reader task gets data, it sendstrueto this channel.sleep(30000)— The heartbeat sleeps for 30 seconds, then checks whether the reader sent anything during that interval.recv_timeout(alive_ch, 1)— Non-blocking receive: if anything is in the channel, take it immediately (1ms timeout). Ifnull, nothing arrived — the client sent no data for 30 seconds.tcp_close(conn)— Force-close the dead connection. The reader task will get an empty recv from the closed socket and exit naturally.
Production note: Many production systems use TCP keepalive at the OS level (via SO_KEEPALIVE socket option). NOVA's approach is application-level keepalive — more flexible (you control the interval and timeout) and works through load balancers that strip TCP keepalive packets.
I/O polling — building your own event loop
Every server example so far in this chapter uses one green task per connection — spawn fn() handle_client(conn) — and that already gets you epoll/kqueue/IOCP-class scalability (thousands of concurrent connections on one OS thread) without writing a single line of event-loop code; the scheduler is the reactor. So why would you ever want the multiplexing primitive directly? Three real reasons: embedding NOVA's networking inside a different event loop you don't control, building a genuinely single-threaded reactor where you need deterministic ordering of events (no interleaving between green tasks), or replicating an existing C10K-era architecture that's specified in terms of a poll loop. io_poll_* is NOVA's answer for exactly that: one uniform API — io_poll_create, io_poll_add, io_poll_wait, io_poll_remove, io_poll_close — that maps to epoll on Linux, kqueue on macOS, and IOCP on Windows. You write it once; the runtime picks the right OS mechanism.
// A single-threaded echo server multiplexed over one poll loop — // no spawn, no green-task-per-connection, just one reactor. listener = tcp_listen(8080) io_set_nonblocking(listener) poll = io_poll_create() io_poll_add(poll, listener, "read") loop ready = io_poll_wait(poll, 1000) // block up to 1s for any activity for fd in ready if fd == listener conn = tcp_accept(listener) io_set_nonblocking(conn) io_poll_add(poll, conn, "read") else data = tcp_recv(fd) if len(data) == 0 io_poll_remove(poll, fd) tcp_close(fd) else tcp_send(fd, "ECHO:" + data)
What changed versus the spawn-per-connection server earlier in this chapter: there is exactly one call stack, ever. Every connection's state has to live in a variable you track yourself (here, none is needed beyond the fd — a real reactor typically keeps a dict of per-fd parse buffers), because there's no longer a suspended function frame per client the way there is with spawn. io_poll_wait returns a list of fds that are ready — for the listener, "ready" means a new connection is waiting to be tcp_accept'd; for a client fd, it means data (or a close) is waiting to be tcp_recv'd.
DO: Default to the spawn-per-connection pattern shown earlier in this chapter — it already gives you the scalability io_poll_* exists to provide in other languages, with none of the manual state-tracking cost. DON'T: Reach for io_poll_* "for performance" — NOVA's green-task scheduler already multiplexes internally using this same OS mechanism underneath every blocking-looking tcp_recv/tcp_accept call, so hand-rolling a poll loop buys you no extra throughput, only extra bookkeeping. Use it when you have a structural reason (embedding, deterministic ordering, matching an external spec), not a speed reason.
TLS — encrypted networking (HTTPS and beyond)
NOVA's TLS support is a drop-in encrypted layer over the same TCP model used throughout this chapter: tls_connect/tls_listen/tls_accept/tls_send/tls_recv/tls_close mirror tcp_connect/tcp_listen/tcp_accept/tcp_send/tcp_recv/tcp_close argument-for-argument. Turning a plaintext service into an HTTPS one is a matter of swapping the function name prefix, not restructuring the code — there's no separate TLS library to learn, no context objects to configure, no certificate-store wiring. The one addition on the server side is that tls_listen takes a certificate and private key file path, since a TLS server must prove its identity during the handshake.
// HTTPS client — GET request over TLS fd = tls_connect("example.com", 443) tls_send(fd, "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") response = tls_recv(fd) print(response) // "HTTP/1.1 200 OK\r\n..." — decrypted, exact bytes depend on the live server tls_close(fd) // HTTPS server — identical shape to tcp_listen/accept, plus a cert and key fn run_https_server(port) listener = tls_listen(port, "cert.pem", "key.pem") client = tls_accept(listener) request = tls_recv(client) tls_send(client, "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nSecure Hello!") tls_close(client) tls_close(listener)
Three more pieces cover the cases the basic client/server pair doesn't: tls_connect_insecure(host, port) connects without verifying the server's certificate at all — useful when testing against a self-signed cert on localhost, dangerous anywhere else, since it removes exactly the check that stops a man-in-the-middle from impersonating the server. tls_connect_alpn/tls_listen_alpn negotiate an application protocol (like "h2" for HTTP/2) as part of the handshake itself, and tls_alpn(fd) tells you which protocol the two sides agreed on. tls_upgrade(fd) converts an already-open plaintext TCP connection into a TLS one in place — the STARTTLS pattern used by protocols like SMTP, where the connection begins in plaintext and only switches to encryption after both sides agree to.
// Development only — skips certificate verification entirely dev_fd = tls_connect_insecure("localhost", 8443) // Negotiate a protocol during the handshake (e.g. HTTP/2's "h2") h2_fd = tls_connect_alpn("example.com", 443, ["h2", "http/1.1"]) negotiated = tls_alpn(h2_fd) print(negotiated) // "h2" if the server supports it, else "http/1.1" // STARTTLS pattern — begin plaintext, upgrade the same fd mid-connection plain = tcp_connect("mail.example.com", 587) tcp_send(plain, "STARTTLS\r\n") tcp_recv(plain) // server confirms it's ready to upgrade secure = tls_upgrade(plain) // same fd, now encrypted
DO: Use plain tls_connect (full certificate verification) for anything that touches a real network. DON'T: Ship tls_connect_insecure in any code path a production build can reach — it silently disables the check that makes TLS meaningful in the first place, turning "encrypted" traffic into traffic that's merely obfuscated from a passive observer but fully readable and rewritable by an active attacker sitting on the path. Reserve it strictly for localhost development against a self-signed certificate.
Raw WebSocket handshake — building without Forge
Chapter 27 covers Forge's forge.ws(app, path, handler) and the @websocket route decorator — the right choice for a WebSocket server living inside a normal Forge app, since Forge wires the handshake, framing, and room broadcast for you. The raw ws_upgrade/ws_send/ws_recv/ws_close/ws_accept_key builtins documented here are the layer underneath that: reach for them directly when you're not using Forge at all — a WebSocket endpoint bolted onto a bare tcp_accept loop — or when you're writing a WebSocket client, since Forge's WebSocket support is server-side routing only and has no equivalent for dialing out to someone else's WebSocket endpoint.
fn handle_ws_client(conn) ws = ws_upgrade(conn) // performs the HTTP -> WebSocket handshake on this connection loop msg = ws_recv(ws, 5000) // wait up to 5s for the next frame if msg == null break // timeout, or the client closed the connection ws_send(ws, "echo: {msg}") ws_close(ws) fn main() server = tcp_listen(8090) loop conn = tcp_accept(server) // still a plain HTTP connection at this point spawn fn() handle_ws_client(conn)
ws_accept_key(key) is the one piece of raw protocol crypto behind ws_upgrade: the WebSocket handshake (RFC 6455) requires the server to take the client's Sec-WebSocket-Key header, concatenate a fixed magic GUID, SHA-1 hash the result, and base64-encode it into the Sec-WebSocket-Accept response header. ws_upgrade does this automatically; ws_accept_key exposes the computation directly for the rare case where you're assembling the HTTP upgrade response by hand instead of letting ws_upgrade manage the whole handshake:
client_key = "dGhlIHNhbXBsZSBub25jZQ==" accept_header = ws_accept_key(client_key) print(accept_header) // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" — value for Sec-WebSocket-Accept
DO: Use Forge's forge.ws(...) (chapter 27) for any WebSocket server inside a normal Forge app — it's built on exactly these primitives and saves you from re-implementing framing and connection tracking. DON'T: Call ws_send/ws_recv on a connection before calling ws_upgrade on it — until the handshake completes, the peer is still speaking plain HTTP, and framing calls on an un-upgraded socket will send or expect the wrong bytes.
DNS resolution
tcp_connect and http_get already resolve hostnames internally, so most NOVA code never calls a DNS function directly. You need the raw resolver when the IP address itself is the thing you're after, not just a means to open a connection: logging which address a hostname resolved to, health-checking every backend behind a round-robin DNS name individually (rather than only whichever one your OS's resolver happens to hand back for a single lookup), or building custom load-balancing logic. dns_resolve(hostname) returns one IP; dns_resolve_all(hostname) returns every IP currently published for that hostname; reverse_dns(ip) goes the other direction, looking up the hostname associated with an address.
ip = dns_resolve("example.com") print(ip) // e.g. "93.184.216.34" — depends on live DNS state for addr in dns_resolve_all("example.com") print("candidate: {addr}") host = reverse_dns("8.8.8.8") print(host) // "dns.google"
A realistic use of dns_resolve_all: trying each backend behind a hostname in turn until one accepts a connection, rather than trusting whichever single address tcp_connect would have picked on its own:
fn connect_to_healthy_backend(hostname, port) for ip in dns_resolve_all(hostname) fd = tcp_connect(ip, port) if fd > 0 print("connected via {ip}") return fd print("all backends unreachable") return 0
DO: Reach for dns_resolve/dns_resolve_all only when you need the IP itself for something other than connecting — tcp_connect(host, port) and http_get(url) already resolve internally, so resolving first "to be safe" before calling them is redundant work, not an extra safety net. DON'T: Assume dns_resolve_all's ordering is stable or meaningful — DNS round-robin order can rotate between successive calls, so treat the result as an unordered set of candidates to try, not a ranked priority list.
21. HTTP client
What is this? HTTP (HyperText Transfer Protocol) is the protocol that powers the web. An HTTP client makes web requests programmatically — calling APIs, downloading data, interacting with web services. NOVA has built-in HTTP client functions — no imports, no external libraries needed. All requests are green-aware: the task parks while waiting for the response, so other tasks continue running.
Basic GET and POST requests
// GET request — fetches data from a URL response = http_get("http://example.com/api/users") print(response) // POST request — sends data to a server body = json_encode({"name": "Alice", "age": 30}) response = http_post("http://example.com/api/users", body, "application/json") print(response)
Line-by-line:
http_get("http://example.com/api/users")— Sends an HTTP GET request to the URL. GET requests retrieve data — they ask the server "give me the list of users." Blocks until the response arrives, then returns the response body as a string.json_encode({"name": "Alice", "age": 30})— Converts the dict to a JSON string:{"name":"Alice","age":30}. This becomes the request body.http_post(url, body, content_type)— Sends an HTTP POST request with the given body andContent-Typeheader (here"application/json"— the third argument is required, there is no 2-argument overload). POST requests send data TO the server — they say "create a new user with this data."
Self-contained loopback example
fn main() spawn fn() sock = http_listen(18080) conn = http_accept_raw(sock) if len(conn) > 0 client = conn[0] http_send_raw(client, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 11\r\nConnection: close\r\n\r\nhello-nova!") sleep(800) resp = http_get("http://127.0.0.1:18080/") print(resp) // hello-nova!
Line-by-line:
spawn fn()— Starts a green task running a tiny HTTP server in the background.http_listen(18080)— Listens for connections on port 18080.http_send_raw(client, "HTTP/1.1 200 OK\r\n...")— Sends a raw HTTP response. The\r\nsequences are required by the HTTP protocol (carriage return + newline). Includes headers (Content-Type, Content-Length) and the body.sleep(800)— Waits 800ms for the server task to start listening.http_get("http://127.0.0.1:18080/")— Makes a request to our own server at localhost.
Full request with headers and auth
http_get/http_post are raw builtins that always work with zero setup, but they only give you the body string. For the status code, response headers, or custom request headers, use http_request from the forge_http_client module — it takes four positional arguments (method, url, headers, body) and returns a Result<HttpResponse>, where HttpResponse has fields status: int, headers: dict, body: string:
import forge_http_client match http_request("GET", "https://api.example.com/secret", {"Authorization": "Bearer my-token"}, "") Ok(resp) => print(resp.status) // 200 print(resp.body) // response body string print(resp.headers) // response headers dict Err(msg) => print("request failed: {msg}")
Line-by-line:
import forge_http_client—http_requestlives in this module, not in the always-on builtin set. It must be imported before use (unlikehttp_get/http_post, which need no import).http_request("GET", url, headers, body)— takes exactly four positional arguments, always in this order. For a GET or DELETE with no body, pass""for the body — there is no way to omit it.Result<HttpResponse>— network failures (DNS failure, connection refused, timeout) come back asErr(msg), not a crash and not a fake status code. A successful HTTP round-trip — even one that returns 404 or 500 — isOk(resp); the status code lives inresp.status, not in whether theResultisOkorErr.resp.status/resp.body/resp.headers—HttpResponseis a struct, so its fields are accessed with., not["..."]dict indexing.
For building real HTTP servers with routing, middleware, and production features, use Forge (section 25) instead of raw HTTP functions. Raw HTTP is for calling other services; Forge is for serving your own.
Complete HTTP client patterns
Here are the common patterns you'll use when calling external APIs — GET with error handling, POST with JSON, authenticated requests, and response parsing:
GET with full error handling:
// Simple GET — returns the body string or crashes on network error body = http_get("https://api.example.com/status") print(body) // GET with full request/response info (status code + headers) match http_request("GET", "https://api.example.com/users", {}, "") Ok(resp) => if resp.status == 200 users = json_decode(resp.body) print("Got {len(users)} users") else if resp.status == 404 print("Not found") else print("HTTP error {resp.status}: {resp.body}") Err(msg) => print("network error: {msg}")
POST with JSON body — creating a resource:
// POST JSON to create a new user payload = { "name": "Alice", "email": "alice@example.com", "role": "admin" } match http_request("POST", "https://api.example.com/users", {"Content-Type": "application/json"}, json_encode(payload)) Ok(resp) => new_user = json_decode(resp.body) print("Created user with id: {new_user["id"]}") Err(msg) => print("request failed: {msg}")
Authenticated requests — Bearer token:
fn whoami() // First: login to get a token — try propagates Err up if the request fails login_resp = try http_request("POST", "https://api.example.com/auth/login", {"Content-Type": "application/json"}, json_encode({"username": "alice", "password": "secret"})) auth = json_decode(login_resp.body) token = auth["access_token"] // Then: use the token for subsequent requests profile_resp = try http_request("GET", "https://api.example.com/me", {"Authorization": "Bearer {token}"}, "") ok(json_decode(profile_resp.body)) match whoami() Ok(profile) => print("Logged in as: {profile["name"]}") Err(msg) => print("login failed: {msg}")
Line-by-line breakdown (authenticated pattern):
http_request(method, url, headers, body)— the full request function takes four positional arguments and returnsResult<HttpResponse>. This is more verbose thanhttp_get(url)but gives full control: method, URL, headers, body — and it surfaces network failures asErrinstead of a crash.try http_request(...)— sincewhoamiitself returns aResult(viaok(...)at the end),trycan unwrap each request: on success it binds theHttpResponse, on failure it immediately returnsErr(msg)fromwhoami— no manualmatchneeded at each step."Content-Type": "application/json"— tells the server to expect JSON in the request body. Without this header, some servers reject the request or fail to parse the body."Authorization": "Bearer {token}"— the standard way to authenticate with a JWT or OAuth2 bearer token. TheBearerprefix is part of the HTTP Authorization header spec. String interpolation{token}inserts the actual token value.json_decode(profile_resp.body)— the response body is a string.json_decodeconverts it to a NOVA dict so you can access fields..bodyis struct field access, not dict indexing.
Parallel HTTP requests — fetch multiple URLs concurrently:
// Fetch 3 URLs in parallel — takes as long as the SLOWEST, not the SUM urls = [ "https://api.example.com/users", "https://api.example.com/products", "https://api.example.com/orders" ] // pmap runs the function on all items concurrently results = pmap(urls, url => http_get(url)) users = json_decode(results[0]) products = json_decode(results[1]) orders = json_decode(results[2]) print("Got {len(users)} users, {len(products)} products, {len(orders)} orders")
pmap runs the HTTP requests in parallel green tasks. If each request takes 200ms and you made them sequentially, total would be 600ms. With pmap, total is ~200ms — all three run simultaneously.
HTTP client API reference
| Function | When to use | Returns |
|---|---|---|
http_get(url) | Quick GET — just want the body string. Always available, no import. | Body string |
http_post(url, body, content_type) | Quick POST — body and content-type are both required strings. Always available, no import. | Body string |
http_request(method, url, headers, body) | Full control — need status code, headers, auth. Requires import forge_http_client. | Result<HttpResponse> — struct with status, body, headers |
DO: Use http_get(url) and http_post(url, body, content_type) for simple cases. Use http_request(...) (after import forge_http_client) when you need the response status code or custom headers. DON'T: Build URLs by string concatenation with user input — always url_encode() query parameters to prevent injection. DO: Use pmap when fetching multiple URLs — parallel is almost always faster than sequential for I/O.
When to use each HTTP method
| Method | Semantics | Has request body? | Typical use |
|---|---|---|---|
| GET | Read — fetch a resource, never modify it | No | Load a page, fetch a user profile, query a list |
| POST | Create — submit data, create a new resource | Yes | Submit a form, create a new record, upload a file |
| PUT | Replace — fully replace an existing resource | Yes | Update all fields of a user record |
| PATCH | Partial update — change only specific fields | Yes | Change a user's email without touching other fields |
| DELETE | Remove a resource | Rarely | Delete a post, cancel an order |
Idempotency matters for retry safety: GET, PUT, and DELETE are idempotent (calling them twice has the same effect as calling once). POST and PATCH are not idempotent — submitting a form twice creates two records. When designing APIs, prefer PUT over PATCH for simplicity unless partial updates are a hard requirement from clients.
Response status codes pair with methods: 200 OK (GET success), 201 Created (POST success — resource now exists), 204 No Content (DELETE success — nothing to return), 400 Bad Request (client sent invalid data), 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found (resource does not exist), 422 Unprocessable Entity (data syntactically valid but semantically wrong — use for validation failures), 500 Internal Server Error (your code crashed).
Retry with exponential backoff
Network requests fail transiently. The correct response is to retry — but with increasing delays so you don't hammer an already-struggling server:
import forge_http_client // Retry up to max_retries times with exponential backoff fn http_get_retry(url, max_retries) attempt = 0 delay_ms = 100 // start with 100ms while attempt < max_retries match http_request("GET", url, {}, "") Ok(resp) => if resp.status >= 200 and resp.status < 300 return ok(resp.body) // success — done else if resp.status >= 400 and resp.status < 500 return err("client error {resp.status}") // 4xx = our fault, don't retry // else: 5xx — fall through to the retry-with-backoff below Err(_) => null // network error (DNS/connect/timeout) — also retry-eligible // 5xx or network error — retry with backoff attempt = attempt + 1 if attempt < max_retries log_warn("Attempt {attempt} failed, retrying in {delay_ms}ms", null) sleep(delay_ms) delay_ms = delay_ms * 2 // exponential: 100ms, 200ms, 400ms, 800ms... err("all {max_retries} attempts failed") // Use it result = http_get_retry("https://api.example.com/data", 5) match result Ok(body) => print(json_decode(body)) Err(msg) => print("Failed: {msg}")
Why exponential backoff:
- 100ms → 200ms → 400ms → 800ms: Each retry doubles the wait. After 4 retries you've waited 1.5 seconds total. This gives the server time to recover from transient overload without waiting forever.
- Don't retry 4xx: A 404 or 403 means YOUR request is wrong — retrying will never help. Only retry on 5xx (server errors) and network failures where the server might recover.
- Jitter in production: If 1000 clients all retry at the same time, you still get a thundering herd. Add
delay_ms + random_int(0, 50)to spread retries out. For this tutorial example, the deterministic version is clearer.
22. Cryptography and encoding
What is this? Cryptography is the science of keeping data secure. When you build any application that handles passwords, authentication, API keys, or sensitive data, you need cryptography. NOVA provides both cryptographic functions (for security) and encoding functions (for data format conversion). All cryptography is pure NOVA — no C library dependencies. Every algorithm passes standard KAT (Known Answer Test) vectors from the RFCs.
Key concepts:
- Hashing — Converting data into a fixed-size "fingerprint." Irreversible. Used for: password storage, data integrity, deduplication.
- HMAC — Hash + secret key. Proves both that data was not modified AND that it came from someone who knows the key. Used for: API authentication, JWT signatures, webhook verification.
- Encoding — Converting data between formats (Base64, hex). This is NOT encryption — encoded data can be decoded by anyone. Used for: embedding binary data in text, URL-safe strings.
Cryptographic hashing with SHA-256
import forge_crypto hash = sha256_hex("hello") print(hash) // 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
Line-by-line:
import forge_crypto— Loads the cryptography module, providing SHA-256, HMAC, and other crypto functions.sha256_hex("hello")— Computes the SHA-256 hash of"hello". The result is always exactly 64 hexadecimal characters regardless of input length. The same input always produces the same output. But changing even one character produces a completely different hash.
// SHA-512 for stronger security print(sha512_hex("hello")) // 128-char hex string // Hash binary data (files, network payloads) data = str_to_bytes("hello") hash = sha256_of_bytes(data) // DON'T store passwords as plain text — DO hash them hashed = sha256_hex(password) // When user logs in: compare sha256_hex(input) to stored hash
The bare sha256() builtin — hashing without an import
Every SHA-256 example so far called sha256_hex(), a convenience wrapper that lives in the forge_crypto module. The compiler also registers a lower-level sha256(str) -> hex string builtin directly — at the same "zero import required" tier as hash(), crc32(), and fnv1a() further down this chapter. Reach for the bare builtin when you want a one-line integrity check, a small build script, or a tiny CLI tool, and don't want to pull in the rest of forge_crypto's surface — HMAC, PBKDF2, TLS helpers, X.509 parsing — just to hash one string.
// sha256() is a core compiler builtin — no "import forge_crypto" needed digest = sha256("hello world") print(digest) // b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde // sha256_of_bytes(bytes_handle) -> hex string works the same way, for a byte buffer instead of a string data = str_to_bytes("hello world") print(sha256_of_bytes(data)) // identical digest — same bytes in, same hash out
b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde
DO: Use the bare sha256() / sha256_of_bytes() for standalone hashing in scripts and small tools that don't need anything else from forge_crypto. DON'T: Treat sha256_hex() and sha256() as two different algorithms — they compute the identical SHA-256 digest; sha256_hex() is only a forge_crypto-namespaced wrapper around the same underlying hash, so mixing the two calling styles in one codebase is safe as long as the import is present wherever the wrapper form is used.
Hashing a prefix — sha256_bytes for partially-filled buffers
Sometimes only part of a buffer holds meaningful data — a fixed-capacity buffer passed to an extern C function that reports back how many bytes it actually wrote via an out<T> parameter, for instance, or a reusable scratch buffer where only the current message's bytes matter and everything past it is stale. sha256_bytes(bytes_handle, n) -> hex string hashes exactly the first n bytes and ignores the rest, so you never need to slice a sub-buffer first just to compute a checksum.
// A 64-byte buffer where only the first 5 bytes hold real data buf = bytes(64) bytes_set(buf, 0, 104) // 'h' bytes_set(buf, 1, 101) // 'e' bytes_set(buf, 2, 108) // 'l' bytes_set(buf, 3, 108) // 'l' bytes_set(buf, 4, 111) // 'o' // Hash only the 5 meaningful bytes — ignore the 59 zero bytes after them prefix_digest = sha256_bytes(buf, 5) full_digest = sha256_of_bytes(buf) // hashes all 64 bytes, zero tail included print(prefix_digest) // 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 — same as sha256("hello") print(prefix_digest == full_digest) // false — the trailing zero bytes change the digest
false
DO: Pass the exact count of meaningful bytes to sha256_bytes(buf, n) when a buffer's allocated size is larger than its valid payload. DON'T: Call sha256_of_bytes() on an oversized buffer expecting it to "know" where your real data ends — a bytes buffer has no concept of a valid-payload length distinct from bytes_len(); you must track and pass that count yourself.
HMAC — message authentication
import forge_crypto mac = hmac_sha256("secret-key", "message to authenticate") print(mac) // 64-char hex string // Verify: compare the received MAC to the expected MAC expected = hmac_sha256("secret-key", received_message) if expected == received_mac print("Message is authentic") else print("Message was tampered!")
When to use HMAC: API authentication (signing requests), verifying webhooks (GitHub/Stripe send an HMAC so you can verify the payload came from them), JWT (JSON Web Token) signatures.
Encoding — Base64, hex, and URL
// Base64: converts binary data to printable text enc = base64_encode("Hello, NOVA!") // "SGVsbG8sIE5PVkEh" dec = base64_decode(enc) // "Hello, NOVA!" // Common uses: embedding images in HTML, sending binary in JSON, email attachments // Hex: each byte becomes two hex characters (0-9, a-f) hex_str = hex_encode("Hello") // "48656c6c6f" original = hex_decode(hex_str) // "Hello" // More readable than Base64 for debugging; less space-efficient // URL encoding: converts special characters to %XX for safe URL inclusion url_enc = url_encode("hello world&foo=bar") // "hello+world%26foo%3Dbar" url_dec = url_decode(url_enc) // "hello world&foo=bar" // Always URL-encode user input before embedding it in a URL query string
Base64 vs hex trade-off: Base64 uses 1.33 chars per byte (more compact). Hex uses 2 chars per byte (more readable). Use hex for debugging/logging; Base64 for data transfer and storage.
Random values
// Random integer in a range — random_int is INCLUSIVE on both ends (unlike the .. range operator) n = random_int(1, 100) print(n) // some integer between 1 and 100 // Random float between 0.0 and 1.0 f = random_float() print(f) // 0.7234... (varies) // Cryptographically secure random bytes — use for tokens, keys, passwords data = secure_bytes(32) // 32 unpredictable OS-entropy bytes token = base64_encode(secure_bytes(24)) // 32-char URL-safe session token
0.7234
Line-by-line:
random_int(1, 100)— Random integer 1–100 inclusive. Good for games, simulations, tests. NOT suitable for security (predictable if you know the seed).random_float()— Random float 0.0–1.0. Useful for probability, Monte Carlo simulations, random selection.secure_bytes(32)— 32 cryptographically secure random bytes from the OS's entropy source (Linux:/dev/urandom, Windows:CryptGenRandom). Use this for session tokens, API keys, and encryption keys — neverrandom_intfor security.
Cryptographically secure random
// Generate random bytes from OS entropy (NOT predictable) raw = random_bytes(32) // 32 cryptographically random bytes token = base64_encode(random_bytes(24)) // 32-char URL-safe session token // UUID v4 import uuid print(uuid.v4()) // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
DON'T: Name a variable the same as a builtin function you still need to call later, e.g. random_bytes = random_bytes(32). NOVA local variables shadow builtins in their scope — after that line, random_bytes(...) means "call the int/bytes value I just stored," not "call the builtin function," and the very next call to it fails to compile. DO: Pick a distinct name for the result, as in the example above (raw = random_bytes(32)).
Non-cryptographic hashing (built-in, no import)
These hash functions are very fast but NOT suitable for security. Use them for hash tables, data deduplication, and checksums:
print(hash("hello")) // integer hash value — general-purpose print(fnv1a("hello")) // FNV-1a hash — fast, excellent distribution print(murmur3("hello")) // MurmurHash3 — very fast, great for hash tables print(crc32("hello")) // CRC-32 — file integrity / packet checksums
| Function | Speed | Use case |
|---|---|---|
sha256_hex | Slow (secure) | Passwords, signatures, integrity |
hmac_sha256 | Slow (secure) | API auth, JWT, webhooks |
hash / fnv1a | Very fast | Hash table keys, deduplication |
murmur3 | Very fast | Hash tables, bloom filters |
crc32 | Fast | File checksums, network packets |
Seeding murmur3 for partitioning and independent hash functions
murmur3 actually takes two arguments: murmur3(str, seed) -> int. Varying the seed produces a completely different, still well-distributed hash of the same input — that's the entire point of exposing it. A fixed seed gives deterministic, reproducible partitioning (the same key always maps to the same shard, on every run, on every machine); a sequence of seeds gives several statistically independent hash functions derived from one input, which is exactly what a Bloom filter needs.
// Deterministic sharding — the same key always lands on the same shard fn shard_for(key, num_shards) murmur3(key, 0) % num_shards print(shard_for("user:48213", 16)) // same shard index every time this runs // Derive k independent hash functions from ONE hash function by varying the seed fn bloom_positions(item, k, table_size) positions = [] for seed in 0..k // exclusive range — visits seed = 0..k-1, exactly k seeds h = murmur3(item, seed) push(positions, h % table_size) positions print(bloom_positions("needle", 3, 1024)) // 3 bucket indices, e.g. [117, 902, 41]
DO: Pass a fixed seed like 0 for reproducible partitioning, and vary the seed (0, 1, 2, ...) when you need several independent hash functions from one input, as in a Bloom filter. DON'T: Use murmur3 — seeded or not — anywhere an adversary controls the input and correctness depends on collisions being infeasible to find; it is fast and well-distributed, not cryptographically secure. Use sha256 or hmac_sha256 for anything security-sensitive.
Structural hashing — hash() over lists, dicts, and structs
hash(value) -> int is not limited to strings. It recurses structurally over any NOVA value — lists, dicts, and structs all get a well-defined hash derived from their contents, not their identity. Two separate list objects holding the same elements in the same order hash identically, even though they are different objects in memory. This is what makes it practical to deduplicate compound values, or build a custom hash-indexed lookup, without writing per-type comparison logic by hand. (The same structural property backs the Set-based dedup patterns in Chapter 10 — Collections.)
// Same contents, two different list objects — same hash a = [1, 2, 3] b = [1, 2, 3] print(hash(a) == hash(b)) // true — hash is structural, not identity-based // Order matters c = [3, 2, 1] print(hash(a) == hash(c)) // false // Dicts hash by content too d1 = {"name": "Alice", "age": 30} d2 = {"name": "Alice", "age": 30} print(hash(d1) == hash(d2)) // true // Deduplicate a list of structs using their structural hash as a dedup key type Point x: int y: int fn dedup_points(points) seen_hashes = [] out = [] for p in points key = hash(p) if not (key in seen_hashes) push(seen_hashes, key) push(out, p) out pts = [Point { x: 1, y: 2 }, Point { x: 3, y: 4 }, Point { x: 1, y: 2 }] unique = dedup_points(pts) print(len(unique)) // 2 — the duplicate Point{x: 1, y: 2} was removed
false
true
2
DO: Use hash(value) to build custom hash-based lookups over compound values — it recurses through the whole structure, so equal contents always produce equal hashes regardless of object identity. DON'T: Treat hash(a) == hash(b) as proof that a and b are deeply equal — like any hash function, collisions are possible (though extremely rare); use the hash to narrow candidates quickly, then confirm with == wherever correctness actually matters, such as final dedup output.
Available secure algorithms: SHA-256/384/512, MD5, HMAC-SHA256/384/512, PBKDF2 (key derivation), HKDF, AES-256-GCM, ChaCha20-Poly1305, X25519 (Diffie-Hellman key exchange), Ed25519 (digital signing), ECDSA P-256, RSA-PKCS1/PSS verify, full TLS 1.3 handshake, X.509 certificate verification.
DON'T: Use hash(), fnv1a(), or crc32() for security — they are not cryptographically secure and can be forged. DON'T: Use MD5 for security — it has known collision attacks. DON'T: Use SHA-256 directly for passwords — use PBKDF2 (designed to be slow, resists brute-force). DO: Use random_bytes() for session tokens and API keys.
Algorithm selection guide: use SHA-256 for checksums and data integrity; HMAC-SHA-256 for API request signing and token verification; PBKDF2 for storing user passwords (with at least 100,000 iterations); AES-256-GCM for encrypting data at rest; ChaCha20-Poly1305 for encrypting data in transit where AES hardware acceleration is unavailable; X25519 for key exchange; Ed25519 for digital signatures; HKDF for deriving multiple keys from one master secret.
A note on why NOVA ships cryptography as pure-NOVA code rather than binding OpenSSL: OpenSSL has a long history of CVEs traceable to its C implementation complexity. Pure-NOVA crypto can be audited with the same tools as application code, tested with the same test runner, and verified against RFC KAT vectors without a separate C build step. The tradeoff is performance — hardware AES acceleration requires a C backend call; NOVA's AES implementation runs in software. For most web applications the difference is imperceptible; for high-volume TLS termination, a future LLVM intrinsic path will close the gap.
Never roll your own cryptographic primitives. Use the functions listed above exactly as documented. The most common cryptographic mistake is not choosing the wrong algorithm but implementing a correct algorithm incorrectly — off-by-one errors in key derivation, reusing nonces in AES-GCM, or comparing MACs with == (timing-side-channel) instead of hmac_verify (constant-time comparison). NOVA's built-in functions handle these details correctly.
23. System and environment
What is this? Programs need to interact with the operating system: read command-line arguments, access environment variables, query system information, run external commands, and manage subprocesses. This section covers all of NOVA's system interaction functions.
Command-line arguments
// Run: nova run script.nova arg1 arg2 arg3 arguments = args() print(arguments) // ["arg1", "arg2", "arg3"] if len(arguments) < 1 print("Usage: program <filename>") exit(1) filename = arguments[0] // arguments are strings; use int() if you need a number
Line-by-line:
args()— Returns a list of strings containing all command-line arguments passed to the program. The program name itself is NOT included.arguments[0]— The first argument. Always a string — useint(arguments[0])to convert to integer.
Environment variables
// Read an environment variable (returns "" if not set) home = env("HOME") print(home) // /home/user (Linux) or C:\Users\user (Windows) // Set for current process only set_env("MY_VAR", "hello") print(env("MY_VAR")) // hello // Common pattern: use env var or fall back to a default port = env("PORT") if port == "" then port = "8080" print("Listening on port {port}")
Common environment variables: HOME (user's home directory), PATH (where OS looks for programs), NOVA_HOME (NOVA's standard library location), PORT (convention for server port in cloud deployments).
System information
print(os_name()) // "windows" or "linux" or "darwin" (macOS) print(arch_name()) // "x86_64" or "aarch64" (ARM) print(hostname()) // your computer's hostname print(getpid()) // current process ID (a number like 12345) print(cpu_count()) // number of CPU cores (e.g., 8) print(temp_dir()) // temp directory ("/tmp" or "C:\...\Temp") print(cwd()) // current working directory // Platform-specific behavior if os_name() == "windows" print("Running on Windows") else print("Running on Unix-like OS")
x86_64
hostname
12345
8
/tmp
/home/user/project
Running on Unix-like OS
Locating the running binary, and changing directories
cwd() answers "what directory did the shell launch me from?" — self_exe_path() answers a completely different question: "where does my own executable file live on disk?" Once a tool is installed anywhere outside the folder it was built in — a symlink in /usr/local/bin, an entry on Windows' PATH, a binary bundled inside an installer — these two directories are almost never the same. Reach for self_exe_path() whenever a program needs to find files that ship next to its own binary — a default config template, a plugins/ folder, a version-pinned asset bundle — regardless of which directory the user happened to be sitting in when they typed the command. chdir(path) is the write-side counterpart to cwd(): it moves the process's current working directory, so every subsequent relative path — file opens, shell() calls, the next cwd() read — resolves against the new location instead of the old one.
print(self_exe_path()) // where the binary itself lives -- not where you launched it from print(cwd()) // the directory the process started in chdir("/tmp") print(cwd()) // "/tmp" -- chdir actually moved the process there
/home/user/project
/tmp
DO: Call self_exe_path() to locate resources bundled alongside your binary instead of assuming a fixed install directory — installers and package managers put binaries in different places on every OS. DON'T: Call chdir() from inside a spawned task that runs concurrently with other file work — the working directory is global, process-wide state, so two tasks calling chdir() at the same time race each other, and every other task's relative paths silently start resolving against the wrong directory.
Running external commands
// Run a command and capture its output as a string output = shell("echo hello") print(output) // hello // Run and check the exit code (0 = success, non-zero = failure) result = system("git status") print(result) // 0 // More control: run a subprocess, read stdout, wait for it to finish proc = proc_open("python3 -c \"print('hello from python')\"") output = proc_read_stdout(proc) exit_code = proc_wait(proc) print(output) // hello from python print(exit_code) // 0
Line-by-line:
shell("echo hello")— Runs the command in the system shell and returns its standard output as a string. Use when you need the command's output.system("git status")— Runs the command and returns its exit code. Use when you only care about success/failure.proc_open(cmd)— Starts a subprocess by handingcmdto the OS shell to parse and run, just likeshell()andsystem()(it takes ONE command string, not a program name plus an argument list). The subprocess runs in parallel with your NOVA program. Returns a process handle.proc_wait(proc)— Waits for the subprocess to finish and returns its exit code.
Piping data into a subprocess
The subprocess example above only reads a command's output — but proc_open gives you both ends of the pipe. proc_write_stdin(h, data) writes bytes to the subprocess's stdin, and proc_close_stdin(h) signals end-of-input by closing that pipe. This matters for any filter-style external tool — sort, a formatter, a template engine — that reads its entire input before producing any output at all. Those programs call the OS-level read in a loop until it hits EOF; if you never close the stdin pipe, that read never returns, the subprocess never writes anything to stdout, and your NOVA program's proc_read_stdout(h) call blocks forever waiting for output that will never come. Both sides end up stuck waiting on each other — a classic pipe deadlock, the same failure mode as a mishandled bidirectional pipe in C, Python, or Go. The fix is a strict three-step discipline: write everything, close stdin, then read.
h = proc_open("sort") proc_write_stdin(h, "banana\napple\ncherry\n") proc_close_stdin(h) // EOF signal -- without this, sort blocks forever waiting for more input sorted_output = proc_read_stdout(h) code = proc_wait(h) print(sorted_output) // apple / banana / cherry, one per line print(code) // 0
banana
cherry
0
DO: Always call proc_close_stdin(h) before proc_read_stdout(h) for any subprocess that buffers its whole input before writing output. DON'T: Expect proc_wait(h) to break a stalled pipe — if the subprocess is blocked reading stdin and your program is blocked reading its stdout, proc_wait blocks too; the deadlock only breaks once stdin is actually closed.
Locating an executable: which
which(cmd) resolves a bare program name to its absolute path by searching the same PATH the OS shell would — and returns an empty string, not an error, when nothing is found. It exists for exactly the moment before you call shell(), system(), or proc_open(): check that the tool you're about to invoke actually exists, so your program can fail with a clear message of its own instead of surfacing whatever cryptic "command not found" the shell would produce two calls later. It's also the building block for portable fallback chains — trying a preferred tool first, then falling back to an alternative — without hardcoding either one's install location.
cc = which("clang") if cc == "" print("clang not found, falling back to gcc") cc = which("gcc") if cc == "" print("No C compiler found on PATH") exit(1) print("Using compiler: {cc}")
Using compiler: /usr/bin/gcc
DO: Check which(tool) before shelling out to it and fail with a specific error message when it comes back empty. DON'T: Hardcode absolute tool paths like "/usr/bin/clang" — they differ across Linux distros, Homebrew's /opt/homebrew on Apple Silicon, and Windows entirely; which() resolves the current machine's actual PATH instead.
Standard input and output at the byte level
print() and readline() cover most interactive I/O, but both are line-oriented — print() always appends a newline, and readline() always waits for one before returning. stdout_write(s) writes raw text with no trailing newline, which is what an in-place prompt or a redrawing progress bar needs: anything that has to keep the cursor on the same line instead of starting a fresh one. stdin_read_n(n) is its input-side counterpart — it reads up to n bytes from stdin without waiting for a newline at all, which is the only option once you're reading a piped binary stream or a fixed-width protocol frame that has no line structure to wait for in the first place.
stdout_write("Enter your name: ") // no trailing newline -- prompt and reply share one line name = stdin_read_n(32) print("Hello, {name}")
stdout_write("Enter your name: ")— writes the prompt with no newline, so the cursor stays on the same line waiting for the reply, instead of dropping to a new line the wayprint()would.stdin_read_n(32)— reads up to 32 raw bytes. Unlikereadline(), it has no concept of "line" to stop at or strip — it returns whatever bytes arrived, so trim trailing whitespace yourself if your protocol needs it.
DO: Use stdout_write() for prompts and progress indicators that must stay on one line, then follow up with a normal print() once you're ready to move to a new line. DON'T: Reach for stdin_read_n() for ordinary line-based input like a username or a menu choice — readline() already strips the trailing newline for you; use stdin_read_n() only when the input isn't line-delimited at all.
Exit — terminating the program
if critical_error print("Fatal error!") exit(1) // non-zero exit code signals failure to the caller exit(0) // 0 = success
By convention, exit code 0 means success and any non-zero code means failure. Shell scripts and CI systems check the exit code to determine if a program succeeded.
Building a complete CLI tool
Here is a full command-line tool that parses flags, validates arguments, and reports errors properly — the pattern for any CLI program:
// novagrep: search for a pattern in a file // Usage: novagrep <pattern> <file> [--count] fn usage() print("Usage: novagrep <pattern> <file> [--count]") print(" --count Print count only, not matching lines") exit(1) fn main() argv = args() if len(argv) < 2 usage() pattern = argv[0] filename = argv[1] count_only = len(argv) >= 3 and argv[2] == "--count" content = read_file(filename) if content == "" print("Error: cannot read file '{filename}'") exit(2) lines = split(content, "\n") matches = filter(lines, s => s matches pattern) if count_only print(len(matches)) else for line in matches print(line) if len(matches) == 0 exit(1) // exit code 1 = no matches (like grep)
Line-by-line:
argv = args()— Gets all arguments after the program name. Fornovagrep "hello" file.txt --count, argv is["hello", "file.txt", "--count"].len(argv) < 2— Requires at least pattern and filename. Without 2 arguments, print usage and exit with code 1.count_only = len(argv) >= 3 and argv[2] == "--count"— Check if the third argument is the optional flag. This is manual flag parsing — for complex CLIs, a parsing library would abstract this.filter(lines, s => s matches pattern)— Filter all lines to only those matching the regex pattern.matchesis the NOVA keyword for regex test, equivalent to Python'sre.search().exit(1)at the end — Exit code 1 when there are zero matches, following the POSIX convention used by the realgreptool. CI scripts use this to check whether a pattern exists in a file.
Cross-platform path handling
File paths look different on Windows vs Unix. NOVA provides functions that normalize paths correctly for the current OS:
// path_join: correct separator on every OS config_path = path_join([env("HOME"), ".config", "myapp", "config.json"]) // Linux/Mac: /home/user/.config/myapp/config.json // Windows: C:\Users\user\.config\myapp\config.json // Don't hardcode separators — this breaks on Windows bad_path = env("HOME") + "/" + ".config" // DON'T — broken on Windows good_path = path_join([env("HOME"), ".config"]) // DO — works everywhere // Get path components filename = path_name("/home/user/file.txt") // "file.txt" directory = path_parent("/home/user/file.txt") // "/home/user" // Check if a path exists if file_exists(config_path) print("Config found") else print("Using defaults")
DO: Always use path_join([...]) to build file paths. DON'T: Concatenate paths with "/" or "\" — this breaks on the other platform. NOVA programs should run on Linux, Mac, and Windows with zero code changes.
System API compared across languages
| Operation | NOVA | Python | Go |
|---|---|---|---|
| Command-line args | args() | sys.argv[1:] | os.Args[1:] |
| Environment variable | env("VAR") | os.environ.get("VAR", "") | os.Getenv("VAR") |
| Run command | shell("cmd") | subprocess.check_output(["cmd"]) | exec.Command("cmd").Output() |
| Join paths | path_join([a, b]) | os.path.join(a, b) | filepath.Join(a, b) |
| OS name | os_name() | sys.platform | runtime.GOOS |
| Exit with code | exit(0) | sys.exit(0) | os.Exit(0) |
24. Logging
What is this? When something goes wrong in production at 3am, logs are your primary tool for diagnosing what happened. NOVA has built-in structured logging — no external library needed. It supports severity levels, timestamps, and JSON output for log aggregation tools.
Log levels — from most detailed to most critical
Every log_* function takes two arguments: the message string, and an optional fields dict for structured context (pass null or {} when there is none — there is no one-argument form):
log_trace("entering function X with n={n}", null) // [TRACE] extremely detailed log_debug("loaded {len(users)} users from db", null) // [DEBUG] development info log_info("server started on port {port}", null) // [INFO] normal operations log_warn("slow query: {elapsed}ms", null) // [WARN] unusual but handled log_error("db connection failed: {err}", null) // [ERROR] real problem, continuing log_fatal("required config missing", null) // [FATAL] logs, then terminates the process
What each level is for:
log_trace— Exact program flow. "Entering function X," "Variable Y is now 42." Turn off in production — it generates enormous output.log_debug— Useful during development. Cache hits, query results, intermediate values.log_info— Routine events worth noting. "Server started," "Batch complete." This is the default production level.log_warn— Unexpected but handled. "Retrying request," "Config not found, using defaults."log_error— Something went wrong, program continues. "Failed to connect," "Invalid input." Investigate these.log_fatal— Critical failure. Unlike the other five, this one always terminates the process after logging (it calls the same panic path as an uncaught runtime error) — it does not just print and continue.
Controlling verbosity
The minimum level is a plain integer, not a string — lower numbers are more verbose:
| Constant | Value | Constant | Value |
|---|---|---|---|
TRACE | 0 | ERROR | 4 |
DEBUG | 1 | FATAL | 5 |
INFO | 2 | OFF | 6 |
WARN | 3 |
log_set_level(2) // INFO — only info and above (hides trace and debug)
Level hierarchy: 0 (trace) < 1 (debug) < 2 (info) < 3 (warn) < 4 (error) < 5 (fatal) < 6 (off). Setting log_set_level(3) shows only warn, error, and fatal. The default level (before you call log_set_level) is 2 (info). There is currently no NOVA_LOG environment variable read automatically — verbosity is set in code, at startup, with log_set_level(...).
log_get_level() reads back the currently active threshold — the counterpart to log_set_level above. This matters once logging code stops living only in a single top-level main() and starts living inside reusable functions: a function that needs extra verbosity for one diagnostic window should restore whatever level it found on the way out, not assume it knows what the caller already configured.
fn diagnose_slow_request(req) let saved = log_get_level() log_set_level(0) // TRACE — capture everything while diagnosing log_trace("headers: {req.headers}", null) let result = handle(req) log_set_level(saved) // hand verbosity back exactly as found it result
DO: Save the result of log_get_level() before temporarily changing it, and restore it when you're done — a reusable function that calls log_set_level(0) without saving/restoring first permanently changes verbosity for the whole process, including unrelated code that runs afterward. DON'T: Assume the level is always 2 (info) inside a library module — whatever the host application's main() set before your code ran is the level you're actually inheriting.
JSON structured logging
For production systems with log aggregation tools (Elasticsearch, Datadog, Grafana Loki), use JSON-formatted output. There is no separate log_json function — log_set_json(true) switches ALL log_* calls to JSON, and the second (fields) argument of any of them is how you attach structured context:
log_set_json(true) // switch to JSON output log_info("request handled", null) // {"level":"INFO","time":"2026-06-28T12:00:00","msg":"request handled"} // Attach structured context via the fields dict — the SECOND argument, not a separate function log_info("request", { "method": method, "path": path, "status": 200, "ms": elapsed }) // {"level":"INFO","time":"2026-06-28T12:00:00","msg":"request","method":"GET","path":"/users","status":"200","ms":"3"}
Field values come back as strings inside the JSON, regardless of their NOVA type (the 200 and 3 above are numbers on the way in, quoted strings on the way out) — the logger stringifies every field value before writing it. If a downstream tool needs numeric types, parse them on the aggregator side, not the NOVA side. The timestamp is local time, formatted YYYY-MM-DDTHH:MM:SS with no timezone suffix — there is no built-in UTC/Z option.
DO: Use log_info for events that matter in production (startup, config loaded, shutdown). Use log_debug for verbose tracing you only want during development.
DON'T: Log per-request data at log_info in high-throughput services — you will flood your log aggregator. Use log_debug for per-request detail and only promote to log_warn when something is worth investigating.
DON'T: Call any log_* function with only one argument — the fields parameter is required; pass null when there is nothing structured to attach.
Real-world logging pattern — a web request logger
Here is the logging pattern you will use in every production Forge server. The goal: every request gets a structured log entry with method, path, status code, and elapsed time, all as JSON fields that log aggregators can query:
import forge // Middleware body needs more than one statement, so it is a named function — // an anonymous fn(req, next) can only hold a single expression. fn request_logger(req, next) start = time_ms() result = next(req) // call the actual handler elapsed = time_ms() - start log_info("request", { "method": req.method, "path": req.path, "ms": elapsed }) result fn main() app = forge.app() // JSON logging — machine-readable for log aggregation tools log_set_json(true) log_set_level(2) // INFO forge.use(app, request_logger) forge.get(app, "/", fn(req) "Hello!") log_info("Server started on port 8080", null) forge.serve_app(app, 8080) // Every request now logs: {"level":"INFO","time":"...","msg":"request","method":"GET","path":"/","ms":"1"}
Line-by-line breakdown:
forge.use(app, request_logger)— attaches a middleware function, passed by name because its body has more than one statement. Middleware runs before (and after) every request handler. Thenext(req)call runs the actual route handler.log_set_json(true)— switches all log output to JSON format. Every subsequentlog_info,log_debug, etc. produces a JSON object instead of plain text.log_set_level(2)— sets the minimum level to INFO. Onlylog_info,log_warn,log_error,log_fatalproduce output.log_debug(1) andlog_trace(0) are below the threshold and are silenced — good for production where verbose output would flood the aggregator.start = time_ms()— records the start time in milliseconds before calling the handler.elapsed = time_ms() - start— computed after the handler finishes. This measures the handler's actual runtime, not just scheduling latency.log_info("request", {...})— logs a structured event with named fields as the SECOND argument — there is no separatelog_jsonfunction.req.method/req.pathare struct fields onRequest, not dict indexing.
NOVA's logger currently writes only to stdout (levels below WARN) and stderr (WARN and above) — there is no built-in log_set_output(path) to redirect to a file. For batch jobs and daemons that need a log file, redirect the process's own output at the shell level instead: nova run server.nova >> app.log 2>&1. For long-running servers, prefer JSON logging to stdout/stderr and let a log aggregator (Filebeat, Promtail, Fluentd) read and ship the output — this is also how most containerized deployments expect logs to be delivered.
Logging comparison — NOVA vs other languages
| Feature | NOVA | Python (logging) | Go (log/slog) | Java (SLF4J) |
|---|---|---|---|---|
| Import required | No — built-in | import logging | import log/slog | import org.slf4j.* |
| Log levels | trace/debug/info/warn/error/fatal | DEBUG/INFO/WARNING/ERROR/CRITICAL | Debug/Info/Warn/Error | TRACE/DEBUG/INFO/WARN/ERROR |
| JSON output | log_set_json(true) | Custom formatter required | Built-in slog JSON handler | Logback JSON encoder |
| Structured fields | log_info("event", {k:v}) — fields are the 2nd arg of any log call | logging.info("msg", extra={...}) | slog.Info("msg", "key", val) | log.info("{}", val) |
| Set level at runtime | log_set_level(2) — integer constant, not a string | logging.setLevel(logging.INFO) | Programmatic via handler | Logback XML or API |
DO: Always log: application startup (with version and config), requests in/out (with method + path + status + elapsed), errors with full context (which user, which resource, what failed). DON'T: Log passwords, tokens, credit card numbers, or any PII (Personally Identifiable Information). DON'T: Use print() for production logging — print has no timestamps, no levels, and no JSON output.
25. Forge: building a REST API
What is this? NOVA's built-in web framework. No package manager needed — import forge and you have a full web server. Compiles to a single ~1.6MB binary. Per-request arena memory means zero GC pauses under load. Handles thousands of concurrent connections with NOVA's green-task scheduler.
Minimal server
import forge fn main() app = forge.app() forge.get(app, "/", fn(req) "Hello from NOVA!" ) forge.serve_app(app, 8080)
nova build server.nova && ./server curl http://localhost:8080/ # Hello from NOVA!
What happens inside forge.serve() — step by step
- Forge calls
tcp_listen(8080)to start listening on port 8080. - It enters an accept loop — waiting for clients to connect.
- When a client connects, Forge spawns a new green task to handle that client. The main accept loop immediately goes back to waiting for the next connection.
- The green task reads the HTTP request, parses the method and path, finds the matching route, calls your handler function, and sends the HTTP response back to the client.
- All memory allocated during request handling (strings, lists, dicts you build in the handler) uses a per-request arena. When the request completes, the entire arena is freed in one step — no garbage collector, no memory leak.
- The green task ends. The next request gets a fresh green task and a fresh arena.
This is why Forge is fast: arenas eliminate GC pauses, green tasks enable thousands of concurrent connections, and LLVM-compiled native code provides low per-request latency.
HTTP methods — what each one means
| Method | Forge function | When to use |
|---|---|---|
| GET | forge.get() | Retrieve data (viewing a page, fetching a list). Must NOT change server state. |
| POST | forge.post() | Create new data (submitting a form, creating a record). |
| PUT | forge.put() | Replace an existing resource entirely. |
| PATCH | forge.patch() | Partially update an existing resource (change one field). |
| DELETE | forge.delete() | Remove a resource. |
DO: Use forge.get for reading and forge.post/forge.delete for mutations. DON'T: Use GET for operations that change data — browsers prefetch GET links, which can trigger unintended mutations.
Routes: path params, query params, request body
import forge type Todo id: int title: string done: int // The shape of a create-todo POST body. type NewTodoTitle title: string fn main() let app = forge.app() let todos = [Todo(1, "Buy milk", 0), Todo(2, "Learn NOVA", 0)] // GET /todos — list all forge.get(app, "/todos", fn(req) todos) // GET /todos/filter?done=true — query params live in req.query. Registered // BEFORE /todos/:id below: routes are matched in registration order, so if // the :id route came first, a request for literal "/todos/filter" would be // caught by :id first (with id == "filter") and never reach this handler. forge.get(app, "/todos/filter", req => let done_only = req.query["done"] == "true" forge.resp_json(200, filter(todos, t => not done_only or t.done == 1)) ) // GET /todos/:id — get one forge.get(app, "/todos/:id", req => let id = int(req.params["id"]) // :id from URL — path params live in req.params for t in todos if t.id == id return forge.resp_json(200, t) forge.resp_error(404, "not found") ) // POST /todos — create one (parse JSON body) forge.post(app, "/todos", req => let body: NewTodoTitle = from_json(req.body) let id = len(todos) + 1 let todo = Todo(id, body.title, 0) push(todos, todo) forge.resp_json(201, todo) // 201 Created — resp_json serializes the struct for you ) forge.serve_app(app, 8080)
Line-by-line:
req => <block>— an arrow lambda sitting directly in call-argument position accepts a multi-statement indented body (it's lifted into a real function); thefn(req) ...keyword form does not — it's always exactly one expression. That's why the single-expression list handler above usesfn(req) todos, but every handler that does more than one thing usesreq =>instead.- Route registration order matters:
/todos/filteris registered before/todos/:idspecifically so the literal segment wins the match — see the comment above it. req.params["id"]— EveryRequesthas aparams: dictfield, filled by the router at match time from named segments (:id) in the route pattern. Read it directly — there is noparam()function.req.query["done"]— Similarly,Requesthas aquery: dictfield parsed once from the?key=valuepart of the URL. Both fields are plain dict access, not function calls.let body: NewTodoTitle = from_json(req.body)— Parses the request body as JSON directly into a typed struct. There's also an untypedforge.body_json(req)that returns a plain dict — reach for it only when you genuinely don't know the shape ahead of time; a typed target avoids untyped dict indexing on request bodies, which the compiler can't always resolve.
DON'T: Use query_get(req, key) to read a path parameter or a query parameter off a Request object — query_get takes a raw query STRING as its first argument, not a Request struct, and it has nothing to do with path params (:id) at all. DO: Read req.params[...] for path segments and req.query[...] for query-string values — both are plain struct-field dict access, no function call needed.
Complete CRUD REST API — Todo list
All five HTTP methods in one app (~35 lines). This is what would take 100+ lines in Flask or Express:
import forge type Todo id: int title: string done: bool // Shapes for the parts of a Todo a client actually sends — // narrower than Todo itself, so from_json has an exact target. type NewTodo title: string type TodoUpdate title: string done: bool // In-memory store, at module level so every handler below shares it. // In production, replace with a database (see §26). let todos = [] let next_id = [1] // a 1-element list so the handler below can mutate it in place // Each route gets a NAMED handler — a lambda body is always a single // expression, so a multi-step handler like these needs a name. // LIST: GET /todos fn list_todos(req: Request) -> Response forge.resp_json(200, todos) // CREATE: POST /todos body: {"title":"Learn NOVA"} fn create_todo(req: Request) -> Response let body: NewTodo = from_json(req.body) let todo = Todo(next_id[0], body.title, false) push(todos, todo) next_id[0] = next_id[0] + 1 forge.resp_json(201, todo) // READ: GET /todos/:id fn read_todo(req: Request) -> Response let id = int(req.params["id"]) for t in todos if t.id == id return forge.resp_json(200, t) forge.resp_error(404, "todo not found") // UPDATE: PUT /todos/:id body: {"title":"...", "done":true} fn update_todo(req: Request) -> Response let id = int(req.params["id"]) let body: TodoUpdate = from_json(req.body) for t in todos if t.id == id t.title = body.title t.done = body.done return forge.resp_json(200, t) forge.resp_error(404, "todo not found") // DELETE: DELETE /todos/:id fn delete_todo(req: Request) -> Response let id = int(req.params["id"]) todos = filter(todos, t => t.id != id) forge.resp_json(200, {"deleted": true}) fn main() let app = forge.app() forge.get(app, "/todos", fn(req) list_todos(req)) forge.post(app, "/todos", fn(req) create_todo(req)) forge.get(app, "/todos/:id", fn(req) read_todo(req)) forge.put(app, "/todos/:id", fn(req) update_todo(req)) forge.delete(app, "/todos/:id", fn(req) delete_todo(req)) print("Todo API on http://localhost:8080") forge.serve_app(app, 8080)
Line-by-line breakdown:
let todos = []/let next_id = [1]— In-memory store, at module level so every handler function can see and mutate it.next_idis wrapped in a 1-element list rather than a bare int specifically sonext_id[0] = next_id[0] + 1can mutate it in place — a bare captured int can't be reassigned from inside a different function the way a list's element can.fn(req) create_todo(req)— every route handler here is a one-line lambda that immediately delegates to a named function. A NOVA lambda body is always a single expression, so a handler that does more than one thing (parse, then respond) needs a name — this is a real, load-bearing rule, not a style preference.let body: NewTodo = from_json(req.body)— typed JSON parsing: the: NewTodoannotation tellsfrom_jsonexactly which struct to build, sobody.titleis real field access on a real typed value, not an untyped dict lookup.let id = int(req.params["id"])— Path parameters live inreq.params, a plain dict filled in by the router from the:idsegment of the route pattern.forge.resp_json(201, todo)— Serializes ANY value — struct, dict, or list — to a properResponse, in one call. This is the counterpart toforge.get/forge.post-style app routing; the similarly-namedforge.json_ofreturns a raw response STRING instead and pairs with the lower-levelforge.serve(port, handler)— the two are not interchangeable.forge.resp_error(404, "todo not found")— Returns a 404Response. Forge handles the HTTP status code.todos = filter(todos, t => t.id != id)— Builds a new list excluding the deleted item. No mutation needed.
Testing:
# Create a todo curl -X POST -H "Content-Type: application/json" \ -d '{"title":"Learn NOVA"}' http://localhost:8080/todos # {"id":1,"title":"Learn NOVA","done":false} # List all curl http://localhost:8080/todos # [{"id":1,"title":"Learn NOVA","done":false}] # Mark done curl -X PUT -H "Content-Type: application/json" \ -d '{"title":"Learn NOVA","done":true}' http://localhost:8080/todos/1 # {"id":1,"title":"Learn NOVA","done":true} # Delete curl -X DELETE http://localhost:8080/todos/1 # {"deleted": true}
GET /todos → 200 [{"id":1,"title":"Learn NOVA","done":false}]
PUT /todos/1 → 200 {"id":1,"title":"Learn NOVA","done":true}
DELETE /todos/1 → 200 {"deleted": true}
Forge vs competitors:
| Framework | Language | Concurrency | Memory | Deploy |
|---|---|---|---|---|
| Forge (NOVA) | NOVA | Green tasks, non-blocking | Arena, zero GC | Single binary |
| Flask | Python | Threads or async | GC, high memory | Python + gunicorn + nginx |
| Django | Python | Threads | GC, high memory | Python + WSGI stack |
| Gin | Go | goroutines | GC | Single binary |
| Actix-Web | Rust | async/tokio | Manual, fast | Single binary |
26. Forge: data layer & ORM
What is this? Two layers, from lowest to highest ceremony. sqlitex/forge_db below are raw parameterized SQL over an embedded SQLite file — no DB server to install or manage, NOVA links SQLite via FFI. forge_orm, covered later in this chapter, is a single agnostic ORM that runs the SAME struct-driven code over SQLite, PostgreSQL, or MySQL — one API, three databases, zero annotations. All queries on every layer are parameterized to prevent SQL injection.
SQLite access has two layers: sqlitex is the single-connection layer (open one handle, run queries on it); forge_db is a POOL built on top of it (a fixed number of connections handed out from a channel — what a Forge server should use, since many requests query concurrently). This section starts with sqlitex directly, then moves to forge_db pools once Forge enters the picture.
Basic CRUD operations
import sqlitex // Open (creates file if not exists) db = sqlitex.db_open("app.db") // Create table — db_exec is for DDL / non-parameterized statements only (no ? placeholders) sqlitex.db_exec(db, "CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE )") // Insert — ALWAYS use ? parameters, never string concat (SQL injection!) // db_query always takes a params list — pass [] when there are none sqlitex.db_query(db, "INSERT INTO users (name, email) VALUES (?, ?)", ["Alice", "alice@example.com"]) sqlitex.db_query(db, "INSERT INTO users (name, email) VALUES (?, ?)", ["Bob", "bob@example.com"]) // Query rows as name-keyed dicts — db_query_named is the dict-returning variant rows = sqlitex.db_query_named(db, "SELECT * FROM users", []) for row in rows print("{row["id"]}: {row["name"]} <{row["email"]}>") // Parameterized WHERE clause alice = sqlitex.db_query_named(db, "SELECT * FROM users WHERE email = ?", ["alice@example.com"]) print(alice[0]["name"]) // Alice // Update and delete — db_query again (both are parameterized statements) sqlitex.db_query(db, "UPDATE users SET name = ? WHERE id = ?", ["Alicia", 1]) sqlitex.db_query(db, "DELETE FROM users WHERE id = ?", [2])
2: Bob <bob@example.com>
Alice
Two query functions, two shapes: db_query(db, sql, params) returns each row as a plain LIST of column values in SELECT order (row[0], row[1], ...) — cheap, but positional. db_query_named(db, sql, params) does the extra work of reading the real column names back from SQLite and returns each row as a DICT (row["name"]) — what you want for readable code, at a small extra cost. Both are equally injection-safe; the difference is only how you index the result.
DON'T: Concatenate user input into SQL strings. "SELECT * FROM users WHERE name = '" + name + "'" is a SQL injection vulnerability that can delete your entire database. ALWAYS use ? parameters: sqlitex.db_query(db, "... WHERE name = ?", [name]). DON'T: Reach for db_exec when a statement needs parameters — it takes only (db, sql), no params argument at all, and exists solely for DDL like CREATE TABLE. Every parameterized statement, including INSERT/UPDATE/DELETE, goes through db_query or db_write.
SQL injection — the #1 database vulnerability
SQL injection happens when you build a SQL query by concatenating user input as a string. An attacker can type SQL commands as their "name" and destroy your database:
// USER INPUT: name = "'; DROP TABLE users;--" // (attacker typed this as their username) // WRONG: string concatenation — SQL INJECTION VULNERABILITY // sqlitex.db_exec(db, "INSERT INTO users (name) VALUES ('" + name + "')") // This executes: INSERT INTO users (name) VALUES (''); DROP TABLE users;--' // Result: your entire users table is deleted! // CORRECT: parameterized — always safe, regardless of input sqlitex.db_query(db, "INSERT INTO users (name) VALUES (?)", [name]) // The ? is replaced safely. "'; DROP TABLE users;--" is stored as a literal name string.
Rule: Never put user-provided values directly into a SQL string. Always use ? placeholders. There are no exceptions to this rule.
Write errors are silent unless you ask — db_write
db_query's return value only ever reports SELECTed rows — a failed INSERT/UPDATE (e.g. a UNIQUE constraint violation) still returns [], indistinguishable from "zero rows matched." When you need to know whether a write actually succeeded, use db_write instead, which returns a real Result:
match sqlitex.db_write(db, "INSERT INTO users (name, email) VALUES (?, ?)", ["Alice", "alice@example.com"]) Ok(n) => print("inserted, {n} row(s) affected") Err(e) => print("insert failed: {e}") // e.g. UNIQUE constraint failed: users.email
Transactions — all-or-nothing operations
A transaction groups multiple database operations so they either ALL succeed or ALL fail. Essential for operations that must be atomic — like transferring money (debit one account, credit another). The pooled helper forge_db.with_tx is the idiomatic way to run one:
import forge_db pool = forge_db.pool_open("app.db", 5) // a pool of 5 connections // with_tx acquires ONE pooled connection, BEGINs, runs body_fn(db) on that connection, // then COMMITs if body_fn returns ok(...) or ROLLS BACK if it returns err(...) result = forge_db.with_tx(pool, fn(db) sqlitex.db_query(db, "UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]) sqlitex.db_query(db, "UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]) ok(true) // body_fn MUST return a Result — this is what triggers the commit ) // if body_fn had returned err(...) instead, BOTH updates roll back — balances stay unchanged
forge_db.pool_open("app.db", 5)— Opens a pool of 5 connections, not one. The pool is a channel under the hood — acquiring a connection is arecv, which parks the calling task if all 5 are busy rather than failing.forge_db.with_tx(pool, fn(db) ...)— Acquires a connection (bounded by a 5-second timeout — a saturated pool degrades to a fasterr("pool acquire timeout")instead of hanging forever), BEGINs, and runs your closure on that SAME connection. A transaction cannot span more than one connection, which is why the closure receivesdbto use for every statement inside it.- The closure's return value IS the commit/rollback decision — return
ok(...)to commit,err(...)to roll back. This is different from exceptions in other languages: there's no implicit "commit unless it throws," you say explicitly which outcome you got. - Without a transaction, if the first update succeeds but the server crashes before the second runs, account 1 has been debited but account 2 has not been credited — money disappears. The transaction prevents this inconsistency.
Lower-level syntax (explicit BEGIN/COMMIT on a single sqlitex connection) also works, and is what with_tx does internally:
sqlitex.db_exec(db, "BEGIN") sqlitex.db_query(db, "UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]) sqlitex.db_query(db, "UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]) sqlitex.db_exec(db, "COMMIT") // both succeed or both roll back
Complete Forge + SQLite API
Real-world pattern: a connection pool, full CRUD, parameterized queries, and a Forge server:
import forge import forge_db // The shape of a user-creation POST body. type NewUser name: string email: string fn main() let pool = forge_db.pool_open("app.db", 5) // a pool of 5 connections forge_db.pool_exec(pool, "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL)") let app = forge.app() let cols = ["id", "name", "email"] // pool_query_dicts needs the SELECT's column names, in order // Each handler below is `param =>` — an arrow lambda sitting directly in // call-argument position, which (unlike the `fn(req) ...` keyword form) DOES accept // a multi-statement indented body. It's lifted into a real function under the hood. // List all users forge.get(app, "/users", req => let users = forge_db.pool_query_dicts(pool, "SELECT id, name, email FROM users", [], cols) forge.resp_json(200, users) ) // Create a user — pool_insert runs a parameterized INSERT and returns the new row's id forge.post(app, "/users", req => let body: NewUser = from_json(req.body) let id = forge_db.pool_insert(pool, "INSERT INTO users (name, email) VALUES (?, ?)", [body.name, body.email]) forge.resp_json(201, {"id": id}) ) // Get one user by ID forge.get(app, "/users/:id", req => let rows = forge_db.pool_query_dicts(pool, "SELECT id, name, email FROM users WHERE id = ?", [req.params["id"]], cols) if len(rows) == 0 return forge.resp_error(404, "user not found") forge.resp_json(200, rows[0]) ) // Delete a user — a parameterized statement, so pool_query (not pool_exec) forge.delete(app, "/users/:id", req => forge_db.pool_query(pool, "DELETE FROM users WHERE id = ?", [req.params["id"]]) forge.resp_json(200, {"deleted": true}) ) forge.serve_app(app, 8080)
forge_db.pool_open("app.db", 5)— Opens a pool of 5 connections to the SQLite file, so concurrent handlers never contend for the same one.forge_db.pool_query_dicts(pool, sql, params, cols)— Runs a parameterized query and returns a list of dicts keyed bycols.colsmust match the SELECT list's order exactly —pool_query_dictsdoesn't read column names back from SQLite the waydb_query_nameddoes, so a mismatch silently mislabels every field.forge_db.pool_insert(pool, sql, params)— Runs a parameterized write and returns the new row's id.forge_db.pool_exec(pool, sql)(used above only forCREATE TABLE) takes exactly two arguments — no params list — because it exists purely for non-parameterized DDL.let body: NewUser = from_json(req.body)— typed JSON parsing, sobody.name/body.emailare real field access rather than untyped dict lookups on anany-typed result.if len(rows) == 0: return forge.resp_error(404, ...)— If no rows matched, return a 404Response. Thereturnexits the handler early — theforge.resp_jsonline below does NOT execute.
DON'T: Pass a params list to pool_exec — its signature is pool_exec(pool, sql), exactly two arguments; a third argument is an arity error, not a silently-ignored one. DO: Reach for pool_query (list rows), pool_query_dicts (dict rows, needs cols), or pool_insert (returns the new id) for every parameterized statement — pool_exec is DDL-only.
JOIN queries — combining tables
// Get all posts with their author's name rows = forge_db.pool_query_dicts(pool, "SELECT posts.id, posts.title, users.name AS author FROM posts JOIN users ON posts.user_id = users.id ORDER BY posts.id DESC LIMIT 20", [], ["id", "title", "author"]) for row in rows print("{row["author"]}: {row["title"]}")
Column aliases (AS author) determine what name to list in cols — pool_query_dicts keys each row positionally against the list you pass, not by re-deriving names from SQLite, so cols must name the SELECT list left-to-right exactly, aliases included.
Connecting: one API, three databases
Every driver — SQLite, PostgreSQL, MySQL — is reached through exactly two entry points, orm_open and orm_open_with_pool. Both take a single connection URL and dispatch on its scheme; nothing else in the calling code changes when you swap "sqlite://" for "postgres://". The three accepted forms:
let local = unwrap(forge_orm.orm_open("sqlite://app.db")) let pg = unwrap(forge_orm.orm_open("postgres://app:secret@db.internal:5432/prod")) // "postgresql://" is accepted too -- both spellings feed the same DSN parser let my = unwrap(forge_orm.orm_open("mysql://app:secret@db.internal:3306/prod"))
The user:password credentials, host, port, and database name for the two network drivers are parsed by one shared routine rather than two copies (it used to be two byte-identical 25-line blocks, one per driver). Splitting is always on the first occurrence of the separator, so a password containing ":" still splits correctly at the boundary with the host, and a ?query suffix on the database name is silently dropped. Postgres and MySQL disagree on defaults in a way worth knowing: a postgres:// URL with no /dbname falls back to database "postgres", but a mysql:// URL with no /dbname falls back to an empty string — MySQL has no equivalent default-database convention, so leaving it off is a connection-time error waiting to happen, not a harmless default.
Why the driver is an enum, not a string
Before the current design, an open connection was a raw list — ["orm", kind, pool, pool_size] — read back positionally as db[1], db[2], db[3] at 19 call sites, with the driver selected by comparing a bare kind string through an if-chain that had no final else. That shape produced two real bugs. First, the MySQL branch built a three-element list because MySQL had no pool at the time — every db[3] read against a MySQL handle was an out-of-bounds access sitting live in production code. Second, a kind string that matched none of the branches fell straight through the bottom of the if-chain and returned a wrong default: for orm_all that default was [] — "no rows" — which is indistinguishable from a query that legitimately matched nothing.
The fix models the driver as an enum whose variants each carry their own pool and size, so a driver value cannot exist without them:
enum OrmDrv DSqlite(dsq_pool: any, dsq_size: int) DPostgres(dpg_pool: any, dpg_size: int) DMysql(dmy_pool: any, dmy_size: int)
Every dispatch site now matches over OrmDrv, and that match is exhaustiveness-checked by the compiler: adding a fourth database without handling it everywhere is a compile-time E1009 ("non-exhaustive match on enum 'OrmDrv': missing ...") instead of a silent wrong default at runtime. Field names are prefixed (dsq_, dpg_, dmy_) because NOVA looks fields up by name, not by (type, name) pair — an unprefixed pool field on three different variants would collide.
Around that enum sits a small struct for the knobs every driver needs regardless of which one it is:
type OrmDb odb_drv: OrmDrv odb_timeout_ms: int
odb_timeout_ms is kept outside the enum on purpose — it's driver-independent (every driver wants a pool-acquire budget), so putting it on the struct instead of duplicating it into every variant means adding a new per-handle setting later touches one place, not three. Both orm_open and orm_open_with_pool seed it to a fixed 5000 milliseconds; neither function exposes a parameter for it. That timeout is what lets a saturated connection pool fail fast and catchably with an err() instead of parking the calling task forever waiting on a connection that will never free up.
Opening and closing
orm_open fixes the pool size at 4. orm_open_with_pool is the same connection logic with an explicit size in its place, for when four connections isn't enough ceiling for real traffic — a busy API server fanning out concurrent requests against Postgres or MySQL will exhaust a 4-connection pool quickly, where each caller starts queueing on pool_acquire_to until the 5-second timeout above turns the wait into an error.
let db = unwrap(forge_orm.orm_open("sqlite://app.db")) // ... use db for orm_all / orm_one / orm_insert / orm_exec ... forge_orm.orm_close(db) let busy = unwrap(forge_orm.orm_open_with_pool("postgres://app:secret@db.internal:5432/prod", 20)) forge_orm.orm_close(busy)
orm_close drains and closes every pooled connection by reading the pool size back out of the same OrmDrv variant the open call built — the count it drains can never disagree with the count that was actually opened, because there is no second place that number is tracked.
DO: call orm_open_with_pool whenever more than a handful of tasks will hold connections concurrently — the default of 4 is sized for a script or a test, not a production API server. DON'T assume a mysql:// URL behaves like a postgres:// one when you omit the database name — Postgres quietly falls back to "postgres", MySQL falls back to an empty database name and will fail to connect.
Introspection escape hatches
The typed handle is deliberately opaque in normal use — you pass OrmDb straight to orm_all/orm_one/orm_exec without ever touching its fields. When you do need to look inside (logging which dialect a handle is bound to, or checking how large its pool is), three low-level accessors exist for exactly that, each implemented as a one-line exhaustive match over OrmDrv so they can never disagree with the enum itself:
let db = unwrap(forge_orm.orm_open_with_pool("postgres://app:secret@db.internal:5432/prod", 20)) print("driver={forge_orm.orm_kind(db)} pool_size={forge_orm.orm_pool_size(db)}")
orm_pool returns the raw pool handle itself (an any, since sqlite/postgres/mysql pools are three unrelated underlying types) for the rare case you need to hand it to a driver-specific function directly. orm_kind is deliberately derived from the enum on every call rather than stored as a separate field — a stored copy could drift from the variant it was supposed to describe; deriving it means that can never happen.
Zero-annotation persistence: the struct IS the schema
No @Entity, no @Table, no @Column, no XML mapping file, no base class to extend, no code-generation step to run before the compiler even sees your model. A NOVA type declaration already carries its own field names and types as compiler-generated reflection — type_name, field_names, field_get, the same zero-annotation reflection every struct gets for free (see §6). forge_orm reads that reflection directly, entirely at the library level: orm_ensure derives a CREATE TABLE from a sample struct's fields, and orm_save/orm_put/orm_modify/orm_remove all derive the table name from type_name(row) and the column list from field_names(row) at the moment you call them. There is nothing to keep in sync when you add a field — the struct is the schema, not a description of one.
import forge_orm type Todo id: int title: string done: bool let db = unwrap(forge_orm.orm_open("sqlite://app.db")) forge_orm.orm_ensure(db, Todo(0, "", false)) // CREATE TABLE IF NOT EXISTS "Todo" (...) -- columns + types from the struct's own fields forge_orm.orm_save(db, Todo(0, "ship it", false)) // INSERT -- id 0 means "unset, let the database assign it" forge_orm.orm_save(db, Todo(0, "write docs", false)) // a second, unrelated row -- also auto-id'd
orm_ensure(db, sample) is literally orm_create_table(db, type_name(sample), sample) — the sample argument (Todo(0, "", false) above) exists only to be reflected on; its field values don't matter, only its field names and declared types do. The table name comes from the struct's own type name, capitalization and all, and is quoted per-dialect (ANSI double quotes on SQLite/PostgreSQL, backticks on MySQL) so a reserved word or a CamelCase name like Todo is still legal SQL on every driver without you thinking about it once.
orm_ensure also doesn't emit a generic id INTEGER PRIMARY KEY for every dialect — it deliberately spells the auto-generating key column differently per driver, because INTEGER PRIMARY KEY is a rowid alias — and therefore auto-increments — only on SQLite; the identical text on PostgreSQL and MySQL is a plain key with no generator attached. Get this wrong and the whole id == 0 convention breaks the moment you leave SQLite: PostgreSQL rejects the id-omitted INSERT with 23502 null value in column "id" violates not-null constraint, and MySQL rejects it with HY000 1364 Field 'id' doesn't have a default value. So orm_ensure instead emits BIGSERIAL PRIMARY KEY on PostgreSQL and BIGINT AUTO_INCREMENT PRIMARY KEY on MySQL — meaning the id column has somewhere to land when orm_save omits it. That omission is the other half of the convention: orm_save (via orm_insert) keeps every field of your struct in the INSERT column list except id, and it keeps id too only when its value isn't 0. NOVA has no separate "unset" state for an int — the zero value doubles as both a genuine 0 and "nothing was set" — and this is exactly where that convention is put to work: construct a Todo with id: 0, and the database's own generator assigns the real key.
This is also where orm_save and orm_put diverge, and it is a real gotcha worth naming: orm_save is INSERT-ONLY, always has been, and stays that way even when the row already exists — call it twice with the same id and the second call raises a duplicate-key error rather than updating anything.
DO: reach for orm_put when you want JPA/Hibernate save() semantics — insert if the entity is new, update if it already carries a key, safe to call repeatedly with the same struct. DON'T: assume orm_save behaves the same way. orm_save never checks whether the row exists; a second call against the same id fails outright (UNIQUE constraint failed: Todo.id on SQLite) instead of updating in place.
forge_orm.orm_save(db, Todo(10, "ship it", false)) // id=10 unused so far -- INSERT succeeds match forge_orm.orm_save(db, Todo(10, "ship it", true)) // same id, called again Ok(n) => print("inserted {n} row(s)") Err(e) => print("save failed: {e}") // UNIQUE constraint failed: Todo.id forge_orm.orm_put(db, Todo(20, "write docs", false)) // id=20 is new -> INSERT forge_orm.orm_put(db, Todo(20, "write docs", true)) // id=20 now exists -> UPDATE, never an error
Under the hood, orm_put(db, row) is orm_put_into(db, type_name(row), row), and that function's whole decision is a three-way branch on the struct's own shape: no id field at all means there's nothing to address an update by, so it inserts; an id of 0 means "new," so it inserts; anything else runs an upsert — one atomic INSERT ... ON CONFLICT (id) DO UPDATE (PostgreSQL/SQLite) or ON DUPLICATE KEY UPDATE (MySQL) statement, in a single round trip rather than a read-then-decide.
orm_modify(db, row) and orm_remove(db, row) are the explicit UPDATE-by-id and DELETE-by-id operations — use these instead of orm_put when you specifically know the row already exists and want an UPDATE (not an insert-or-update) or a DELETE. Both require an id field to address the row by, and both refuse cleanly rather than building a broken WHERE clause when a struct doesn't have one:
forge_orm.orm_modify(db, Todo(10, "ship it -- shipped", true)) // UPDATE "Todo" SET ... WHERE id = 10 forge_orm.orm_remove(db, Todo(20, "", false)) // DELETE FROM "Todo" WHERE id = 20 -- only .id is read type Widget name: string qty: int match forge_orm.orm_remove(db, Widget("bolt", 5)) Ok(n) => print("removed {n}") Err(e) => print("{e}") // orm_remove: struct 'Widget' has no 'id' field
Two more of this family are worth knowing but only briefly: orm_put_into(db, table, row) is the explicit-table form orm_put itself delegates to — reach for it when the table you want isn't type_name(row) (an archive table, a per-tenant table, a struct reused across two tables). And orm_fetch_all(db, table) is the read-side mirror of the same idea: it builds a dialect-quoted SELECT * FROM <table> and, called as the right-hand side of a typed list-of-struct let, gives you back real structs with zero SQL of your own:
forge_orm.orm_put_into(db, "todos_archive", Todo(1, "old task", true)) // explicit table -- not type_name(row) let open_todos: list<Todo> = forge_orm.orm_fetch_all(db, "Todo") // zero-SQL typed read, runtime-chosen table
The last piece of "the struct is the schema" is what happens when a column genuinely needs to be absent rather than empty. NOVA has no nullable value — null and 0 are indistinguishable — so there's no NOVA value you can put in a list of bound parameters that naturally means "SQL NULL." orm_null() fills that gap with a sentinel: it returns a short string built from the ASCII SOH control character (chr(1) + "NOVA_ORM_NULL" + chr(1)) that no real text a user could type would ever contain, so it's safe to use as an unambiguous marker. Before your statement reaches any driver, forge_orm scans the SQL for every ? placeholder bound to that exact sentinel and rewrites it into the literal keyword NULL — dropping it from the bound parameter list entirely, so the driver never actually sees the sentinel string, only a real SQL NULL. Because NULL is a keyword substituted in by the library itself rather than a value spliced from outside, this stays injection-safe, and it works identically across SQLite, PostgreSQL and MySQL since the rewrite happens before dialect dispatch — no per-driver binding code required.
forge_orm.orm_exec(db, "UPDATE Todo SET title = ? WHERE id = ?", [forge_orm.orm_null(), 10]) // title is now a real SQL NULL -- not the string "NOVA_ORM_NULL", and not the string "" forge_orm.orm_count(db, "Todo", "title IS NULL", []) // 1 -- the row just nulled forge_orm.orm_count(db, "Todo", "title = ?", [forge_orm.orm_null()]) // 0, ALWAYS -- SQL three-valued logic
Note the second orm_count call: testing for NULL still requires IS NULL in your SQL. A bound col = ? follows ordinary SQL three-valued logic and is never true against a NULL, no matter what your program passed for that parameter — that isn't a NOVA quirk, it's the same rule = follows everywhere else in SQL, and orm_null() doesn't change it.
Compare this to the field's established players, and the gap is structural, not cosmetic. Hibernate needs @Entity, @Table(name=...), @Column, and @Id/@GeneratedValue on every field (or an equivalent hbm.xml mapping) before a POJO becomes persistable at all — the class and its persistence metadata are two artifacts kept in sync by hand. Rails ActiveRecord goes the opposite way and separates them completely: the model is a bare subclass of ApplicationRecord with no field declarations whatsoever, while the actual columns live in a migration-generated schema.rb that Rails introspects at boot — so the Ruby class and the real schema are two different files, synchronized only by remembering to run rails generate migration and rails db:migrate every time a field changes. Prisma requires writing the model a third time, in its own schema.prisma DSL, and then running prisma generate as a build step before the generated client types even exist. forge_orm has none of these seams: a NOVA type declaration already carries its field names and types as compiler-generated reflection, and orm_ensure/orm_save/orm_put read that reflection directly at call time — there is no annotation to add, no second file to maintain, and no generator to run between defining the struct and persisting it.
Typed reads, scalar queries, and the sound Result API
Every read in forge_orm ultimately goes through one of two families. The first — orm_all and orm_one — are the names the compiler itself recognizes to drive zero-annotation typed mapping; their return type is fixed by that mechanism, not by choice, so they cannot report failure as a Result. The second — orm_rows, orm_row, orm_exec, orm_each — are ordinary library functions with no compiler involvement, so they return a real Result and are the ones to reach for whenever the caller needs to know precisely what happened, not just get an empty answer back.
orm_all and orm_one — the compiler-pinned typed reads
Zero-annotation typed reads run through orm_all(db, sql, params) and orm_one(db, sql, params):
import forge_orm type User id: int name: string email: string let db = unwrap(forge_orm.orm_open("sqlite://app.db")) // list<User> on the left is what makes this typed: the compiler recognizes the NAME "orm_all" // on the right and rewrites the whole let, at compile time, into // User__from_dict_list(forge_orm.orm_all(db, sql, params)) let users: list<User> = forge_orm.orm_all(db, "SELECT * FROM users WHERE name LIKE ?", ["A%"]) // Without a typed-let target, orm_one returns the bare name-keyed dict it always returns -- // {} when nothing matched, a real row otherwise. No struct, no Result, just a dict. let raw = forge_orm.orm_one(db, "SELECT * FROM users WHERE id = ?", [7]) if len(raw) == 0 print("no user 7")
Why these two specifically cannot return a Result: the generated User__from_dict_list that the rewrite above splices in takes exactly one parameter, typed rows: list — a bare list, because it is a plain typed loop that runs the generated, Result-returning User__from_dict over each row dict and collects the Ok payloads. Feeding it a Result instead of a list does not type-check. So orm_all's signature is dictated by the shape the compiler's own rewrite needs on the outside of it — and that rewrite fires on the literal string "orm_all" (alongside orm_one, orm_get, orm_where, orm_fetch) inside the compiler's typed-let handling. Changing what orm_all returns means changing the compiler, not this library — a real, load-bearing constraint, not an oversight left unfixed.
What the library-level fix did close is the silence. A dead connection or a rejected statement used to make orm_all return [] — identical to a query that legitimately matched nothing. Now every failure that orm_all swallows is printed before it degrades to []: [orm] orm_all FAILED: <driver message> | sql: <the statement>. Because orm_one is implemented as "take orm_all's first row, or {}," a failure inside orm_one is logged the same way, through the very same call.
DO: Reach for orm_all/orm_one for the common case — an internal query against a database you trust is up, where an empty result and a broken connection can be treated the same way (both mean "nothing to show"). DON'T: Use them where the caller must distinguish "genuinely empty" from "the database is down" — orm_all literally cannot return err(...) no matter what failed underneath it. Reach for orm_rows/orm_row below instead.
orm_rows and orm_row — the sound counterparts
orm_rows(db, sql, params) -> Result is the plain counterpart of orm_all: ok(list of name-keyed dicts) on success, err(message) on a real driver failure — nothing pinned, nothing swallowed. orm_row(db, sql, params) -> Result is the counterpart of orm_one, and it closes a real bug class rather than just a stylistic one: orm_one cannot report absence at all. It returns {} for "no match," the compiler wraps that as User__from_dict({}), and — because the generated mapper fills every missing field with its type's zero value and still returns ok(...) — the result is a zero-valued struct wrapped in Ok, not an Err. A caller writing let r: Result<User> = orm_one(...) and checking is_err(r) concludes the row exists when it does not. That mistake previously shipped a 200-with-an-empty-object where a 404 belonged. orm_row fixes it by returning err("no rows") explicitly when the row count is zero, distinct from any driver error:
match forge_orm.orm_row(db, "SELECT * FROM users WHERE id = ?", [7]) Ok(row) => print("found: {row["name"]}") Err(e) => print("lookup failed: {e}") // "no rows", or the driver's own message
orm_rows and orm_row both route through the same three-driver dispatch as orm_all/orm_one underneath (SQLite via the pool, PostgreSQL, MySQL), so a caller pays nothing extra for soundness beyond the one extra match arm.
orm_exec — writes and DDL
orm_exec(db, sql, params) -> Result runs an INSERT, UPDATE, DELETE, or DDL statement. Because orm_exec is not one of the names the compiler's typed-let rewrite recognizes, nothing pins its signature, and it reports failure the way every write should: ok(affected_rows) or err(message) — a constraint violation, a busy database, or a syntax error all surface as a real Err instead of a return value that looks identical to success.
match forge_orm.orm_exec(db, "INSERT INTO users (name, email) VALUES (?, ?)", ["Alice", "alice@example.com"]) Ok(n) => print("inserted, {n} row(s) affected") Err(e) => print("insert failed: {e}")
orm_each — bounded-memory streaming reads
Every read covered so far materializes the entire result set as one list of dicts before the caller sees any of it — fine for a page of results, unbounded for SELECT * FROM big_table, where the row count is the server's to decide, not the caller's. orm_each(db, sql, params, chunk, row_fn) -> Result walks the result in bounded chunks and hands each row to row_fn one at a time, so peak memory is one chunk, never the whole table. row_fn returns nothing; it runs once per row purely for its side effect. orm_each itself returns ok(total_rows_seen), or an Err the moment any chunk fails.
match forge_orm.orm_each(db, "SELECT * FROM events ORDER BY id", [], 500, fn(row) print("event {row["id"]}: {row["kind"]}") ) Ok(n) => print("streamed {n} row(s) total") Err(e) => print("orm_each failed: {e}")
How each driver actually streams is not uniform, and the difference matters: on PostgreSQL, orm_each opens a real server-side cursor — BEGIN, DECLARE ... CURSOR FOR <your query>, then repeated FETCH <chunk> FROM the cursor, CLOSE, COMMIT — which is the correct primitive: the server streams and never builds the full set either. SQLite and MySQL have no equivalent for an ad-hoc statement (MySQL cursors exist only inside stored programs), so both fall back to wrapping your SQL as a subquery and paging it with LIMIT <chunk> OFFSET <n>.
DO: Add an ORDER BY on a stable, unique key to any query you drive through orm_each on SQLite or MySQL. DON'T: Assume the LIMIT/OFFSET fallback behaves like the PostgreSQL cursor path — it does not, in two concrete ways. First, OFFSET is O(offset) on every driver: the server walks and discards every skipped row to reach the next page, so deep pagination gets slower as it goes, a cost the cursor path never pays. Second, a LIMIT/OFFSET walk is not a transactional snapshot — a concurrent INSERT or DELETE between chunks can shift rows across page boundaries, so a row can be seen twice or missed entirely. The PostgreSQL cursor holds one transaction open for the whole walk and is genuinely consistent; the fallback is not.
orm_get and orm_where — SQL-free typed repository reads
orm_get(db, table, id) finds one row by primary key; orm_where(db, table, cond, params) finds every row matching a condition — both without writing a SELECT. Underneath, orm_get is orm_one against "SELECT * FROM <table> WHERE id = ?", and orm_where is orm_all against "SELECT * FROM <table> WHERE <cond>", so both are recognized by the same compiler rewrite and both inherit exactly the constraints described above:
type Post id: int user_id: int title: string // has-many: every Post whose foreign key points at this user -- ONE typed call, no SQL ceremony let posts: list<Post> = forge_orm.orm_where(db, "posts", "user_id = ?", [7]) // belongs-to: the User a Post's foreign key points at let author: Result<User> = forge_orm.orm_get(db, "users", posts[0].user_id)
There is no separate relation API — a has-many is orm_where on the child table's foreign-key column, a belongs-to is orm_get on the parent table by the foreign-key value. Nothing about "relations" exists as a distinct concept; it's the same two functions used with a different table and column.
DON'T: Trust is_err() on orm_get's Result<T> to mean "no such row." Because orm_get is orm_one underneath, an id that matches nothing produces the exact same zero-valued-struct-wrapped-in-Ok described above for orm_one — author above is Ok(User{id: 0, name: "", email: ""}), never Err, when the id doesn't exist. DO: Reach for orm_row(db, "SELECT * FROM users WHERE id = ?", [id]) instead whenever "does this row exist" is a real question the caller must answer — it returns err("no rows") precisely when nothing matched.
Scalar aggregates: orm_count, orm_exists, orm_agg — and their Result-returning counterparts
Three helpers answer a single scalar question without a hand-written SELECT: orm_count(db, table, where, params) -> int counts matching rows (pass "" for where to count the whole table); orm_exists(db, table, where, params) -> bool answers "does any row match" using SELECT 1 ... LIMIT 1 rather than orm_count's SELECT count(*), so the planner can stop at the first match instead of scanning every matching row just to prove one exists; orm_agg(db, fn_name, col, table, where, params) -> string runs MIN/MAX/SUM/AVG/COUNT over one column. fn_name is restricted to exactly count, sum, avg, min, max, and any other value is refused (orm_agg returns "") rather than executed — the aggregate function's name sits in a position of the SQL where it is a keyword, not a value, so it cannot be bound as a ? parameter the way every other piece of this library is; without the whitelist, an unvalidated fn_name would be the single most directly injectable string in the whole ORM.
All three share the same failure mode as orm_all: on a genuine driver failure they return 0 / false / "" — indistinguishable from a legitimately empty table, no matching row, or a null aggregate. orm_try_count, orm_try_exists, and orm_try_agg take the identical arguments and return a Result instead, for the call sites where a dead connection must not be misread as "zero":
let active = forge_orm.orm_count(db, "users", "active = ?", [1]) // 0 on a real outage too let has_admin = forge_orm.orm_exists(db, "users", "role = ?", ["admin"]) // false on a real outage too match forge_orm.orm_try_count(db, "users", "active = ?", [1]) Ok(n) => print("{n} active user(s)") Err(e) => print("count failed: {e}") // now distinguishable from a real 0
The plain versions stay because most call sites use the result directly as a number or a boolean (a dashboard counter, an if orm_exists(...) guard) where threading a match through every check would be pure ceremony; reach for the orm_try_* form only at the boundary where a driver failure genuinely must be reported rather than silently read as zero.
The fluent query builder — OrmQuery
Everything so far has meant either writing a SQL string by hand or reaching for a fixed-shape helper like orm_where. OrmQuery is the middle ground — NOVA's answer to JPA's Criteria API — and it is the flagship piece of this library: build a SELECT up fluently, one predicate at a time, then run it either typed (through orm_all) or untyped (through .run). Every method returns a brand-new OrmQuery rather than mutating the one you called it on, so a spec is a value you can branch off of freely — assigning the result of .eq(...) to a new variable never changes the original.
Starting a spec: q_from, orm_from, orm_spec
Three ways to start, differing only in identifier quoting. q_from(table) passes the table name through verbatim — use it for hand-written column expressions where you don't want the library quoting anything. orm_from(db, table) quotes the table name for db's own dialect, and every column name any typed predicate below touches gets quoted the same way, so reserved words and CamelCase names stay legal SQL without you thinking about it. orm_spec(db, row) is orm_from(db, type_name(row)) — a dialect-aware spec for a struct's own table, with no table-name string to get wrong.
import forge_orm type User id: int name: string age: int let db = unwrap(forge_orm.orm_open("sqlite://app.db")) let raw = forge_orm.q_from("users") // table name passed through verbatim let dial = forge_orm.orm_from(db, "users") // "users" quoted for db's dialect let spec = forge_orm.orm_spec(db, User(0, "", 0)) // table name derived from type_name(User)
Choosing columns: .columns, .columns_of, .distinct
.columns(cols) takes a raw SQL fragment as a string — "id, name", not a list — and replaces the default "*". .columns_of(sample) projects onto a struct's own fields instead, quoted one at a time for the spec's dialect — the typed-DTO case, so a query that only needs three of a table's twenty columns doesn't fetch the other seventeen just to throw them away. .distinct() takes no arguments and prefixes the emitted SELECT with DISTINCT.
type NameOnly name: string let q1 = forge_orm.orm_from(db, "users").columns("id, name") let q2 = forge_orm.orm_from(db, "users").columns_of(NameOnly("")) let q3 = forge_orm.orm_from(db, "users").distinct().columns("age") // SELECT DISTINCT age FROM ...
The raw predicate, and every typed predicate that funnels through it
Every predicate below — .eq, .like, .in_, all of them — is a thin wrapper over one method, .where(cond, params), which appends a raw condition and its bound parameters. That means there is exactly one place a condition gets appended and one place parameters get kept in lock-step with it, so it's worth seeing directly before the typed forms that build on it:
let q = forge_orm.orm_from(db, "users").where("age > ? AND age < ?", [18, 65])
Multiple calls to .where (or any typed predicate) combine with AND automatically — each call appends " AND " + cond to whatever conditions already exist. The typed predicates below exist so you rarely need to write a raw condition string at all:
let q = forge_orm.orm_from(db, "users") .gt("age", 18) // age > ? .ne("status", "banned") // AND status <> ? .between("age", 18, 65) // AND age BETWEEN ? AND ? .not_null("email") // AND email IS NOT NULL
The full set: .eq, .ne, .gt, .ge, .lt, .le (the six comparison operators), .between(col, lo, hi), and .is_null(col) / .not_null(col) (no bound parameter — IS NULL is a keyword, not a comparison against a value).
Pattern matching: .like, .starts, .ends, .contains, .icontains
These five look similar but split into two groups with different safety guarantees. .like(col, pattern) passes the pattern through verbatim — use it when you actually want %/_ wildcards. .starts, .ends, .contains, and .icontains promise literal substring matching, which means they must escape % and _ before splicing your value into the pattern — binding a value as a parameter protects against SQL injection, but it does nothing to stop %/_ from acting as wildcards inside a LIKE pattern even when that pattern arrives as a bound parameter. This was measured, not assumed: .contains("%") without escaping matched every row in the table, and .starts("user_") matched a value like "userXadmin" because the unescaped _ matches any single character. Since these are exactly the methods a search box or a filter field feeds, an untrusted % typed into a search bar would otherwise silently return the whole table.
let raw_pattern = forge_orm.orm_from(db, "users").like("email", "%@example.com") // wildcards intact, on purpose let search = "100% off" // user-typed search text, containing a literal % character let literal = forge_orm.orm_from(db, "products").contains("name", search) // matches products whose name contains the literal text "100% off" -- the % does NOT act as a wildcard
.icontains(col, needle) is the case-insensitive form of .contains: it wraps the column in LOWER(...) and lower-cases needle on this side before comparing, because real ILIKE does not exist on MySQL (it errors there) — LOWER(col) LIKE ? is the one spelling all three dialects accept.
Set membership: .in_ and .not_in
.in_(col, vals) and .not_in(col, vals) bind an entire list as one condition. The empty-list case is handled deliberately rather than left to chance: emitting IN () is a syntax error on every dialect, and simply dropping the predicate when vals is empty would silently widen the query to match everything — the worse of the two failures. So .in_(col, []) degrades to the condition 1=0 (matches nothing, which is the correct answer to "is this value one of zero possibilities"), and .not_in(col, []) degrades to its dual, 1=1 (an empty exclusion set excludes nothing).
let ids: list = [] let none = forge_orm.orm_from(db, "users").in_("id", ids) // WHERE 1=0 -- matches nothing, not everything let some = forge_orm.orm_from(db, "users").in_("id", [1, 2, 3]) // WHERE id IN (?, ?, ?)
Joins, ordering, and grouping
.inner_join(other, on) and .left_join(other, on) each append one join clause; call either repeatedly to chain multiple joins. .order_by(o) sets the ORDER BY clause outright (a raw string, replacing whatever was there); .asc(col) and .desc(col) instead append one quoted term each, so .asc("a").desc("b") composes into ORDER BY a ASC, b DESC rather than one overwriting the other.
let q = forge_orm.orm_from(db, "posts") .inner_join("users", "users.id = posts.user_id") .eq("users.active", 1) .desc("posts.id")
.group_by(cols) takes a list of column names (not a single string), each quoted for the spec's dialect. .having(cond, ps) attaches a HAVING condition with its own bound parameters — and those parameters are tracked specially: .sql() always emits WHERE before HAVING, so HAVING's parameters have to occupy the tail of the spec's parameter list, counted separately. If a later .where(...) call simply appended its new parameter after everything else, it would land after the HAVING parameters that already come last — the SQL still has the WHERE placeholder appearing first, so at execution time WHERE would bind the value meant for HAVING and HAVING would bind the value meant for WHERE. Both statements would run without any error and silently return the wrong rows. .where(...) avoids this by always splicing a new parameter in before the counted HAVING tail, regardless of call order:
let q = forge_orm.orm_from(db, "orders") .group_by(["customer_id"]) .having("count(*) > ?", [5]) .gt("total", 100) // called AFTER .having -- still binds correctly, in WHERE not HAVING
DO: call .eq/.gt/etc. and .having in whatever order reads best — the parameter ordering is handled for you regardless. DON'T: pass a single string to .group_by — it takes a list, and a bare string would be reflected on as if it were a list of individual characters.
Paging and running the query
.limit(n), .offset(n), and .paginate(page, per_page) are covered in full in the pagination section of this chapter — the short version here: .paginate is the one to reach for directly, it computes the right LIMIT/OFFSET from a 1-based page number.
Four methods turn a built spec into something you can hand to the rest of the ORM: .cond() returns just the WHERE condition text with no WHERE keyword (for the bulk-write helpers like orm_delete_where, which need the bare condition); .sql() returns the complete, runnable SELECT statement; .count_sql() returns a SELECT count(*) over the same filters, correctly wrapped in a subquery whenever DISTINCT/GROUP BY/HAVING would otherwise make a naive count wrong (see the pagination section for exactly how). .run(db) and .try_run(db) execute the query for you — .run(db) calls orm_all underneath (so a typed let xs: list<T> = q.run(db) works exactly the way orm_all does, including that it cannot report failure), while .try_run(db) calls the sound orm_rows instead, returning a real Result.
let q = forge_orm.orm_from(db, "users").gt("age", 18).desc("name") // typed, via orm_all under the hood -- cannot report a driver failure let users: list<User> = forge_orm.orm_all(db, q.sql(), q.params) // same thing spelled as a method call let rows = q.run(db) // sound: a real Result instead match q.try_run(db) Ok(rows) => print("{len(rows)} row(s)") Err(e) => print("query failed: {e}")
One design point is worth naming directly: an OrmQuery is a value, not a SQL string and not a method name. That is a deliberate departure from both ends of how other ORMs express queries — a JPQL/HQL string (Hibernate) is opaque text the ORM can only validate at runtime when it's finally executed, and a derived method name like Spring Data's findByFirstNameAndLastNameOrderByAgeDesc is opaque to any tooling that isn't Spring Data's own name parser. An OrmQuery spec, by contrast, is inspectable data from the moment it's constructed — which is what makes compile-time analysis of it (static N+1 detection, static round-trip counting, compile-time column checking) possible at all, not just a runtime nicety.
Migrations: schema generated from a struct
orm_ensure(db, sample) (covered earlier) is the zero-ceremony entry point — it derives the table name from the struct's type and delegates straight to orm_create_table. Reach for orm_create_table directly when the table name needs to be something other than the type name, and use orm_drop_table/orm_ensure_index/orm_ensure_unique to round out the migration surface: create, drop, index, and unique-constrain, all driven from the struct's own field reflection rather than a separate migration DSL.
import forge_orm type Product id: int name: string price: float active: bool let db = unwrap(forge_orm.orm_open("sqlite://shop.db")) // Table name is a runtime string here -- it does not have to match the struct's type name. _ = forge_orm.orm_create_table(db, "products", Product(0, "", 0.0, false)) // Idempotent: emits CREATE TABLE IF NOT EXISTS, so calling this again at the next startup is a no-op. _ = forge_orm.orm_create_table(db, "products", Product(0, "", 0.0, false)) // Unconditional -- DROP TABLE IF EXISTS. The IF EXISTS only guards "table doesn't exist", // it does not ask before destroying the rows in a table that DOES exist. _ = forge_orm.orm_drop_table(db, "products")
Both calls run through the same identifier-quoting path as every other struct-driven builder in the library (_orm_qident internally), so a table or field name that happens to collide with a SQL keyword is still safe to use — the raw field name only matters to the type mapping below, the emitted DDL always quotes it per-dialect.
The sample argument is never inserted or read — its only job is to hand orm_create_table the field names and field types via reflection, in declaration order. Constructing one at the call site (Product(0, "", 0.0, false)) is the idiomatic way to do that: it costs nothing at runtime and the compiler already knows the shape.
The NOVA type → SQL column type mapping is a small table, not an if-ladder, and every NOVA type outside it silently falls back to TEXT:
| NOVA field type | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
int (non-id field) | INTEGER | INTEGER | INTEGER |
bool | INTEGER | INTEGER | INTEGER |
string (non-id field) | TEXT | TEXT | TEXT |
float | REAL | DOUBLE PRECISION | REAL |
int id field | INTEGER PRIMARY KEY | BIGSERIAL PRIMARY KEY | BIGINT AUTO_INCREMENT PRIMARY KEY |
string id field | TEXT PRIMARY KEY | TEXT PRIMARY KEY | VARCHAR(255) PRIMARY KEY |
bool mapping to INTEGER rather than a native boolean column is deliberate portability, not an oversight: it is the one column type all three dialects agree on without a driver-specific spelling. float is the only entry in the base table that is dialect-dependent on its own — PostgreSQL gets DOUBLE PRECISION, SQLite and MySQL both get REAL — everything else diverges only when the field is named id.
Auto-increment primary keys had to be dialect-specific
The text "INTEGER PRIMARY KEY" is not one behavior across three databases — it is two completely different things wearing the same spelling. On SQLite it is a rowid alias: the column literally becomes the table's internal rowid, and SQLite auto-assigns it whenever an insert omits it. On PostgreSQL and MySQL the identical text is a plain integer key with no generator attached — nothing will ever fill it in for you.
That distinction is not academic, because the entire zero-SQL hero flow depends on it: orm_insert deliberately omits the id column when a struct's id is 0 (NOVA's "unset" — see null IS 0), trusting the database to assign a real key. Before the per-dialect spelling existed, that flow issued exactly this DDL on every driver and broke on two of the three:
// postgres -> 23502 null value in column "id" violates not-null constraint // mysql -> HY000 1364 Field 'id' doesn't have a default value
i.e. the flagship "construct a struct with id: 0, save it, get a real id back" flow worked on exactly ONE of three drivers. The fix is the per-dialect spelling in the mapping table above: BIGSERIAL PRIMARY KEY attaches PostgreSQL's sequence-backed generator, BIGINT AUTO_INCREMENT PRIMARY KEY attaches MySQL's. Measured after the fix: two id-omitted inserts assign ids [1, 2] on SQLite, PostgreSQL, and MySQL alike — the same NOVA code, three real generators.
A non-integer id (a caller-supplied key, e.g. a UUID string) hits a smaller version of the same trap on MySQL alone: "TEXT PRIMARY KEY" is a hard error there — BLOB/TEXT column used in key specification without a key length — because MySQL cannot build a key index over an unbounded TEXT column. A struct with id: string could not create its table at all on MySQL until that case was special-cased to VARCHAR(255) PRIMARY KEY; SQLite and PostgreSQL both accept TEXT PRIMARY KEY as-is.
DO: Let orm_create_table/orm_ensure pick the primary-key spelling — it is already dialect-correct for whichever OrmDb you pass in. DON'T: Hand-write "id INTEGER PRIMARY KEY" in raw SQL and expect it to auto-increment on PostgreSQL or MySQL — it will create the table, then fail every id-omitted insert with 23502 or 1364 the moment real traffic hits it.
Indexes and unique constraints — orm_ensure_index / orm_ensure_unique
Both take the same shape, (db, table: string, cols: list), and differ only in whether the index enforces uniqueness:
// Composite index, generated name "ix_products_active" _ = forge_orm.orm_ensure_index(db, "products", ["active"]) // UNIQUE constraint on "name" -- generated name is STILL "ix_products_name", not "ux_..." _ = forge_orm.orm_ensure_unique(db, "products", ["name"])
The generated index name is always "ix_" + table + "_" + cols.join("_") regardless of which of the two functions you called — orm_ensure_unique does not get its own ux_ prefix, only a UNIQUE keyword in the DDL. Both functions are safe to call on every process startup, right alongside orm_ensure/orm_create_table: on SQLite and PostgreSQL the emitted statement is a literal CREATE [UNIQUE] INDEX IF NOT EXISTS, so a re-run is a genuine no-op.
MySQL has no such clause — there is no CREATE INDEX IF NOT EXISTS in its grammar at all. Re-running the plain CREATE INDEX there raises error 1061, Duplicate key name, on the second call. That ONE error is caught and swallowed to fake idempotency; every other error still propagates as a real failure. The match is deliberately against the error TEXT "Duplicate key name", not the bare code "1061" — the number alone also occurs inside any table or index name that happens to contain it (a table called sensor1061, say), and matching on the substring would silently swallow a genuine failure — permission denied, disk full — on such a table as if it were harmless idempotency.
DO: Call orm_ensure_index/orm_ensure_unique unconditionally at startup, the same way you call orm_ensure — all three are safe to re-run on every one of the three dialects. DON'T: Put a unique index on a non-id string column and expect it to work on MySQL — a plain string field maps to TEXT (see the mapping table above), and MySQL cannot index a TEXT column without an explicit key length. Widening every string column to VARCHAR(255) to sidestep this was considered and rejected: it would silently truncate any value over 255 bytes in non-strict mode (and hard-error in strict mode) — losing user data to make an index possible is the wrong trade, so the limitation is accepted and documented rather than papered over.
Transactions and bulk writes
Everything so far has been one statement at a time. Real workloads need two more things: a group of statements that must all succeed or all fail together, and a way to write thousands of rows without paying one network round trip per row. forge_orm covers both without asking you to touch a driver directly — the same OrmDb handle and the same orm_* calls work whether you're inside a transaction or not.
orm_with_tx — one connection, one transaction, ordinary orm_* calls inside it
A transaction cannot span more than one physical connection — that's what makes it atomic. orm_with_tx(db, body) acquires ONE connection from the pool, wraps it in its own single-connection pool, and hands that back to body as a completely normal OrmDb. Because it's a normal OrmDb, every ordinary orm_exec/orm_all/orm_insert/orm_save call you make inside body transparently runs on that same connection — you don't call a different API inside a transaction, you call the exact same one. body must return a Result: returning ok(...) commits, returning err(...) rolls back. Each driver's own transaction primitive is used underneath (START TRANSACTION on MySQL, BEGIN everywhere else), so a failed BEGIN or a failed COMMIT comes back as err() instead of silently reporting success.
import forge_orm let db = unwrap(forge_orm.orm_open("sqlite://app.db")) let moved = forge_orm.orm_with_tx(db, fn(tx) let debit = forge_orm.orm_exec(tx, "UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]) if is_err(debit) return debit let credit = forge_orm.orm_exec(tx, "UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]) if is_err(credit) return credit ok(true) ) // moved is ok(true) with BOTH updates committed, or err(...) with BOTH rolled back -- // tx is an ordinary OrmDb, so orm_exec runs on it exactly the way it runs on db
DO: keep everything inside a transaction body sequential; if you need concurrency, do it OUTSIDE the transaction and only wrap the parts that must be atomic. DON'T: spawn concurrent tasks that each call orm_* on tx inside one orm_with_tx body — the whole body shares ONE connection wrapped in a size-1 pool, so two concurrent operations would contend for that single slot instead of running in parallel.
orm_tx_batch — a list of operations as one all-or-nothing unit
orm_tx_batch(db, operations) is orm_with_tx shaped for a list instead of a closure: operations is a list of fn(tx) -> Result values, run in order on the SAME transaction. The moment one returns err(...), the whole batch stops right there and rolls back — including whatever the earlier operations in the list had already done on that connection. On success it returns ok(results), a list of each operation's unwrapped value, in the same order the operations ran.
let ops = [ fn(tx) forge_orm.orm_exec(tx, "UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]), fn(tx) forge_orm.orm_exec(tx, "UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]) ] let moved = forge_orm.orm_tx_batch(db, ops) // moved is ok([1, 1]) -- each element is the affected-row count that operation returned -- // or the first err() any operation produced, with both updates rolled back
orm_insert_many — chunked batch insert, with an automatic PostgreSQL COPY fast path
Bulk loading row-by-row pays one full network round trip per INSERT, and the round trip — not the insert itself — is what dominates the cost. orm_insert_many(db, table, rows) collapses a whole batch into INSERT INTO t (a, b) VALUES (?, ?), (?, ?), ..., turning N round trips into a handful.
It's chunked because the wire protocol has a hard ceiling: PostgreSQL's Bind message carries the bound-parameter count as a signed INT16, so a single statement can bind at most 65,535 values — go over and it's a protocol error, not a slow path. The chunk size is derived from the column count (roughly 60,000 divided by the number of columns per row) so the cap holds no matter how wide the struct is; a 3-column row chunks at 20,000 rows per statement, a 30-column row chunks at 2,000.
For a batch of 500 rows or more, orm_insert_many tries PostgreSQL's COPY protocol FIRST — measured 192x–219x faster than issuing the same rows as row-by-row INSERT, because COPY streams rows in with no per-row parse/plan and no parameter ceiling at all. If COPY fails for any reason — a permission, a trigger, an unsupported column type — or the driver isn't PostgreSQL at all (SQLite and MySQL have no COPY equivalent), it falls straight through to the chunked multi-row INSERT above; the batch never just fails because the fast path didn't apply. COPY is refused outright, before it even tries the server, if ANY value in the batch is orm_null() — COPY's text format has no way to write a value that isn't there, so there's no way for the driver to tell "the string NULL" from "an actual SQL NULL" inside it. That batch simply falls back to the INSERT path instead, where a bound orm_null() is rewritten into a literal SQL NULL keyword before the statement is sent.
type LogEntry id: int source: string message: string let rows = [LogEntry(0, "worker-3", "job {i} done") for i in 0..5000] let inserted = forge_orm.orm_insert_many(db, "logs", rows) // 5000 rows clears the 500-row COPY threshold, so on PostgreSQL this streams the whole // batch in with COPY instead of 5000 (or even a few chunked) INSERT statements. // inserted is ok(5000) either way -- the fast path is invisible from the caller's side
orm_upsert, orm_upsert_by, and orm_upsert_many — insert-or-update
orm_upsert(db, table, row) inserts a row, or updates it in place if a row with the same id already exists — one statement, one round trip, atomic. orm_upsert_by(db, table, row, key) is the same thing keyed on any other unique column instead of id (an email, a SKU) — useful for syncing external data where you know the natural key but not the internal id.
MySQL cannot do this safely for an arbitrary key. SQLite and PostgreSQL take ON CONFLICT (key) DO UPDATE, which names the exact unique constraint the conflict has to hit. MySQL's ON DUPLICATE KEY UPDATE has no conflict target at all — it fires on ANY unique-key collision, including the primary key, not just the one you asked about. So an email-keyed upsert whose row also happens to carry an existing (but different) id could silently overwrite THAT row instead of the one matching the email. Rather than honor a request it can't guarantee, orm_upsert_by refuses a non-id key on MySQL outright:
type User id: int email: string name: string let saved = forge_orm.orm_upsert(db, "users", User(1, "alice@example.com", "Alice V2")) // keyed on a unique column instead of id -- fine on sqlite/postgres let synced = forge_orm.orm_upsert_by(db, "users", User(0, "bob@example.com", "Bob"), "email") // on a MySQL-backed db, `synced` is instead: // err("orm_upsert_by: MySQL cannot target a specific conflict key ('email') -- ON DUPLICATE KEY // UPDATE fires on ANY unique collision, so this would silently update the wrong row. Use // orm_upsert (keyed on id), or do an explicit SELECT-then-update.") // batch form: one orm_upsert per row -- NOT wrapped in a transaction let n = forge_orm.orm_upsert_many(db, "users", [User(0, "a@x.com", "A"), User(0, "b@x.com", "B")])
DO: wrap the whole call in orm_with_tx yourself (orm_with_tx(db, fn(tx) orm_upsert_many(tx, table, rows))) when a batch genuinely needs to land as one unit. DON'T: assume orm_upsert_many is all-or-nothing the way orm_tx_batch is — it loops orm_upsert one row at a time, and if row 5 of 10 fails, rows 1–4 are already committed; the function simply stops and returns that err().
orm_insert_id and orm_save_id — the generated key, made correct on MySQL
orm_insert_id(db, table, row) inserts a struct and hands back the primary key the database generated for it; orm_save_id(db, row) is the same thing against the struct's own type-name table. On SQLite and PostgreSQL this is one round trip: INSERT ... RETURNING id. MySQL has no RETURNING, so the only way to get the key back is a second statement, SELECT LAST_INSERT_ID() — and LAST_INSERT_ID() is per-CONNECTION state, not per-session or per-query. Issue the INSERT and that SELECT as two ordinary pool calls and they can land on two DIFFERENT pooled connections, in which case the SELECT returns whatever id some OTHER connection last inserted — a measured failure where the probe got back 2 when the row it had just inserted actually had key 1001. orm_insert_id pins both statements to the SAME connection by running them inside orm_with_tx, which is what makes the MySQL answer correct.
type Order id: int customer: string total: int let created = forge_orm.orm_insert_id(db, "orders", Order(0, "Acme Co", 4200)) if is_ok(created) let new_id = unwrap(created) // same thing, against Order's own type-name table let saved_id = forge_orm.orm_save_id(db, Order(0, "Acme Co", 4200))
DO: use orm_insert_id/orm_save_id, which pin both statements to one connection for you. DON'T: hand-roll "orm_exec the INSERT, then orm_rows a SELECT LAST_INSERT_ID()" against a pooled MySQL OrmDb yourself — those are two separate pool acquisitions and can silently return someone else's id.
orm_update_where, orm_delete_where, orm_delete_all, and orm_delete_by_ids — bulk writes by spec
orm_update_where(db, table, sets, q) runs one UPDATE against however many rows the OrmQuery spec q matches; sets is a plain dict of column name to new value. orm_delete_where(db, table, q) is the delete equivalent — with one deliberate restriction: it REFUSES a spec with no condition at all, returning err("orm_delete_where: spec has no condition -- use orm_delete_all(db, table) to erase every row") instead of running the delete. A criteria object that ends up with no filters attached — because a caller forgot a .eq(...), or built it conditionally and every condition happened to be skipped — is exactly the shape of bug that would otherwise erase a whole table by accident. Erasing everything has to be asked for by its own name: orm_delete_all(db, table).
orm_delete_by_ids(db, table, ids) deletes a known set of ids in one or more DELETE ... WHERE id IN (...) statements, chunked under the same protocol parameter cap and the same 60,000 safety margin as orm_insert_many — the difference is that an id list binds exactly one value per row, so there's no column count to divide by: each chunk is simply up to 60,000 ids. A batch larger than that is split across multiple statements automatically, and the returned count is the sum across all of them.
let q = forge_orm.orm_from(db, "users").eq("active", 0) let deactivated = forge_orm.orm_update_where(db, "users", {"note": "bulk deactivate"}, q) let stale = forge_orm.orm_from(db, "sessions").lt("expires_at", "2026-08-01 00:00:00") let removed = forge_orm.orm_delete_where(db, "sessions", stale) // a spec with no condition at all is refused, not silently run: let refused = forge_orm.orm_delete_where(db, "sessions", forge_orm.q_from("sessions")) // refused is err("orm_delete_where: spec has no condition -- use orm_delete_all(db, table) to erase every row") // "erase every row" has to be its own explicitly-named call let cleared = forge_orm.orm_delete_all(db, "sessions") // delete a known set of ids -- chunked under the insert_many parameter cap let gone = forge_orm.orm_delete_by_ids(db, "users", [7, 12, 19])
| Function | What it does |
|---|---|
orm_with_tx(db, body) | Run body(tx) — an ordinary OrmDb pinned to one connection — inside one transaction; commit on ok(), rollback on err() |
orm_tx_batch(db, operations) | Run a list of fn(tx) -> Result operations as one transaction; stops and rolls back at the first err() |
orm_insert_many(db, table, rows) | Chunked multi-row INSERT; auto-switches to PostgreSQL COPY at 500+ rows (192x–219x faster), falls back on any COPY failure or on sqlite/mysql |
orm_upsert(db, table, row) | Insert, or update in place if the row's id already exists |
orm_upsert_by(db, table, row, key) | Same, keyed on any unique column — refuses a non-id key on MySQL |
orm_upsert_many(db, table, rows) | One orm_upsert per row; NOT wrapped in a transaction |
orm_insert_id(db, table, row) | Insert and return the generated id; pins INSERT + LAST_INSERT_ID() to one connection on MySQL |
orm_save_id(db, row) | Same, against the struct's own type-name table |
orm_update_where(db, table, sets, q) | UPDATE every row the spec q matches with the columns in sets |
orm_delete_where(db, table, q) | Delete every row the spec matches — refuses a spec with no condition |
orm_delete_all(db, table) | Delete every row in the table, deliberately, by name |
orm_delete_by_ids(db, table, ids) | Delete a known set of ids, chunked under the 60,000-parameter cap |
Pagination: offset, real totals, and keyset
OrmQuery has a .paginate(page, per_page) method that takes a 1-based page number and turns it into the LIMIT/OFFSET the driver actually needs: paginate(2, 20) means "page 2 at 20 per page," which is rows 21..40, so it builds LIMIT 20 OFFSET 20 underneath. A page number below 1 is clamped up to 1 rather than producing a negative OFFSET.
let q = forge_orm.q_from("users").where("age > ?", [18]).order_by("name").paginate(2, 20) let users: list<User> = forge_orm.orm_all(db, q.sql(), q.params)
That gets you the page of rows, but a real pagination UI needs more than the rows — it needs to know how many pages exist so it can render "Page 2 of 47" or disable the "Next" button on the last page. That is exactly the gap Spring Data's Page<T> fills on the JVM, and orm_page_meta is NOVA's answer to it: a real COUNT(*) over the same spec, packaged with the arithmetic already done.
let spec = forge_orm.orm_spec(db, User(0, "", 0)).gt("age", 18).desc("name") let pg = unwrap(forge_orm.orm_page_meta(db, spec, 2, 20)) let items: list<User> = forge_orm.orm_all(db, spec.paginate(2, 20).sql(), spec.paginate(2, 20).params) // pg.op_total, pg.op_page, pg.op_per, pg.op_pages, pg.op_has_next, pg.op_has_prev
orm_page_meta(db, q, page, per) takes the spec before you call .paginate on it and hands back a Result you unwrap(), wrapping an OrmPage with six fields: op_total (row count across every page, not just this one), op_page and op_per (the inputs echoed back, with page floored to 1 and per floored to 1 the same way .paginate clamps them), op_pages (total divided by per, rounded up so a partial last page still counts), and the two booleans a pager template actually renders off of: op_has_next (page < pages) and op_has_prev (page > 1).
Notice the items and the metadata are two separate calls, deliberately — orm_page_meta only ever runs a count(*), never the row-fetching query, because the two need different SQL shapes (one has LIMIT/OFFSET, the other must not). The count itself is built by the same .count_sql() covered in the query-builder section, so it inherits the same correctness fixes: a spec with GROUP BY counts the number of groups, not the number of underlying rows, by wrapping the grouped query in SELECT count(*) FROM (...) AS _sub rather than slapping count(*) in front of the caller's column list — and a spec with .having() forces that same subquery wrapping, because the flat SELECT count(*) ... WHERE ... path would bind the HAVING params against placeholders that were never emitted and silently drop the filter. A spec built with .distinct() keeps its real SELECT list inside the subquery instead of collapsing it, because the whole point of a DISTINCT count is counting distinct values, not distinct rows of some unrelated projection.
Why OFFSET doesn't scale, and what keyset pagination does instead
.paginate() is built on OFFSET, and OFFSET has a cost that is invisible until your table grows: every one of SQLite, PostgreSQL, and MySQL implements LIMIT n OFFSET m by having the server walk m + n rows and discard the first m — there is no index that lets a database seek directly to "row 10,000." That makes an OFFSET query O(offset), not O(1): page 500 at 20 rows per page costs as much work as reading 10,020 rows, even though it only returns 20 of them. A product list, an admin table, an infinite-scroll feed — anything where users (or bots) page deep enough — turns into a query that gets slower every page in, on every single driver.
orm_paginate_keyset(db, table, key_col, after, limit, asc) sidesteps this entirely by replacing OFFSET with a WHERE on the last key you saw. Instead of "skip 10,000 rows," it asks "give me rows after key X" — a query the key column's index answers directly, so every page costs the same regardless of depth. Pass "" for after to get the first page; read the last row's key_col value out of that page and pass it as after on the next call to keep walking forward:
let page1 = forge_orm.orm_paginate_keyset(db, "orders", "id", "", 20, true) let last_id = page1[len(page1) - 1]["id"] let page2 = forge_orm.orm_paginate_keyset(db, "orders", "id", str(last_id), 20, true)
The asc flag picks the direction for both the ORDER BY and the comparison operator: ascending walks forward with key_col > after, descending walks backward with key_col < after. There is no offset anywhere in the generated SQL, so this is the pattern to reach for the moment "page 40" starts showing up in a slow-query log — not just for pages you expect users to scroll to deliberately, but for any API that a script or a crawler might page through sequentially to the end.
DO: Reach for orm_paginate_keyset for any endpoint where depth is unbounded — a public API, an infinite-scroll feed, a data export — because the cost per page never grows. DON'T: Use it as a drop-in replacement for .paginate() everywhere: it has no op_total/op_pages equivalent (a real "next" cursor doesn't know how many pages are left without a separate count), and it can't jump to an arbitrary page number — only "the next N after this key." Use .paginate() + orm_page_meta when the UI needs page numbers; use keyset when the UI only needs "next."
Walking an entire table without loading it into memory
orm_stream(db, table, key_col, chunk_size, process_fn) takes the same keyset mechanism and turns it into a full-table walk: given a 10-million-row table, calling orm_all on it tries to materialize all 10 million rows as one list, which is exactly the kind of load that OOMs a process. orm_stream instead reads chunk_size rows at a time, ordered by key_col ascending, calls process_fn with each chunk, and advances its internal cursor to the last row's key before fetching the next chunk — so memory use stays bounded at roughly one chunk's worth of rows no matter how large the table is.
forge_orm.orm_stream(db, "events", "id", 1000, fn(batch) for e in batch process(e) )
process_fn receives each chunk as a whole batch (a list), not row by row, so it loops over batch itself — that's the shape to match if you want per-row logic. The walk stops when a chunk comes back smaller than chunk_size, which is how it recognizes the end of the table without a separate count query, and it returns a Result wrapping the total number of rows seen across every chunk.
This is deliberately a much narrower tool than orm_each (covered earlier in this chapter). orm_each(db, sql, params, chunk, row_fn) takes an arbitrary SQL query — any joins, any WHERE, any shape — and, when the driver is PostgreSQL, drives it through a real server-side DECLARE ... CURSOR inside one transaction, so it's an actual snapshot: concurrent inserts and deletes elsewhere can't shift rows across its chunk boundaries. On SQLite and MySQL, which have no equivalent ad-hoc cursor, it falls back to the same LIMIT/OFFSET-over-a-subquery approach as .paginate(), with the same "not a snapshot" caveat that implies. orm_stream makes none of those choices and needs none of that driver branching: it always works off one key column, always uses the keyset WHERE, and behaves identically on SQLite, PostgreSQL, and MySQL because there is no cursor path to differ. Reach for orm_each when you already have a specific query (joins, filters, projections) you need to page through; reach for orm_stream when the job is simpler than that — "walk this whole table" — and you'd rather not write the SQL at all.
N+1 elimination, relations, and DataLoader
Read a list of projects, then loop over them fetching each project's tasks, and you've just issued
1 + len(projects) queries to answer one page load. With 100 projects that's 101 round
trips for data a single WHERE project_id IN (...) could return in one. This is the N+1
problem, and forge_orm attacks it structurally rather than papering over it
with a cache: read the parents, pluck their keys, fetch all children in one IN
query, then group the results in memory. Two queries for any depth-1 relation, regardless of how many
parents there are — and because grouping happens in memory instead of a SQL JOIN, the
children come back as real, distinctly-typed structs rather than a flattened, duplicated row set.
The manual building blocks
orm_pluck pulls one field's value out of every struct in a list — this is how you turn a list of parent rows into the key set the child query needs:
let ids = forge_orm.orm_pluck(ps, "id") // [1, 1, 2, 3, 3, 3, ...] -- one entry per row, duplicates included
orm_pluck_uniq does the same but drops duplicates — necessary because a
foreign key repeats across children (three tasks with project_id = 3 would otherwise put
3 into an IN (...) list three times). And orm_group_by_field
takes a flat list of child rows and buckets them by a field's value into a dict, keyed by
the stringified value — a dict key must be a string here, so the parent's id has to be
stringified the same way (str(p.id)) when you look a bucket up, or the lookup silently
misses and you get back the empty-list default instead of the children that are actually there.
Chained together, these three calls are the whole manual pattern:
let ps: list<Project> = forge_orm.orm_all(db, "SELECT * FROM projects", []) let q = forge_orm.orm_from(db, "tasks").in_("project_id", forge_orm.orm_pluck_uniq(ps, "id")) let ts: list<Task> = forge_orm.orm_all(db, q.sql(), q.params) let by_project = forge_orm.orm_group_by_field(ts, "project_id") let mine = get(by_project, str(ps[0].id), []) // zero extra queries
Two queries total, and ts stays a genuine list<Task> — this is exactly
the pattern the rest of this section collapses into single calls.
orm_load_related — the one-call N+1 killer
orm_load_related is pluck_uniq + .in_() +
group_by_field fused into one function. Given the parent list, the child table name, and
the foreign-key field on the child, it returns a dict mapping each parent's id (as a
string) to a list of child structs — two queries total, no matter how many parents:
let ps: list<Project> = forge_orm.orm_all(db, "SELECT * FROM projects", []) let by_pid = forge_orm.orm_load_related(db, ps, "tasks", "project_id") for p in ps let tasks = get(by_pid, str(p.id), []) // zero extra queries
If the parent list is empty, orm_load_related returns {}
immediately without touching the database — an empty result page never triggers a wasted round trip.
When the child side needs its own filtering or ordering — say, only open tasks —
orm_load_related_spec takes an OrmQuery spec for the child query
instead of a bare table name, and applies the same .in_() + group-by underneath:
let by_pid = forge_orm.orm_load_related_spec(db, ps, forge_orm.orm_from(db, "tasks").eq("status", "open"), "project_id")
orm_prefetch — several relations, still N+1 total queries not N×parents
Real pages rarely need just one relation — a project list view wants tasks and comments.
orm_prefetch takes the parent list and a list of
[child_table, fk_field] pairs, and returns a list of grouped dicts in the same order as the
relations you asked for — one additional query per relation, not per relation per parent:
let ps: list<Project> = forge_orm.orm_all(db, "SELECT * FROM projects", []) let [tasks_by_pid, comments_by_pid] = forge_orm.orm_prefetch(db, ps, [ ["tasks", "project_id"], ["comments", "project_id"] ]) // 3 queries total (1 parent + 2 children), not 1 + 100 + 100 for 100 projects
OrmLoader — a request-scoped DataLoader, for when keys don't arrive as a list
orm_load_related assumes you already have the whole parent list in hand
before you go to the child table. That assumption breaks down in a GraphQL resolver: each parent's
resolver runs independently, one key at a time, and you don't get to see the full parent list up front —
you only find out you need child 77 when that particular resolver call happens to fire.
OrmLoader is built for exactly that shape: it queues keys as they trickle in from separate
resolver calls across a request, then dispatches them as one batched query at the end, the way
Facebook's DataLoader pattern does — except this one is dialect-aware, so the quoting and
parameterization it emits are already correct for whichever of the three databases db
points at.
let loader = forge_orm.orm_loader_new(db, "users", "id") forge_orm.orm_loader_queue(loader, "42") forge_orm.orm_loader_queue(loader, "77") forge_orm.orm_loader_dispatch(loader) // one SELECT ... WHERE id IN (42, 77) let u42 = forge_orm.orm_loader_get(loader, "42") let u77 = forge_orm.orm_loader_get(loader, "77")
orm_loader_queue skips a key outright if it's already sitting in the
loader's cache, and skips adding it to the queue twice if it's already pending — queuing the same key
from two different resolver calls in the same request costs nothing extra.
orm_loader_dispatch issues the single IN (...) query, populates the
cache from the results, empties the queue, and returns the count of keys it just resolved (0
if the queue was already empty, so dispatching a loader nobody queued anything into is a no-op, not a
wasted round trip). orm_loader_get returns null for a key that
was never queued or that the query didn't find a row for — there's no distinction between "not asked
for" and "asked for, not found," so check the id you expected against what you get back if that
distinction matters to your caller.
When you do have the wanted key list in hand — even for the loader's request-scoped use case — orm_loader_load_all collapses queue + dispatch + gather into one call:
let loader = forge_orm.orm_loader_new(db, "users", "id") let users = forge_orm.orm_loader_load_all(loader, ["42", "77"])
DO: reach for orm_load_related when
you already have the full parent list — it's two calls, not the queue/dispatch dance, and it's the
simpler tool for that shape. DON'T: reach for OrmLoader in that same
situation just because it sounds more general — it exists for the case where keys arrive piecemeal
across independent resolver calls and you need a request-scoped place to accumulate them before one
final dispatch; used where you already hold the parent list, it's strictly more ceremony for the same
two queries.
orm_coalesce — merging same-table specs into one query
orm_coalesce takes a list of OrmQuery specs that all target
the same table and merges their WHERE conditions with OR into a single query,
parenthesizing each spec's condition so precedence can't bleed across specs:
let q1 = forge_orm.orm_from(db, "users").eq("team_id", 1) let q2 = forge_orm.orm_from(db, "users").eq("team_id", 2) let merged = forge_orm.orm_coalesce(db, [q1, q2]) // one query: WHERE (team_id=1) OR (team_id=2) let all_users = forge_orm.orm_all(db, merged.sql(), merged.params)
The caller distributes the merged result set back out locally. This is a real, usable building block
today — the same source comments that describe it also note it as the piece that a future runtime
scheduler could use to coalesce independently-issued queries automatically, but you don't need that
scheduler to benefit from it now: anywhere your code already builds several same-table specs before
running them, replacing N round trips with orm_coalesce + one orm_all works
today.
orm_find_or_create — atomic-shaped get-or-insert
orm_find_or_create looks a row up by one field, and only inserts it if
nothing matched — two queries in the common case (SELECT, then INSERT only on
a miss), one query when the row already exists. It's a different tool from the ORM's upsert family:
upsert is for the conflict-tolerant "write this row regardless" case, while
orm_find_or_create is for "give me the row that exists, or create it and give
me that" — the case where the caller wants the id either way:
let r = forge_orm.orm_find_or_create(db, "tags", "name", Tag { name: "urgent", id: 0 }) if is_err(r) return r let tag = unwrap(r)
Both the lookup and the insert can fail (a broken connection, a constraint violation the row itself triggers), which is why the result is checked with is_err / unwrap like any other fallible ORM call rather than trusted blindly.
Put together, these functions cover the whole N+1 surface at the library level: manual building blocks when you want control, one-call helpers for the common shapes, a DataLoader for the resolver case, and a coalescing primitive for merging independent queries — with zero annotations and the same compile-time dialect portability as the rest of the ORM. No ORM among Hibernate, ActiveRecord, Django, Prisma, SQLAlchemy, Ecto, or GORM provides this whole set at the library level with zero annotations and compile-time dialect portability.
Portable raw SQL, plus a complete example
Every struct-driven builder covered so far — orm_save, orm_create_table, the OrmQuery methods — already routes every identifier it touches through the dialect-quoting path automatically. The five helpers below exist for the moment you step outside those builders and write a SQL fragment by hand: they give hand-written SQL the same cross-dialect protection the struct-driven path already has for free.
let col = forge_orm.orm_qident(db, "order") // a reserved word, quoted for db's dialect let now = forge_orm.orm_now_sql() // "CURRENT_TIMESTAMP" -- portable; NOW() does not exist on SQLite let concat = forge_orm.orm_concat_sql("first_name", "last_name") // "CONCAT(first_name, last_name)" let ilike = forge_orm.orm_ilike_sql(db, "name") // "LOWER(\"name\") LIKE ?" -- bind lower(needle) yourself forge_orm.orm_all(db, "SELECT * FROM users WHERE {ilike}", [lower("Alice")])
orm_now_sql() exists because NOW(), the obvious spelling, simply doesn't exist on SQLite (it returns zero rows, not an error) — CURRENT_TIMESTAMP is the one spelling verified on all three. orm_concat_sql(a, b) exists because the equally obvious a || b is NOT string concatenation on MySQL — || there is logical OR, and silently returns 0 instead of erroring, which makes it one of the more dangerous cross-dialect traps in this whole area precisely because it doesn't fail loudly. orm_ilike_sql(db, col) exists because real ILIKE doesn't exist on MySQL at all — pass the already-quoted, dialect-correct LOWER(col) LIKE ? fragment into your own SQL and bind lower(needle) as the parameter, the same pattern .icontains uses internally.
orm_table_of — the CamelCase trap that catches almost everyone
orm_table_of(db, row) returns the dialect-quoted table name for a struct's own table — the same string orm_ensure/orm_save already use internally, exposed so hand-written SQL can reference it too. This exists to close a specific, sharp trap: a quoted SQL identifier is case-sensitive on PostgreSQL. NOVA type names are CamelCase by convention, so a struct called Widget creates a table literally named "Widget" — capital W. Hand-written SQL like SELECT * FROM Widget (no quotes) gets case-folded by PostgreSQL down to widget before it looks the table up, and fails outright: relation "widget" does not exist. Since NOVA type names are CamelCase essentially everywhere, this trap is reachable by any hand-written query against a zero-annotation struct table, not an edge case.
type Widget id: int qty: int let t = forge_orm.orm_table_of(db, Widget(0, 0)) let low: list<Widget> = forge_orm.orm_all(db, "SELECT * FROM {t} WHERE qty > ?", [5]) // "SELECT * FROM Widget ..." (unquoted) would fail on PostgreSQL with: // relation "widget" does not exist
DO: reach for orm_table_of(db, sample) the moment you write a raw SELECT/UPDATE/DELETE against a struct's own zero-annotation table, on any driver you might ever run against PostgreSQL. DON'T: assume this only matters in production — SQLite is case-insensitive about table names by default, so the bug is completely invisible in local development and shows up for the first time against a real PostgreSQL database, which is exactly the worst place to discover it.
Putting it together: a task tracker REST API
One last example, combining the pieces from across this chapter: struct-driven migrations at startup, zero-SQL CRUD, real pagination with a total count, and N+1-safe relation loading — the same forge call shapes used throughout this tutorial, just backed by forge_orm instead of raw SQL.
import forge import forge_orm type Project id: int name: string type Task id: int project_id: int title: string done: bool fn main() let db = unwrap(forge_orm.orm_open("sqlite://tracker.db")) // Migrations: the struct IS the schema, no separate migration files forge_orm.orm_ensure(db, Project(0, "")) forge_orm.orm_ensure(db, Task(0, 0, "", false)) forge_orm.orm_ensure_index(db, "Task", ["project_id"]) let app = forge.app() // Create a project. Each handler is `req => <block>` — that arrow form (unlike // the fn(req) keyword form) accepts a multi-statement body in call-argument position. type NewProject name: string forge.post(app, "/projects", req => let body: NewProject = from_json(req.body) let id = unwrap(forge_orm.orm_save_id(db, Project(0, body.name))) forge.resp_json(201, {"id": id}) ) // Paginated project list with a real total -- .paginate() + orm_page_meta forge.get(app, "/projects", req => let spec = forge_orm.orm_spec(db, Project(0, "")) let pg = unwrap(forge_orm.orm_page_meta(db, spec, 1, 20)) let page = spec.paginate(1, 20) let items: list<Project> = forge_orm.orm_all(db, page.sql(), page.params) forge.resp_json(200, {"items": items, "total": pg.op_total, "has_next": pg.op_has_next}) ) // One project by id -- orm_row so a missing id is a real 404, not a zero-valued struct. // find_project below is an ORDINARY function, not the route handler itself, and it's // the one that matches the Result -- see the callout after the JWT example for why that // split matters here, not just as style. forge.get(app, "/projects/:id", req => handle_get_project(req, db)) // Every project WITH its tasks -- 2 queries total, never 1 + one-per-project forge.get(app, "/projects-with-tasks", req => let ps: list<Project> = forge_orm.orm_all(db, "SELECT * FROM {forge_orm.orm_table_of(db, Project(0, \"\"))}", []) let by_pid = forge_orm.orm_load_related(db, ps, "Task", "project_id") let out = [] for p in ps let tasks = get(by_pid, str(p.id), []) push(out, {"project": p, "tasks": tasks}) forge.resp_json(200, out) ) forge.serve_app(app, 8080) // An ordinary function — not itself a route handler — is the one that matches the // Result. See the callout after the JWT example: matching directly inside a function // registered with forge.get/forge.post is a real, currently-open compiler bug. fn find_project(db, id) let t = forge_orm.orm_table_of(db, Project(0, "")) match forge_orm.orm_row(db, "SELECT * FROM {t} WHERE id = ?", [id]) Err(e) => null Ok(row) => row fn handle_get_project(req: Request, db) -> Response let row = find_project(db, req.params["id"]) if row == null return forge.resp_error(404, "project not found") forge.resp_json(200, row)
Every route above is zero-SQL-string-by-hand except the two spots that genuinely need a WHERE clause — and both of those route their table name through orm_table_of rather than writing Project/Task unquoted, so the same code that works against local SQLite during development keeps working unchanged the day this app is pointed at a real PostgreSQL database. That portability — one API, three databases, from a struct declaration to a paginated, N+1-safe REST endpoint — is the whole point of forge_orm.
27. Forge: WebSocket and SSE
What is this? Real-time bidirectional communication. WebSocket (RFC 6455) for two-way messaging. SSE (Server-Sent Events) for one-way server push to browsers. Both built into Forge.
What makes WebSocket different from HTTP
With HTTP, every interaction is a complete request-response cycle: client asks, server answers, connection closes. With WebSocket, the connection stays open and both sides can send messages at any time — like a phone call vs. sending letters.
| HTTP | WebSocket | |
|---|---|---|
| Direction | Client → Server then Server → Client | Both directions, any time |
| Connection | Opens and closes per request | Stays open until closed |
| Good for | APIs, pages, forms | Chat, games, live dashboards |
WebSocket echo server
import forge fn main() app = forge.app() // forge.ws registers a WebSocket handler at /echo forge.ws(app, "/echo", fn(ws, msg) ws_emit(ws, "You said: {msg}") // sends back to THIS client only ) forge.serve_app(app, 8080)
Line-by-line:
forge.ws(app, "/echo", fn(ws, msg) ...)— Registers a WebSocket endpoint. Unlikeforge.get(one request, one response), this keeps a persistent connection open. The handler function is called every time the client sends a message.ws= the connection object;msg= the text the client sent.ws_emit(ws, "You said: {msg}")— Sends a message back to THIS specific client only. If 100 clients are connected, only the one who sent the message gets this reply.
Testing from browser console (F12 → Console):
const ws = new WebSocket("ws://localhost:8080/echo"); ws.onmessage = (e) => console.log(e.data); ws.send("hello"); // Console logs: "You said: hello"
Multi-client chat (broadcast hub)
import forge fn main() app = forge.app() hub = forge.hub() // broadcast hub — like a public address system // All clients on /chat share the hub — messages go to EVERYONE forge.ws_room(app, "/chat", hub, fn(ws, msg, hub) room_say(hub, "Someone said: {msg}") // broadcasts to ALL connected clients ) forge.serve_app(app, 8080)
Line-by-line:
hub = forge.hub()— Creates a broadcast hub. Think of it as a group chat room's shared state — every message goes to everyone.forge.ws_room(app, "/chat", hub, fn(ws, msg, hub) ...)— Likeforge.ws, but every client that connects to/chatis automatically added to the hub. The handler receives a third argument: the hub itself.room_say(hub, "...")— Broadcasts the message to ALL clients currently connected to this hub. If 50 users are in the chat, all 50 receive the message.
SSE (Server-Sent Events) — server push
import forge fn main() app = forge.app() // Server sends events to browser, browser receives them forge.sse(app, "/events", fn(stream) count = 0 loop sleep_ms(1000) count = count + 1 sse_send(stream, "tick {count}") ) forge.serve_app(app, 8080)
Consuming SSE from a browser — no library needed, EventSource is a built-in browser API that auto-reconnects on its own:
const es = new EventSource("http://localhost:8080/events"); es.onmessage = (e) => console.log("got:", e.data); // Console logs: got: tick 1 (then tick 2, tick 3, ... every second) // If the connection drops, the browser reconnects automatically — no reconnect logic needed
WebSocket vs SSE — which to pick:
| WebSocket | SSE | |
|---|---|---|
| Direction | Full duplex — client and server both send | One-way — only server sends |
| Protocol | Its own framing over an upgraded TCP connection | Plain HTTP, chunked text — works through more proxies |
| Reconnect | Manual — you write the retry logic | Automatic — built into EventSource |
| Use for | Chat, multiplayer games, collaborative editing | Live dashboards, notifications, progress bars, log tailing |
DO: Validate the Origin header on WebSocket upgrade requests in production — without it, any website can open a WebSocket connection to your server using a visitor's browser and cookies (cross-site WebSocket hijacking). DON'T: Broadcast large payloads through room_say/hub fan-out on every message if you have thousands of connected clients — each send is copied per-subscriber; batch or throttle high-frequency updates (see the throttle pattern in Appendix B) instead of pushing every tick to every client.
28. Forge: authentication
What is this? JWT authentication for stateless API security, plus CSRF protection for browser-based forms. All built into Forge — no separate auth library.
Full login + protected route
import forge type LoginBody username: string password: string fn main() let app = forge.app() let secret = env("JWT_SECRET") // NEVER hardcode secrets in source code! // POST /login — issue a token on successful login forge.post(app, "/login", req => let body: LoginBody = from_json(req.body) if body.username == "admin" and body.password == "secret" let token = forge.jwt_encode({"sub": "admin", "role": "admin"}, secret) forge.resp_json(200, {"token": token}) else forge.resp_error(401, "invalid credentials") ) // GET /admin — protected: requires a valid JWT token forge.get(app, "/admin", req => handle_admin(req, secret)) forge.serve_app(app, 8080) // A verification helper, called FROM the handler rather than inlined into it. This is // not just style: matching a Result directly inside a function registered as a Forge // route handler is a real, currently-open compiler bug — the matched-out payload comes // back zeroed. Delegating the match to an ordinary function and handing the handler a // plain value avoids it entirely; see the callout after the next example. fn verify_claims(token, secret) match forge.jwt_verify(token, secret) Err(e) => null Ok(claims) => claims fn handle_admin(req: Request, secret: string) -> Response let token = forge.bearer_token(req) let claims = verify_claims(token, secret) if claims == null return forge.resp_error(401, "invalid token") forge.resp_json(200, {"message": "Welcome, {claims["sub"]}!"})
Line-by-line:
secret = env("JWT_SECRET")— Reads the JWT secret from an environment variable. Run the server as:JWT_SECRET=my-long-secret nova run app.nova. Never hardcode secrets.forge.jwt_encode(claims, secret)— Creates a signed JWT. Theclaimsdict contains user identity data embedded in the token."sub"(subject) is the user's ID.forge.bearer_token(req)— Extracts the JWT from theAuthorization: Bearer <token>header sent by the client.forge.jwt_verify(token, secret)— Verifies the signature. Returns the claims dict if valid,nullif invalid or expired.
# Step 1: Login to get a token TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ -d '{"username":"admin","password":"secret"}' \ http://localhost:8080/login | jq -r .token) # Step 2: Access the protected route with the token curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/admin # Output: {"message": "Welcome, admin!"} # Step 3: Try without a token — should fail curl http://localhost:8080/admin # Output: 401 invalid token
Protected routes with middleware
Instead of checking the JWT in every handler, protect all routes at once with one line:
forge.use(app, forge.mw_require_auth(secret)) // Now ALL routes require a valid JWT in the Authorization header // Requests without a valid token are rejected with 401 before any handler runs
CSRF protection (Cross-Site Request Forgery)
CSRF is an attack where a malicious website tricks your browser into submitting forms to YOUR application using YOUR session cookies. The attacker's site cannot know your CSRF token, so forged requests are rejected:
import forge fn main() app = forge.app() forge.use(app, forge.mw_csrf()) forge.get(app, "/form", fn(req) token = forge.csrf_token(req) forge.html("<form method='POST' action='/submit'>" + "<input type='hidden' name='_csrf' value='{token}'>" + "<input type='text' name='data'>" + "<button>Submit</button></form>") ) forge.post(app, "/submit", fn(req) // mw_csrf auto-verifies the _csrf field — if missing, returns 403 data = forge.body_form(req) forge.text("Received: {data[\"data\"]}") ) forge.serve_app(app, 8080)
forge.mw_csrf()— Enables CSRF protection on all POST/PUT/DELETE routes. Rejected with 403 if the token is missing or invalid.forge.csrf_token(req)— Generates a unique token for this request, tied to the user's session. Embed it as a hidden field in every form.- When the form is submitted,
mw_csrfchecks the_csrffield automatically. A different site cannot forge this token because they cannot read it from your page.
Where to store the JWT on the client
The token itself (forge.jwt_encode's output) is just a string — where the CLIENT stores it between requests determines your attack surface:
| Storage | Vulnerable to | Verdict |
|---|---|---|
localStorage | XSS — any injected script can read it and exfiltrate it | Avoid for anything sensitive |
| In-memory JS variable | Lost on page refresh; still readable by any script running on the page | OK for short-lived SPA sessions |
httpOnly cookie | CSRF (mitigated by mw_csrf/SameSite) — but NOT readable by JavaScript, so XSS can't steal it | Recommended for browser apps |
Authorization: Bearer header | Nothing extra — but the client (mobile app, CLI, server-to-server) must manage storage itself | Correct choice for non-browser clients |
DO: Serve auth-protected apps over HTTPS only — a JWT sent over plain HTTP can be captured by anyone on the network path and reused until it expires. Set short expiries (minutes to hours) on access tokens and use a separate long-lived refresh token if you need persistent sessions. DON'T: Put secrets or passwords inside JWT claims — the payload is base64-encoded, not encrypted, and anyone holding the token can decode and read it (try it: paste any JWT into jwt.io). DON'T: Compare the raw password field with == against a stored plaintext password like the example above — that is for demonstration only. Store a salted hash (forge_crypto.pbkdf2_sha256) and compare hashes, never plaintext passwords.
29. Forge: HTML builder
What is this? Instead of writing raw HTML strings (which are error-prone — you might forget to close a tag, or accidentally introduce an XSS vulnerability), forge_html provides functions that generate HTML programmatically. Each function corresponds to an HTML tag. There is no template language and no build step — every builder is just a NOVA function that returns a string, and pages are assembled with ordinary string concatenation.
Every element builder shares ONE shape: tagname(attrs_dict, inner_html_string). The first argument is a dict of HTML attributes ({} for none); the second is the already-built inner HTML — usually the result of other builder calls joined with +. There is no separate "children list" mechanism — you compose HTML the same way you compose strings anywhere else in NOVA.
Building a complete page
import forge import forge_html fn main() app = forge.app() forge.get(app, "/page", fn(req) features = forge_html.join_children([ forge_html.li({}, "Fast — C-level performance"), forge_html.li({}, "Safe — no data races"), forge_html.li({}, "Simple — simpler than Python") ]) body = forge_html.h1({}, "Welcome to NOVA") + forge_html.p({}, "This is a paragraph.") + forge_html.div({}, forge_html.h2({}, "Features") + forge_html.ul({}, features)) + forge_html.link("https://novachan.org", "Learn more") forge.html(200, forge_html.page("NOVA", "", body)) ) forge.serve_app(app, 8080)
Line-by-line:
import forge_html— Loads the HTML builder functions. Like every Forge module, its functions are called with theforge_html.prefix.forge_html.h1({}, "Welcome to NOVA")— Creates<h1>Welcome to NOVA</h1>. The first argument is an attributes dict (empty here), the second is the inner HTML. Onlyh1/h2/h3exist as named helpers — forh4–h6use the genericforge_html.el("h4", {}, text).forge_html.div({}, ...)— Creates a<div>. Its children are built first and concatenated with+, then passed in as the singleinnerstring — there's no children-list argument.forge_html.join_children([...])— A convenience that concatenates a list of HTML fragments into one string. It exists specifically for the common case of building a list of children withmap()or a comprehension, like the threelielements above.forge_html.link("https://novachan.org", "Learn more")— Creates<a href="...">Learn more</a>. The function is namedlink, nota(ais not a valid NOVA identifier issue — it's just the name Forge chose).linkalso checks the URL scheme and neutralizesjavascript:/data:URLs to"#", closing an XSS vector that a raw<a href>would not.forge_html.page("NOVA", "", body)— Wrapsbodyin a complete<!doctype html><html>...</html>document. The title is escaped automatically; the middle argument is raw extra<head>markup (stylesheet links, meta tags) —""here means none.forge.html(200, ...)— Sends the HTML string as the response withContent-Type: text/html. This is the sameforge.html(status, body)used for any text response — there is no separatehtml_responsefunction.
What curl http://localhost:8080/page receives — a single unbroken HTML string, wrapped and indented here for readability:
<h1>Welcome to NOVA</h1>
<p>This is a paragraph.</p>
<div><h2>Features</h2><ul>
<li>Fast — C-level performance</li>
<li>Safe — no data races</li>
<li>Simple — simpler than Python</li>
</ul></div>
<a href="https://novachan.org">Learn more</a>
</body></html>
Escaping — automatic on attributes, explicit on body text
forge_html gives you two DIFFERENT safety guarantees, and confusing them is the single most common mistake:
- Attribute values are always escaped, with zero effort.
forge_html.div({"class": userInput}, ...)is always safe — a value like"x\" onmouseover=\"alert(1)"is escaped so it cannot break out of the attribute. - Inner/body content is NOT auto-escaped. Splicing a raw string into an element's
innerargument emits it verbatim. You must callforge_html.esc(text)yourself before interpolating any user-supplied text into body content.
// WRONG — title goes into inner content unescaped: a real XSS hole forge_html.li({}, todo.title) // CORRECT — esc() converts < > & " ' to HTML entities before splicing forge_html.li({}, forge_html.esc(todo.title)) // title = "<script>alert(1)</script>" --> <script>alert(1)</script> // renders as literal text on the page instead of executing
DO: Call forge_html.esc(text) on every piece of user-supplied text before it goes into an element's inner argument — attribute values are safe automatically, body text is not. DON'T: Assume raw() escapes anything — it is the identity function, an explicit no-op marker for "this HTML is already trusted" (e.g. output from a markdown renderer you control), not a sanitizer. Calling raw(userInput) is exactly as dangerous as splicing the raw string yourself.
Template with dynamic data
fn todo_item(todo) at = if todo.done then {"class": "done"} else {} forge_html.li(at, forge_html.esc(todo.title)) // esc() is required — inner content is not auto-escaped fn todos_page(todos) items = forge_html.join_children([todo_item(t) for t in todos]) forge_html.h1({}, "My Todos ({len(todos)} items)") + forge_html.ul({}, items)
at = if todo.done then {"class": "done"} else {} shows the attributes dict doing double duty as a conditional CSS class — a common pattern since {} (no attributes) and a populated dict are both valid first arguments to every builder.
Available HTML functions
| Function | HTML output | Notes |
|---|---|---|
el(tag, at, inner) | <tag ...>inner</tag> | Generic builder — use for any tag without a dedicated helper (h4-h6, ul, select, ...) |
div(at, inner) / span(at, inner) | <div> / <span> | Block / inline container |
p(at, inner) | <p>...</p> | Paragraph |
h1/h2/h3(at, inner) | <h1>–<h3> | No h4-h6 helpers — use el("h4", at, inner) |
ul(at, inner) / ol(at, inner) | <ul> / <ol> | Unordered/ordered list |
li(at, inner) | <li>...</li> | List item |
link(href, inner) | <a href="href">inner</a> | href is scheme-checked — javascript:/data: become "#" |
img(src, alt) | <img src="src" alt="alt"> | Both arguments required |
table/tr/td/th(at, inner) | table, row, cell, header cell | Same (at, inner) shape as every other element |
form(at, inner) | <form>...</form> | Form container |
input_tag(at) / meta(at) | <input ...> / <meta ...> | Void elements — attributes dict only, no inner content |
button(at, inner) / label(at, inner) | <button> / <label> | |
code(at, inner) / pre(at, inner) | <code> / <pre> | Inline/block code |
em(at, inner) / strong(at, inner) | <em> / <strong> | Italic/bold |
section/nav/header/footer(at, inner) | semantic layout tags | |
br() / hr() | <br> / <hr> | No arguments |
esc(s) | — | Escapes a string for safe body-text interpolation — call on ALL user data |
raw(s) | verbatim (no escaping) | Identity function — an explicit "this is trusted" marker, not a sanitizer |
join_children(list) | — | Concatenates a list of HTML fragments into one inner string |
page(title, head, body) | full <!doctype html> document | title is escaped; head/body are raw |
30. Forge: advanced features
What is this? Middleware chains, declarative validation, CORS, rate limiting, cookies, static files, OpenAPI docs, health checks, compression, and metrics. All production essentials.
Middleware stack
import forge fn main() app = forge.app() // Middleware is applied in order, from first to last forge.use(app, forge.mw_cors()) // add CORS headers to every response forge.use(app, forge.mw_logger()) // log every request forge.use(app, forge.mw_header("X-Frame-Options", "DENY")) // Custom middleware: add request timing forge.use(app, fn(req, next) t0 = now_ms() resp = next(req) log_info("{req.method} {req.path} {now_ms() - t0}ms") resp ) forge.get(app, "/health", fn(req) {"status": "ok"}) forge.serve_app(app, 8080)
Declarative input validation
Never trust user input. Forge's validation system checks fields with composable rules:
import forge fn main() app = forge.app() forge.post(app, "/register", fn(req) body = forge.body_json(req) errors = forge.validate(body, { "email": [forge.required(), forge.email()], "password": [forge.required(), forge.min_len(8)], "role": [forge.one_of(["user", "admin"])] }) if len(errors) > 0 return forge.errors_response(errors) forge.json("{\"created\": true}") ) forge.serve_app(app, 8080)
Line-by-line:
body = forge.body_json(req)— Parses JSON request body into a dict.errors = forge.validate(body, {...})— Each key is a field name; the value is a list of rules. Returns a list of error messages (empty if all pass)."email": [forge.required(), forge.email()]— Email must be present AND match an email format."password": [forge.required(), forge.min_len(8)]— Password must be present AND at least 8 characters."role": [forge.one_of(["user", "admin"])]— Role, if present, must be one of the listed values.forge.errors_response(errors)— Returns 400 Bad Request with the errors as JSON.
curl -X POST -H "Content-Type: application/json" \ -d '{"email":"not-an-email","password":"short","role":"superadmin"}' \ http://localhost:8080/register
{"errors": ["email: invalid format", "password: must be at least 8 characters", "role: must be one of [user, admin]"]}
| Validation rule | What it checks |
|---|---|
forge.required() | Field must be present and non-empty |
forge.min_len(n) | String length ≥ n |
forge.max_len(n) | String length ≤ n |
forge.email() | Valid email address format |
forge.one_of(options) | Value must be in the provided list |
forge.min_val(n) | Numeric value ≥ n |
forge.max_val(n) | Numeric value ≤ n |
forge.matches(regex) | String matches a regex pattern |
Rate limiting
Restrict how many requests a single client can make in a time window. Prevents DoS attacks and API abuse:
// 100 requests per 60 seconds per client IP forge.use(app, forge.mw_rate_limit(100, 60))
If a client exceeds 100 requests in 60 seconds, subsequent requests receive a 429 (Too Many Requests) response until the window resets.
CORS (Cross-Origin Resource Sharing)
CORS headers tell browsers which websites can call your API. Without them, a JavaScript frontend on mysite.com cannot call an API on api.mysite.com:
forge.use(app, forge.mw_cors()) // allow ALL origins (development only!) forge.use(app, forge.mw_cors_origin("example.com")) // allow only example.com (production)
DON'T: Use mw_cors() (allow all origins) in production — it lets any website make requests to your API. DO: Use mw_cors_origin("yourdomain.com") in production.
Static files
// Serve files from the public/ directory forge.get(app, "/static/:file", fn(req) filename = req.params["file"] forge.serve_file("public/" + filename) )
A request to /static/style.css sets req.params["file"] to "style.css". forge.serve_file reads from disk and sends the correct Content-Type header.
Response headers and cookies
forge.get(app, "/", fn(req) resp = forge.resp_text("Hello") forge.resp_set_header(resp, "X-Custom", "value") forge.resp_set_cookie(resp, "session", "abc123") resp )
forge.resp_text("Hello")— Creates a response object with a text body. Use this instead of returning a string when you need to modify headers.forge.resp_set_header(resp, "X-Custom", "value")— Adds a custom HTTP header. Useful for cache-control, security headers, etc.forge.resp_set_cookie(resp, "session", "abc123")— Sets a cookie. The browser sends it back on every subsequent request.
OpenAPI documentation
Forge auto-generates an OpenAPI 3.0 spec and a Swagger UI — interactive API documentation for free:
import forge type UserOut id: int name: string fn main() app = forge.app() forge.enable_docs(app, "My API", "1.0.0") // enables /openapi.json and /docs (Swagger UI) // get_doc's last argument is a SAMPLE response instance — its struct shape becomes // the endpoint's JSON Schema in the generated docs, zero annotation needed sample = UserOut { id: 0, name: "" } forge.get_doc(app, "/users", fn(req) forge.json_of(200, []) , sample) forge.serve_app(app, 8080) // Visit http://localhost:8080/docs for interactive docs
forge.enable_docs(app, title, version) takes the API's display name and version — both required, they appear at the top of the generated Swagger UI. get_doc(app, pattern, handler, resp_sample) takes the handler as its 3rd argument and a sample response instance as its 4th — the sample's field names and types are reflected into a JSON Schema, so the docs stay accurate without a separate schema language to maintain. post_doc/put_doc/patch_doc additionally take a request-sample and a validation-rules dict for documenting the request body; see the language reference for their full signatures. Routes registered with the plain forge.get/forge.post (not the _doc variants) still work exactly the same — they just don't appear in the generated docs.
Health checks and metrics
import forge import forge_obs import forge_compress // Health checks for Kubernetes / load balancers — both hardcode their own path forge.health_route(app) // GET /health -> {"status":"ok"} forge_obs.readyz_route(app, [["database", fn() db_ping(pool)]]) // GET /readyz // Gzip compression (reduces response size 60–80%) — its own module, imported separately forge.use(app, forge_compress.mw_compress()) // Prometheus metrics for Grafana / Datadog — also its own module, and needs a registry reg = forge_obs.metrics_registry() forge.use(app, forge_obs.mw_metrics(reg)) forge.get(app, "/metrics", fn(req) forge.text(200, forge_obs.metrics_prometheus(reg)) )
Line-by-line:
forge.health_route(app)— RegistersGET /health. It takes exactly one argument — the path is always/health, not caller-supplied. Useforge.health_route_checked(app, checks)if you want it to also verify dependencies (samechecksshape asreadyz_routebelow) before reporting healthy.forge_obs.readyz_route(app, checks)— Lives inforge_obs, notforge, and its path is likewise fixed at/readyz— the second argument ischecks, not a path string.checksis a list of[name, fn() -> bool]pairs; the route runs them in order and reports503 not ready: <name>at the first one that returnsfalse, or200 readyif all pass.forge_compress.mw_compress()— Compression middleware is a separate module (forge_compress) that importsforge, not the other way around — it must be imported on its own even though you alsoimport forge.forge_obs.metrics_registry()— Metrics need somewhere to accumulate counts; this creates that registry. Bothmw_metrics(the middleware that records each request) andmetrics_prometheus(the function that renders the Prometheus text format) take the SAME registry — mismatch them (e.g. two separate registries) and/metricssilently reports zero traffic.
Kubernetes and load balancers call /health every few seconds to check if the app is alive. /readyz indicates the app is ready to receive traffic (used during startup — e.g. "database connection established"). /metrics exposes request counts, latencies, and error rates in the format Prometheus scrapes.
31. FFI: calling C libraries
What is this? Call any C function from NOVA. extern fn declares the function signature. @link("lib") tells the linker which library to load. This is how NOVA uses SQLite, system math, and any C library ever written.
Calling C standard library functions
// Declare C functions — types must match the C header extern fn pow(base: float, exp: float) -> float extern fn sin(x: float) -> float extern fn cos(x: float) -> float fn main() print(pow(2.0, 10.0)) // 1024.0 print(sin(3.14159)) // ~0.0
0.0000026535897933
Linking an external library
// @link tells nova_build to link -lsqlite3 @link("sqlite3") extern fn sqlite3_open(path: string, db_ptr: ptr) -> int @link("sqlite3") extern fn sqlite3_close(db: ptr) -> int @link("sqlite3") extern fn sqlite3_exec(db: ptr, sql: string, cb: ptr, arg: ptr, err: ptr) -> int
Type mapping: NOVA to C
| NOVA type | C type | Notes |
|---|---|---|
int | int64_t | 64-bit signed integer |
float | double | 64-bit IEEE 754 |
bool | int | 0 or 1 |
string | const char* | Null-terminated UTF-8 |
ptr | void* | Opaque pointer |
Declaring and calling C standard library functions
The simplest FFI case: calling functions that are already in the C standard library (libc). These are always linked by default — no @link needed:
// puts is in libc — no @link needed extern fn puts(s: string) -> int puts("Hello from C!")
Line-by-line breakdown:
extern fn puts(s: string) -> int— Declares that a C function namedputsexists somewhere in a linked library.extern fndoes NOT define the function body — it says "trust me, this function exists, here is its type signature." The compiler uses this signature to type-check calls and generate the correct calling convention.puts("Hello from C!")— Calls the C function. NOVA automatically converts the NOVA string to a null-terminated Cchar*pointer thatputsexpects.
Unsafe blocks inside FFI — when C needs raw pointers
Some C functions need raw memory pointers. These require an unsafe block:
unsafe ptr = alloc_raw(1024) // allocate 1024 bytes of raw memory (like C malloc) ptr_write(ptr, 42) // write value 42 at this address val = ptr_read(ptr) // read back: 42 free_raw(ptr) // must free manually — no GC in unsafe
Why unsafe exists: Some operations (hardware access, custom allocators, C interop with raw pointers) require pointer manipulation that the compiler cannot verify as safe. Rather than making the entire language unsafe (like C) or forbidding these operations entirely, NOVA lets you opt into unsafety in explicitly marked blocks. You can grep any codebase for unsafe to find every place where safety is suspended — this is auditable by design.
Linking multiple libraries
@link("m") // libm (math library) @link("sqlite3") // libsqlite3 @link("ssl") // libssl (OpenSSL)
Safety note: FFI bypasses NOVA's type and memory safety. Calling a C function with wrong argument types can crash with no useful error message. The idiomatic pattern: wrap every C function in a safe NOVA function that validates inputs and converts error codes into Result values, so callers never directly touch the raw FFI.
The safe wrapper pattern
The idiomatic NOVA FFI pattern: expose the raw C function privately (by not exporting it), and provide a safe NOVA function that validates inputs, calls the C function, and converts C error codes to NOVA Result values.
// ------ raw C declarations (private, lower-level) ------ @link("sqlite3") extern fn sqlite3_open(path: string, db_ptr: ptr) -> int @link("sqlite3") extern fn sqlite3_close(db: ptr) -> int @link("sqlite3") extern fn sqlite3_errmsg(db: ptr) -> string // ------ safe NOVA wrapper (public, higher-level) ------ fn db_open(path) if len(path) == 0 return err("path cannot be empty") db_handle = alloc_raw(8) // 8 bytes for a pointer-to-pointer rc = sqlite3_open(path, db_handle) if rc != 0 // SQLite: 0 = SQLITE_OK msg = sqlite3_errmsg(db_handle) free_raw(db_handle) return err("sqlite open failed: {msg}") ok(db_handle) // Caller uses the safe wrapper — never touches raw C directly result = db_open("app.db") match result ok(db) -> print("Database opened") err(msg) -> print("Error: {msg}")
Why this pattern matters:
- The raw
sqlite3_opendeclaration is just 1 line. The safe wrapper is 8 lines. But every caller now gets input validation, proper error messages, and aResulttype instead of an opaqueintreturn code. - C functions signal errors with integers (0=success, nonzero=error). NOVA functions signal errors with
err(message). The wrapper is the translation layer between these two worlds. - If the wrapper is the ONLY place your code calls
sqlite3_open, then all your SQLite error handling is in one place. If SQLite error codes change between versions, you fix one function.
@link_source / @link_object — pulling companion C code straight into the build
@link("name") assumes the library already exists on the target machine — already built, already installed, findable by the system linker as -lname. Sometimes that's the wrong assumption: you have one small .c helper file, or a prebuilt .o object from a vendor SDK, that should just become part of YOUR program, with no separate install step for anyone who clones your NOVA project. @link_source("file.c") tells nova_build to compile that C file and link it straight into the binary. @link_object("file.o") does the same thing for a file that's already compiled. Both attach to an extern fn declaration in exactly the position @link does, and like @link they are pure build-graph directives — zero runtime overhead, and they disappear entirely the moment the binary is linked.
// fast_sum.c ships next to this .nova file: // long long fast_sum(long long a, long long b) { return a + b; } @link_source("fast_sum.c") extern fn fast_sum(a: int, b: int) -> int fn main() print(fast_sum(19, 23)) // 42
If the companion code only ships as a prebuilt object — a vendor SDK that hands you a .o and a header instead of source — swap the annotation and skip compiling it yourself: @link_object("fast_sum.o") on the same extern fn line.
DO: reach for @link_source when the C helper is small and genuinely belongs to your project — versioned in the same repository, no separate build step for anyone downstream. It turns a plain .c file into a first-class part of the NOVA build graph. DON'T: use @link_source to pull in a whole third-party library from source — that's what @link("name") plus a system package (apt/vcpkg/brew) is for. Compiling a large C dependency from scratch on every NOVA build would make build times unpredictable, which directly undercuts NOVA's fast-compile promise (Chapter 36).
FFI compared across languages
| Language | How to call C | Safety level | Effort |
|---|---|---|---|
| NOVA | extern fn name(args) -> T + @link("lib") | Unsafe at boundary, safe outside | 2 lines to declare |
| Python (ctypes) | lib = ctypes.CDLL("lib.so") + lib.func(args) | Unchecked — no type safety | Load library + set arg/return types per function |
| Python (cffi) | Parse C headers, ffi.cdef(header) | Better — type-checked against header | Medium — header parsing |
| Go (cgo) | // #include <lib.h> in Go file + C.func(args) | Type-checked, but pointers can escape | Medium — Go/C boundary has overhead |
| Rust (bindgen) | Generate bindings from C headers, mark calls unsafe | Wrapper required for safety | High — bindgen setup, lifetime management |
| Java (JNI) | Write C glue code that calls Java, compile separately | Complex — C glue can corrupt JVM | Very high — separate compilation step |
NOVA's FFI sits between Python (low ceremony) and Rust (high safety). You write one declaration line per C function, get static type checking on every call, and the unsafe boundary is explicit. The main limitation compared to Rust's bindgen: you must write the NOVA signatures by hand rather than auto-generating from C headers.
Struct-by-value FFI — byval<T>
Every FFI example above passes a struct as a pointer — the safe-wrapper pattern's db_handle is exactly that: NOVA hands C an address, C writes through it, NOVA reads the result back afterward. That's correct for a C function whose signature is genuinely T*. But plenty of real C ABIs define functions that take or return a struct by value — the fields themselves, packed into registers or pushed on the stack according to the platform's calling convention, not a pointer to them anywhere. A struct { double x, y; } passed by value crosses the boundary as two doubles already sitting in XMM0/XMM1 on SysV x86-64, as a hidden pointer to a caller-owned copy on Win64, and packed differently again on AAPCS64 (Arm64). If NOVA handed over a pointer where the C function expects two doubles already in registers, the callee reads garbage — its own calling convention's bit pattern, not NOVA's. byval<T> is the explicit, opt-in annotation that tells the compiler "lower this parameter (or return) exactly the way clang would for a by-value struct on this target," on a @repr("C") type.
// C side (vec2_native.c), linked in directly with @link_source: // double vec2_sum(Vec2 v) { return v.x + v.y; } // Vec2 make_v2(double k) { Vec2 r; r.x = k; r.y = k * 2.0; return r; } @repr("C") type Vec2 x: float y: float @link_source("vec2_native.c") extern fn vec2_sum(v: byval<Vec2>) -> float // parameter BY VALUE extern fn make_v2(k: float) -> byval<Vec2> // RETURN value BY VALUE fn main() total = unsafe vec2_sum(Vec2(1.5, 2.25)) print(total) v = unsafe make_v2(3.0) print(v.x) print(v.y)
3.0
6.0
A plain @repr("C") parameter (no byval) keeps its original, separate meaning: pass the heap pointer, so C can write through it and NOVA reads the mutation back — this is what the sqlite3_open examples above rely on. byval<T> is a different C signature entirely, and the compiler has to be told explicitly which one it's meeting, because LLVM IR itself has no concept of C's by-value aggregate-passing rules — that lowering lives in clang's frontend, so NOVA's compiler reproduces it per target instead of delegating to LLVM.
DO: Check the actual C header before choosing — byval<T> only for a parameter/return C declares as the struct type itself (Vec2 f(Vec2 v)), a plain type name for a pointer parameter (void f(Vec2 *v)). DON'T: Guess. Passing a pointer where C expects a by-value struct (or vice versa) doesn't just misread one field — it silently misaligns every register/stack slot after it, and on Win64 specifically a small (≤8-byte) by-value struct is passed differently than a large one, so "it worked on my machine" is not proof it's correct on every target.
32. Unsafe and low-level
What is this? unsafe blocks suspend NOVA's memory safety guarantees. Inside them you can allocate raw memory, do pointer arithmetic, and free manually. Required for low-level system code or C interop. Keep unsafe blocks as small as possible.
The meaning of unsafe
unsafe does NOT mean "this code is dangerous." It means "I, the programmer, am taking responsibility for correctness here. I have verified this is correct, and the compiler should trust me." In NOVA, all code is memory-safe by default — you cannot accidentally read uninitialized memory, write past array bounds, or use a pointer after freeing it. The unsafe block is the escape hatch for when you truly need raw memory control.
When to use unsafe:
- Calling C functions that take or return raw pointers
- Direct memory manipulation (memcpy, pointer arithmetic)
- Interfacing with hardware or OS-level APIs
- Building custom data structures that require manual memory layout
Raw memory operations
unsafe buf = alloc_raw(1024) // allocate 1024 bytes ptr_write(buf, 42) // write integer at offset 0 slot1 = ptr_add(buf, 8) // ptr_read/ptr_write take no offset — compute the address first ptr_write(slot1, 99) // write at offset 8 print(ptr_read(buf)) // 42 print(ptr_read(slot1)) // 99 free_raw(buf) // must free manually inside unsafe
Pointer arithmetic — working with structured raw memory
Pointer arithmetic means accessing memory at calculated offsets. This is how C arrays work internally. Each NOVA integer is 8 bytes (64-bit), so slots are at offsets 0, 8, 16, 24, ... ptr_read and ptr_write always act on the exact address you pass them — they take no offset argument — so you compute the offset address yourself with ptr_add(base, offset) before reading or writing through it. ptr_diff(p1, p2) returns the byte distance between two pointers derived from the same allocation.
unsafe base = alloc_raw(40) // 40 bytes = 5 × 8-byte integers i = 0 while i < 5 slot = ptr_add(base, i * 8) // offsets: 0, 8, 16, 24, 32 ptr_write(slot, i * 100) i = i + 1 i = 0 while i < 5 slot = ptr_add(base, i * 8) val = ptr_read(slot) print("slot {i} = {val}") // slot 0 = 0, slot 1 = 100, ... i = i + 1 free_raw(base)
slot 1 = 100
slot 2 = 200
slot 3 = 300
slot 4 = 400
Line-by-line breakdown:
alloc_raw(40)— Allocates 40 bytes. Since each integer is 8 bytes, this fits exactly 5 integers. Contents are uninitialized at allocation.ptr_add(base, i * 8)— Computes a new pointeri * 8bytes pastbase.ptr_readandptr_writetake no offset argument — they always act on the exact address passed in — so the offset must be baked into the pointer first viaptr_add.ptr_write(slot, i * 100)— Writes an 8-byte integer at the computed address. When i=0, writes value 0 at offset 0. When i=1, writes value 100 at offset 8. The* 8is manual — unlike NOVA lists, raw memory has no element-size awareness.ptr_read(slot)— Reads the 8-byte integer at the computed address. Output: slot 0 = 0, slot 1 = 100, slot 2 = 200, slot 3 = 300, slot 4 = 400.
DON'T: Write past the end of the allocation — it is a buffer overflow and corrupts memory silently. alloc_raw(10) then ptr_write(ptr_add(buf, 100), 42) is a bug. The compiler cannot protect you inside unsafe — that is YOUR job. Always track sizes carefully.
A worked example of ptr_diff, the inverse of ptr_add — given two pointers into the same allocation, it returns the byte distance between them:
unsafe base = alloc_raw(40) // 40 bytes = 5 x 8-byte integers slot2 = ptr_add(base, 16) // a real pointer to byte offset 16 (the 3rd slot) ptr_write(slot2, 777) print(ptr_read(slot2)) // 777 print(ptr_diff(slot2, base)) // 16 -- byte distance back to the start free_raw(base)
16
DON'T: Call ptr_diff on pointers from two different allocations — the byte distance between unrelated allocations is meaningless and not guaranteed stable, since the allocator is free to place them anywhere.
Typed pointer accessors — byte-precise reads and writes
Plain ptr_read/ptr_write always operate on a full 8-byte NOVA integer. When you are working with a format that is not made of 8-byte-aligned integers — a binary file format, a network protocol, a C struct with mixed-width fields — you need sized variants: ptr_read_u8 / ptr_read_i8 / ptr_read_u16 / ptr_read_i16 / ptr_read_u32 / ptr_read_i32 / ptr_read_u64 / ptr_read_i64 / ptr_read_f32 / ptr_read_f64, and the matching ptr_write_* family. Each reads or writes exactly its named width at the given address — nothing more, nothing less.
unsafe p = alloc_raw(64) ptr_write_u8(p, 0xFF) val8 = ptr_read_u8(p) // 255 -- exactly one byte q = ptr_add(p, 8) ptr_write_f64(q, 3.14) valf = ptr_read_f64(q) // 3.14 print(val8) print(valf) free_raw(p)
3.14
Mixing widths in one buffer is exactly what these are for: ptr_write_u8 at offset 0 followed by ptr_write_f64 at offset 8 packs a 1-byte flag and an 8-byte float into adjacent memory the way a C struct { uint8_t flag; double value; } would — useful when the layout is dictated by a file format or wire protocol you do not control.
DO: Match the accessor width to the field width your format actually specifies — reading a u32 field with ptr_read_u64 pulls 4 bytes of unrelated adjacent memory into the result. DON'T: Assume signed and unsigned variants are interchangeable — ptr_read_i8 on a byte with the high bit set returns a negative number, while ptr_read_u8 on the same byte returns 128-255; picking the wrong one silently corrupts values near the sign boundary.
memcpy_unsafe and memset_unsafe — bulk memory operations
Copying or zeroing memory one ptr_read/ptr_write call at a time is both slow and verbose for anything beyond a handful of values. memcpy_unsafe(dst, src, n) copies n bytes from src to dst in one call, and memset_unsafe(dst, val, n) fills n bytes starting at dst with a single byte value — the same primitives C's memcpy/memset provide, for the same reason: bulk memory operations the runtime can implement far faster than a manual byte-by-byte loop.
unsafe src = alloc_raw(32) dst = alloc_raw(32) memset_unsafe(src, 0, 32) // zero-fill all 32 bytes first ptr_write(src, 123) memcpy_unsafe(dst, src, 32) // bulk-copy all 32 bytes in one call print(ptr_read(dst)) // 123 -- copied along with the rest of the buffer free_raw(src) free_raw(dst)
DO: Use memset_unsafe to zero a freshly allocated buffer before reading it — alloc_raw memory is uninitialized, so reading a slot you have not written is reading garbage. DON'T: Let the source and destination ranges of a memcpy_unsafe overlap — like C's memcpy (as opposed to memmove), behavior on overlapping regions is undefined; use non-overlapping buffers or copy through a temporary.
null_ptr() and is_null(p) — the null pointer sentinel
A function that may or may not produce a valid address — a C API's "not found" return, an allocator that could not satisfy a request — needs a sentinel value distinguishable from every real address. null_ptr() returns that sentinel, and is_null(p) checks whether a given pointer is it — the direct NOVA equivalent of comparing a C pointer against NULL.
unsafe p = null_ptr() if is_null(p) print("p has no address yet") // prints this p = alloc_raw(8) if is_null(p) print("allocation failed") else print("got a real address") // prints this free_raw(p)
got a real address
DO: Check is_null(p) before dereferencing any pointer that came from a C function that can fail — most C APIs signal "no result" with a null pointer rather than an exception. DON'T: Call ptr_read/ptr_write on a pointer without checking it first if the value came from anywhere other than alloc_raw — dereferencing a null pointer is a crash, exactly like C.
cstr_of and str_from_cstr — explicit C string conversion
NOVA strings passed directly as arguments to an extern fn are converted to a null-terminated C char* automatically (Chapter 31) — but that automatic conversion only happens at the call boundary. Two cases fall outside it: building a raw C string yourself to pass through a raw ptr-typed parameter, and converting a char* a C function handed back to you into a real NOVA string you can call len/upper/split on. cstr_of(s) and str_from_cstr(p) are the explicit versions of that same conversion for exactly those cases.
unsafe c = cstr_of("hello") // a raw null-terminated char* NOVA built for you back = str_from_cstr(c) // read it back into a real NOVA string print(back) // hello print(len(back)) // 5 -- a genuine NOVA string, not a raw pointer
5
The round trip above is contrived for illustration — in practice cstr_of shows up when a C function's parameter is typed as a raw ptr rather than string (so the automatic conversion does not apply), and str_from_cstr shows up on the way back from a C function that returns char* as a ptr rather than a string.
DO: Use str_from_cstr immediately on any char* a C function hands back before doing anything else with it — the underlying C memory may be freed or reused by the next C call, but the NOVA string str_from_cstr produces is an independent copy. DON'T: Assume a ptr coming back from C is always a valid C string — str_from_cstr on a non-null-terminated or garbage pointer reads until it happens to find a zero byte, exactly as unsafe as it is in C.
Atomic operations — thread-safe counters without locks
An atomic operation completes without being interrupted by another green task or OS thread. Regular variables can be corrupted when two tasks read/write simultaneously. Atomics prevent this:
counter = atomic_new(0) print(atomic_get(counter)) // 0 atomic_add(counter, 5) print(atomic_get(counter)) // 5 // CAS (Compare-And-Swap) — the foundation of lock-free programming old = atomic_cas(counter, 5, 10) // if counter == 5, change to 10 print(old) // 5 (returns the previous value) print(atomic_get(counter)) // 10
5
1
10
Line-by-line breakdown:
atomic_new(0)— Creates a new atomic integer initialized to0. Unlike regular variables, this can be safely read and written from multiple green tasks simultaneously without data races.atomic_get(counter)— Reads the current value atomically. No other task can see a partially-written value.atomic_add(counter, 5)— Adds 5 atomically. Even if two tasks callatomic_addat the same time, both additions are applied correctly — no "lost update" bug.atomic_cas(counter, 5, 10)— Compare-And-Swap: "If the counter's current value is 5, change it to 10. Return the value that was there." If another task changed the value between your read and CAS, the CAS fails (returns the unexpected old value) and you can retry. This is the building block for all lock-free algorithms.
DO: Use atomics for simple shared counters and flags between processes. DON'T: Try to build complex data structures with atomics — use channels instead (they are safer and usually fast enough). Atomic operations are for the very rare case where channel overhead is unacceptable.
Offheap memory — unmanaged buffers outside the GC
NOVA's ordinary lists and structs are reference-counted and managed automatically. That is the right default, but it has a cost for very large numeric buffers: allocation overhead per object, and RC bookkeeping on every access. offheap_create(size) gives you a raw byte buffer that lives completely outside that system — no RC header, no automatic collection — for the cases where you are managing millions of numbers yourself and want C-array-like performance without leaving NOVA or reaching for raw unsafe pointer arithmetic.
buf = offheap_create(1024) // 1024-byte unmanaged buffer, outside the GC offheap_set(buf, 0, 42) v = offheap_get(buf, 0) // 42 offheap_set_f64(buf, 8, 3.14) f = offheap_get_f64(buf, 8) // 3.14 print(offheap_len(buf)) // 1024 print(v) print(f) offheap_free(buf) // must free manually -- offheap is never collected
42
3.14
Line-by-line breakdown:
offheap_create(1024)— allocates 1024 raw bytes and returns an opaque handle. No NOVA value is created inside the RC heap.offheap_get/offheap_set— read/write a plain integer at a byte offset, bounds-checked at runtime (unlike rawptr_read/ptr_write, which requireunsafeand have no bounds checking at all). This is why offheap operations do not need anunsafeblock: the runtime still checks the offset against the buffer's actual size.offheap_get_f64/offheap_set_f64— typed float access, for packing IEEE-754 doubles at a known offset instead of NOVA's boxed float representation.offheap_free(buf)— releases the buffer. This is the critical difference from a regular NOVA list: nothing frees it for you. Forgetting this call is a genuine memory leak, not something the RC system will ever catch.
DO: Reach for offheap buffers for large, flat numeric arrays where per-element RC overhead would dominate (millions of samples, a big matrix, a columnar data buffer). DON'T: Forget offheap_free — unlike every other NOVA value, offheap memory is not reference-counted and not collected; if the handle goes out of scope without an explicit free, the bytes are gone forever for the life of the process.
Weak references — non-owning handles that don't block collection
NOVA's memory management is reference-counted: an object is freed once nothing holds a strong reference to it. That is usually exactly what you want, but two common patterns break under pure RC: a cache that should not be the reason an entry stays alive, and two objects that reference each other — a reference cycle a pure RC system can never collect on its own, because each object keeps the other's count above zero forever. A weak reference solves both: it lets you hold a handle to an object without counting toward whether that object gets collected.
obj = {"name": "cache_entry", "data": big_payload}
w = weak_create(obj)
// obj is still alive here -- weak_create did not add a strong reference
print(weak_alive(w)) // true
// ... later, once nothing else holds a strong reference to obj,
// the object may be collected. Always check before using the handle:
if weak_alive(w)
recovered = weak_upgrade(w)
print(recovered["name"]) // cache_entry -- still there
else
print("entry was collected")
// Explicitly break the link early, without waiting for collection
weak_invalidate(w)
print(weak_alive(w)) // falsecache_entry
false
Line-by-line breakdown:
weak_create(obj)— returns a weak handle toobj. This does NOT incrementobj's reference count — as far as the RC system is concerned, this handle is invisible.weak_alive(w)— checks whether the referent is still alive, without attempting to produce it. Cheap, safe to call any time.weak_upgrade(w)— returns the actual object if it is still alive, ornullif it has been collected. "Upgrade" is the standard term (matching Rust'sWeak::upgrade) because you are momentarily turning a weak reference into a strong one for as long as you hold the returned value.weak_invalidate(w)— manually severs the weak handle soweak_alivereports false from then on, independent of whether the object itself is still reachable through other strong references. Useful for explicitly tearing down a cache entry.
Compare to Rust and Swift: This is the same tool as Rust's Weak<T> / Rc::downgrade and Swift's weak reference: a handle that observes an object's lifetime without extending it. Both exist for the identical reason — breaking parent/child or observer/subject reference cycles that a plain RC pointer would keep alive forever.
DO: Use weak references for caches (where you want entries evictable under memory pressure) and for the "back-pointer" side of a parent/child relationship (child → parent strong, parent → child weak, breaking the cycle). DON'T: Call weak_upgrade(w) without checking the result — it returns null once the referent is gone, and using that null as if it were the object crashes exactly like any other null-dereference.
When to use unsafe
- Wrapping C FFI functions that take raw pointers
- Implementing high-performance data structures (ring buffers, memory pools)
- Writing binary protocol parsers that need to read specific byte offsets
- Low-level systems programming (OS interaction, device drivers)
Rule: Keep unsafe blocks as small as possible. The idiomatic pattern is a safe NOVA wrapper around a tiny unsafe block that does the raw operation. The wrapper validates inputs; the unsafe block trusts them. You can grep any codebase for unsafe to find every place where safety is suspended — this is auditable by design.
An unsafe block is an expression — its last line is its value
Every unsafe example above uses the block purely for side effects: allocate, write, read into a separate variable, free. But unsafe is a block in the same sense if/else and match are — its last expression is its value, and that value flows out to whatever contains the block, following the same implicit-return rule used throughout NOVA. A function whose job is "do one raw operation and hand back a result" doesn't need a variable declared outside the block just to receive it — the block's tail expression IS the result.
fn double_and_offset(n: int) -> int unsafe a = n * 2 a + 1 // the block's value becomes the function's value fn sum_via_raw_memory(values) -> int unsafe buf = alloc_raw(len(values) * 8) i = 0 while i < len(values) ptr_write(ptr_add(buf, i * 8), values[i]) i += 1 total = 0 j = 0 while j < len(values) total = total + ptr_read(ptr_add(buf, j * 8)) j += 1 free_raw(buf) // free BEFORE the tail expression — we're leaving unsafe after this total // the block's value, and therefore the function's value fn main() print(double_and_offset(5)) print(sum_via_raw_memory([10, 20, 30]))
60
DO: Let the unsafe block's tail expression carry its result out, exactly like a function body — there's no need to declare a variable above the block just to capture something computed inside it. DON'T: Let a raw allocation outlive the block without freeing it first — free everything you alloc_raw'd BEFORE the tail expression executes; once the block's value has flowed out, you're back in safe code with no way to reach that address again.
33. Bytes and binary data
What is this? First-class byte arrays — raw buffers for binary protocols, network packets, image data, file formats. Unlike strings, bytes have no encoding assumption. Every byte holds a number 0–255.
You need bytes when working with: binary file formats (PNG images, ZIP archives, PDF files), network protocols (TCP packets, WebSocket frames, binary APIs), cryptography (hashes, encryption keys, signatures are all byte sequences), and hardware communication (serial ports, USB devices, sensors).
Creating and using bytes
buf = bytes(10) // allocate 10 bytes, all initialized to 0 print(bytes_len(buf)) // 10 print(bytes_get(buf, 0)) // 0 (all start at zero) // Set individual bytes — ASCII codes: A=65, H=72, i=105 bytes_set(buf, 0, 72) // 'H' bytes_set(buf, 1, 101) // 'e' bytes_set(buf, 2, 108) // 'l' bytes_set(buf, 3, 108) // 'l' bytes_set(buf, 4, 111) // 'o' print(bytes_get(buf, 0)) // 72 print(bytes_get(buf, 4)) // 111
0
72
111
Line-by-line breakdown:
bytes(10)— Creates a byte buffer with 10 slots, all set to0. Think of it as an array of 10 slots, each holding a number from 0 to 255.bytes_set(buf, 0, 72)— Sets byte at index 0 to72. In ASCII encoding,72is the character 'H'. Each character has a numeric code: A=65, B=66, ... H=72, i=105, o=111, etc.bytes_get(buf, 4)— Reads byte at index 4. Returns111(ASCII 'o').
Converting between bytes and strings
// String to bytes — gives raw binary representation data = str_to_bytes("NOVA") print(bytes_len(data)) // 4 print(bytes_get(data, 0)) // 78 (ASCII 'N') print(bytes_get(data, 1)) // 79 (ASCII 'O') // Bytes to string — interpret raw bytes as UTF-8 text text = bytes_to_str(bytes_slice(buf, 0, 5)) print(text) // "Hello"
Line-by-line breakdown:
str_to_bytes("NOVA")— Converts the string "NOVA" into its raw byte representation. Each character becomes one byte in the order N=78, O=79, V=86, A=65.bytes_get(data, 0)— The first byte is 78, which is the ASCII code for 'N'.bytes_to_str(bytes_slice(buf, 0, 5))— Extracts bytes at positions 0–4 (which we set to 'H', 'e', 'l', 'l', 'o') and interprets them as UTF-8 text. Gives back the string "Hello".
Overflow wrapping — bytes always stay in range 0–255
b = bytes(1) bytes_set(b, 0, 256) print(bytes_get(b, 0)) // 0 (256 % 256 = 0 — wraps around) bytes_set(b, 0, 300) print(bytes_get(b, 0)) // 44 (300 % 256 = 44)
44
This is modular arithmetic — the same as how odometers wrap at 100,000 back to 0. Not an error. This mirrors how CPU byte registers work.
Binary protocol — building a length-prefixed network message
Many network protocols use "length-prefixed" messages: the first 4 bytes encode the message length, then the message payload follows. This lets the receiver know exactly how many bytes to read:
fn encode_message(msg) data = str_to_bytes(msg) length = bytes_len(data) header = bytes(4) bytes_set(header, 0, length % 256) // byte 0: least significant bytes_set(header, 1, (length / 256) % 256) // byte 1 bytes_set(header, 2, (length / 65536) % 256) // byte 2 bytes_set(header, 3, (length / 16777216)) // byte 3: most significant bytes_concat(header, data) // join header + payload
Line-by-line breakdown:
data = str_to_bytes(msg)— Converts the message string to raw bytes.length = bytes_len(data)— Gets the number of bytes in the message.header = bytes(4)— Creates a 4-byte header. 4 bytes = 32 bits, can represent lengths up to ~4 billion (2^32).bytes_set(header, 0, length % 256)— Stores the least significant byte of the length. This is little-endian encoding: smallest part first.bytes_concat(header, data)— Joins the 4-byte header and the message payload into one buffer. Result:[len_byte0, len_byte1, len_byte2, len_byte3, payload...]
DO: Use bytes for binary data (files, network, cryptography). DON'T: Use strings to hold binary data — strings are for UTF-8 text. Binary data can contain byte value 0, which is the string terminator in C and will truncate your data.
A higher-level alternative: The length-prefixed framing built by hand above is exactly the kind of boilerplate term_encode(value) / term_decode(data) remove when both ends of the connection are NOVA — see "Binary term encoding" in Chapter 35 (Distributed computing) for the full API. Keep hand-rolled framing like the example above when the wire format itself is fixed by something outside your control (a file format, a hardware protocol, another language's binary API); reach for term_encode/term_decode when you're free to choose the wire format yourself and just need to move NOVA values between NOVA processes.
Hex literals — the natural way to write byte values
Byte values are often written in hexadecimal because two hex digits map exactly to one byte (00–FF). NOVA integer literals accept a 0x prefix anywhere an integer is expected, including as the value argument to bytes_set:
b = bytes(4) bytes_set(b, 0, 0x48) // 'H' — same value as decimal 72 bytes_set(b, 1, 0x69) // 'i' — same value as decimal 105 print(bytes_get(b, 0)) // 72 (decimal for 0x48) // Round-trip through a string and back — same functions as before sub = bytes_slice(b, 0, 2) // first 2 bytes: 'H', 'i' text = bytes_to_str(sub) // "Hi" back = str_to_bytes(text) // bytes are equal to sub again
DON'T: Guess at function names like bytes_new, bytes_to_string, or string_to_bytes — they don't exist and will fail to compile. DO: Use the canonical names: bytes(n) to allocate, bytes_to_str / str_to_bytes to convert. If a name you expect doesn't compile, check Appendix D or grep the stdlib rather than guessing a plausible-sounding variant.
Reading a binary protocol (example: PNG header)
// PNG files start with an 8-byte magic number PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10] fn is_png(data) if bytes_len(data) < 8: return false for i in 0..8 // exclusive range: checks i = 0..7, all 8 magic bytes if bytes_get(data, i) != PNG_MAGIC[i]: return false true
34. AI and tensors
What is this? Built-in multi-dimensional arrays for machine learning. No pip install, no 100MB library download. Build and run neural networks directly in NOVA.
What are tensors?
A tensor is a multi-dimensional array of numbers. Every neural network — from image classifiers to language models — is built by multiplying, adding, and transforming tensors.
| Dimensions | Name | Example | Shape |
|---|---|---|---|
| 0 | Scalar | 42.0 | [] |
| 1 | Vector | [1.0, 2.0, 3.0] | [3] |
| 2 | Matrix | A 2×3 grid of numbers | [2, 3] |
| 3 | 3D Tensor | A color image: height × width × RGB channels | [224, 224, 3] |
Creating tensors
t = tensor_from_list([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]) print(tensor_shape(t)) // [2, 3] print(tensor_rank(t)) // 2 print(tensor_size(t)) // 6 z = tensor_zeros([3, 3]) print(tensor_get(t, [0, 0])) // 1.0 print(tensor_get(t, [1, 2])) // 6.0
Line-by-line: tensor_from_list([...], [2, 3]) — first arg is flat data (6 numbers), second is shape (2 rows, 3 columns). Data fills row by row: row 0 = [1,2,3], row 1 = [4,5,6]. tensor_rank = number of dimensions. tensor_size = total elements (2×3=6). tensor_get(t, [1, 2]) = element at row 1, column 2 = 6.0.
Writing into a tensor — tensor_set
tensor_get reads a single value out of a tensor at a multi-dimensional index; tensor_set(t, indices, value) is its mutation counterpart — it writes a value directly into the tensor's existing storage instead of building a new tensor. This matters because every tensor math builtin (tensor_add, tensor_matmul, tensor_scale, ...) is pure — it allocates and returns a brand-new result tensor, leaving its inputs untouched. There's no way to build a tensor with a specific, hand-picked pattern of values — an identity matrix, a one-hot vector, a manually patched weight — purely from those pure ops. tensor_set is the escape hatch: start from tensor_zeros and poke in exactly the values you need, one index at a time.
ident = tensor_zeros([3, 3]) for i in 0..3 tensor_set(ident, [i, i], 1.0) // mutates ident in place — no return value to assign print(tensor_get(ident, [0, 0])) // 1.0 print(tensor_get(ident, [0, 1])) // 0.0 print(tensor_get(ident, [2, 2])) // 1.0
DO: Pass an index list whose length matches the tensor's rank — [i, i] for a 2D tensor, three indices for a 3D tensor. Getting the arity wrong is a runtime error, not a silent no-op. DON'T: Expect tensor_set to return a new tensor the way tensor_add or tensor_scale do — it mutates ident directly and its return value isn't meant to be used; write tensor_set(t, idx, v) as a bare statement, not t = tensor_set(t, idx, v).
Element-wise arithmetic
a = tensor_from_list([1.0, 2.0, 3.0, 4.0], [2, 2]) b = tensor_from_list([5.0, 6.0, 7.0, 8.0], [2, 2]) c = tensor_add(a, b) // [[6, 8], [10, 12]] d = tensor_sub(a, b) // [[-4, -4], [-4, -4]] e = tensor_mul(a, b) // [[5, 12], [21, 32]] — element-wise (NOT matmul) f = tensor_scale(a, 2.0) // [[2, 4], [6, 8]] — multiply every element by 2
tensor_mul is element-wise (1×5=5, 2×6=12). For actual matrix multiplication, use tensor_matmul.
DON'T: Reach for tensor_mul when you mean matrix multiplication — it silently produces a same-shape result (element-wise product), not an error, so this bug does not crash, it just gives mathematically wrong output that can be hard to notice until predictions look wrong. DO: Use tensor_matmul for layer transforms (x @ W) and reserve tensor_mul for masking, gating, or elementwise scaling where both tensors already share the same shape.
Element-wise division — tensor_div
tensor_div(a, b) completes the element-wise arithmetic family alongside tensor_add/tensor_sub/tensor_mul: it divides a by b position-by-position, and like its siblings it requires both tensors to have the same shape — there's no broadcasting. It shows up most often when normalizing data (dividing by a per-feature standard-deviation tensor) or computing per-element ratios, such as scaling a gradient tensor by a per-parameter learning-rate tensor.
a = tensor_from_list([10.0, 20.0, 30.0, 40.0], [2, 2]) b = tensor_from_list([2.0, 5.0, 3.0, 8.0], [2, 2]) q = tensor_div(a, b) // [[5, 4], [10, 5]] — element-wise a / b print(tensor_get(q, [0, 0])) // 5.0 (10 / 2) print(tensor_get(q, [1, 1])) // 5.0 (40 / 8)
DO: Make sure the divisor tensor's shape exactly matches the dividend's — like tensor_add/tensor_sub/tensor_mul, tensor_div is purely element-wise with no broadcasting, so a [2, 2] divided by a [2, 1] is a shape-mismatch error, not an automatic per-row divide. DON'T: Divide by a tensor that might contain a zero element — tensor_div performs ordinary floating-point division, so a zero divisor produces inf or nan in that position rather than raising an error; guard for zeros yourself (e.g. via tensor_get) if the divisor isn't guaranteed non-zero.
Matrix multiplication — the core AI operation
Matrix multiplication is how neural networks transform inputs into outputs:
a = tensor_from_list([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]) b = tensor_from_list([7.0, 8.0, 9.0, 10.0, 11.0, 12.0], [3, 2]) c = tensor_matmul(a, b) print(tensor_shape(c)) // [2, 2] // Result: [[58, 64], [139, 154]] // Result[0,0] = 1*7 + 2*9 + 3*11 = 7+18+33 = 58
a is 2×3, b is 3×2. Inner dimensions must match (both 3). Result is 2×2. Each output element is a dot product: multiply corresponding elements and sum.
Activation functions
Activation functions introduce non-linearity — without them, any number of matrix multiplications would just be one big linear transformation:
x = tensor_from_list([-1.0, 0.0, 1.0, 2.0], [4]) // print() on a tensor shows its internal representation, not its values — // convert with tensor_to_list() first to see the numbers themselves. print(tensor_to_list(tensor_relu(x))) // [0.0, 0.0, 1.0, 2.0] — max(0, x) print(tensor_to_list(tensor_sigmoid(x))) // [0.269, 0.5, 0.731, 0.881] print(tensor_to_list(tensor_tanh(x))) // [-0.762, 0.0, 0.762, 0.964] print(tensor_to_list(tensor_softmax(x))) // probabilities summing to 1.0 print(tensor_argmax(x)) // 3 (index of max value 2.0) t = tensor_from_list([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]) t2 = tensor_reshape(t, [3, 2]) t3 = tensor_transpose(t)
tensor_relu— ReLU: replaces negatives with 0. Most common activation in modern networks.tensor_sigmoid— Squashes into (0,1). Used for binary classification.tensor_softmax— Converts raw scores into probabilities that sum to 1.0. Used for multi-class classification.tensor_argmax— Returns the INDEX of the largest element.2.0is at index 3.tensor_reshape— Changes shape without changing data. 2×3 → 3×2.tensor_transpose— Flips rows and columns. 2×3 → 3×2 where column i becomes row i.
More element-wise math — tensor_exp and tensor_log
Alongside the named activations above, NOVA exposes the two element-wise math primitives most of them are built from: tensor_exp(t) (natural exponential, e^x at every position) and tensor_log(t) (natural logarithm). You reach for these directly when hand-rolling a numeric routine that isn't already one of the named activations — a custom loss function, a softmax with temperature scaling, or a log-probability computation for cross-entropy.
x = tensor_from_list([0.0, 1.0, 2.0], [3]) print(tensor_exp(x)) // [1.0, 2.718, 7.389] — e^0, e^1, e^2 p = tensor_from_list([1.0, 2.718, 7.389], [3]) print(tensor_log(p)) // [0.0, 1.0, 2.0] — tensor_log undoes tensor_exp
DO: Reach for tensor_exp/tensor_log when composing a custom numeric formula — a hand-written softmax, cross-entropy loss, or log-sum-exp — that the named activations don't already cover. DON'T: Call tensor_log on a tensor that can contain zero or negative values — like ordinary floating-point log, it produces -inf or nan rather than raising an error, so clamp or validate inputs first (values coming out of tensor_softmax are always positive, so tensor_log is safe there).
Getting values back out — tensor_to_list
Tensors are opaque handles, not NOVA lists — you can't loop over one with a plain for x in t or pass it straight to list builtins like sort or map. tensor_to_list(t) is the documented way to escape a tensor back into ordinary NOVA data: it flattens the tensor — row-major, the same order tensor_from_list reads its input in — into a plain list of floats you can iterate, index, serialize to JSON, or hand to any non-tensor builtin.
m = tensor_from_list([1.0, 2.0, 3.0, 4.0], [2, 2]) vals = tensor_to_list(m) print(vals) // [1.0, 2.0, 3.0, 4.0] — flattened, row-major print(sum(vals)) // 10.0 — now a plain list, so ordinary list builtins apply
DO: Call tensor_to_list whenever you need to hand tensor data to something outside the tensor API — printing a full result, serializing a prediction to JSON, or feeding values into a plain-NOVA algorithm. DON'T: Assume the returned list remembers the tensor's shape — it's flat. If you need the shape later, capture tensor_shape(t) yourself before flattening, or reshape the tensor first with tensor_reshape if you need a different flat layout.
Adding a bias vector — tensor_add_bias
A neural-network layer computes x @ W + b — the bias b is a single vector shaped like one row of the output, added to every row of a batch. Plain tensor_add(a, b) can't do this in general: it's strict element-wise addition and requires both operands to already have the identical shape, so adding a [1, 4] bias to a [32, 4] batch of hidden activations fails outright — tensor_add does not broadcast the smaller tensor across the batch dimension. tensor_add_bias(t, bias) exists specifically for this: it takes a batch tensor t shaped [batch, features] and a bias shaped [1, features] and adds the bias to every row.
// batch of 2 examples, 3 features each — bias is a single row of 3 h = tensor_from_list([1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]) bias = tensor_from_list([0.1, 0.2, 0.3], [1, 3]) out = tensor_add_bias(h, bias) print(tensor_to_list(out)) // [1.1, 2.2, 3.3, 4.1, 5.2, 6.3] — bias added to BOTH rows
DO: Use tensor_add_bias for every real layer bias — it's the only element-wise-add builtin that broadcasts a single row across a batch, so it stays correct as your batch size changes. DON'T: Rely on plain tensor_add for a batch-size-1 example and assume it will keep working when the batch grows — it only "works" at batch size 1 because a [1, n] activation and a [1, n] bias already have matching shapes; the moment the batch tensor becomes [32, n], tensor_add shape-mismatches while tensor_add_bias keeps working unchanged.
Neural network forward pass
// Simple 2-layer neural network forward pass fn forward(x, w1, b1, w2, b2) // Layer 1: x @ w1 + b1, then ReLU h = tensor_relu(tensor_add(tensor_matmul(x, w1), b1)) // Layer 2: h @ w2 + b2, then softmax tensor_softmax(tensor_add(tensor_matmul(h, w2), b2)) // Input: batch of 2 examples, 3 features each x = tensor_from_list([0.1, 0.5, 0.9, 0.2, 0.7, 0.3], [2, 3]) // there is no tensor_ones — build an all-ones tensor from an explicit flat list instead w1 = tensor_from_list([1.0 for _ in 0..12], [3, 4]) // 3 inputs → 4 hidden (12 = 3×4 ones) b1 = tensor_zeros([4]) w2 = tensor_from_list([1.0 for _ in 0..8], [4, 2]) // 4 hidden → 2 outputs (8 = 4×2 ones) b2 = tensor_zeros([2]) out = forward(x, w1, b1, w2, b2) print(tensor_shape(out)) // [2, 2] — 2 examples × 2 class probabilities
DON'T: Call tensor_ones(shape) — there is no all-ones constructor in the tensor API, only tensor_zeros(shape). DO: Build one from a flat list when you need it: tensor_from_list([1.0 for _ in 0..n], shape), where n is the product of shape's dimensions.
Tensor operations reference
| Function | What it does |
|---|---|
tensor_add(a, b) | Element-wise addition |
tensor_sub(a, b) | Element-wise subtraction |
tensor_mul(a, b) | Element-wise multiplication |
tensor_div(a, b) | Element-wise division |
tensor_scale(t, s) | Multiply every element by scalar |
tensor_matmul(a, b) | Matrix multiplication (inner dims must match) |
tensor_relu(t) | max(0, x) — replaces negatives with 0 |
tensor_sigmoid(t) | 1/(1+e^-x) — squashes to (0, 1) |
tensor_tanh(t) | Hyperbolic tangent — squashes to (-1, 1) |
tensor_softmax(t) | Normalize to probability distribution |
tensor_sum(t) | Sum all elements |
tensor_sum(t) / float(tensor_size(t)) | Mean of all elements — there is no dedicated tensor_mean, compose it from tensor_sum and tensor_size |
tensor_argmax(t) | Index of the largest element |
tensor_transpose(t) | Swap rows and columns (2D) |
tensor_reshape(t, shape) | Change shape without changing data |
tensor_shape(t) | Returns list of dimension sizes |
tensor_rank(t) | Number of dimensions |
tensor_size(t) | Total element count |
tensor_get(t, idx) | Get element at multi-dimensional index |
tensor_set(t, idx, v) | Set element at multi-dimensional index (mutates in place, no return value used) |
tensor_exp(t) | Element-wise natural exponential, e^x |
tensor_log(t) | Element-wise natural logarithm |
tensor_add_bias(t, bias) | Add a bias row to every row of a batch (broadcasts, unlike tensor_add) |
tensor_to_list(t) | Flatten a tensor into a plain NOVA list (row-major) |
35. Distributed computing
What is this? Distributed computing means running your program across multiple machines connected by a network. Different parts of your system run on different servers and communicate over the network. Common examples: a web app with separate database/API/cache servers; a data pipeline that splits work across a cluster; a chat system routing messages between data centers.
Why NOVA's approach is special
In most languages, local code and distributed code look completely different. You write result = compute(data) locally but response = http.post(url, serialize(data)) for remote calls. In NOVA, channels work across the network with the same API. The mental model: "send a value, the receiver gets an independent copy" — whether tasks are in the same process, on different cores, or on different machines.
The server side — remote_listen, remote_bind, and remote_accept
fn run_server() conn = remote_listen(9000) msg = remote_recv(conn) print("Server received: {msg}") remote_send(conn, {"reply": "got it"}) remote_close(conn)
Line-by-line breakdown:
remote_listen(9000)— Binds port 9000 on all network interfaces (NOVA always listens on every interface — there is no host argument) and parks the green task until one client connects, then returns the connected channel directly. Bind and accept happen in this single call; good for a single point-to-point connection.- To serve more than one client, use
remote_bind(port)instead: it returns a listener you keep open, then callremote_accept(listener)in a loop — each call parks until the next client connects and returns a fresh connection handle for that client (see the worker example below). remote_recv(conn)— Receives a value from the remote client. Blocks until the client sends something. The received value is deserialized from JSON automatically — you get a NOVA dict, list, string, or number, not raw bytes.remote_send(conn, {"reply": "got it"})— Sends a value back to the client. The dict is automatically serialized to JSON, sent over TCP, and deserialized on the other end.remote_close(conn)— Closes the connection and frees resources.
The client side — remote_connect
fn run_client() conn = remote_connect("127.0.0.1", 9000) remote_send(conn, {"op": "hello", "data": "world"}) reply = remote_recv(conn) print("Client got: {reply}") remote_close(conn)
Line-by-line breakdown:
remote_connect("127.0.0.1", 9000)— Connects to a server at IP address127.0.0.1(localhost — the same machine) on port 9000. In production, this would be a real IP like"192.168.1.100"or a hostname like"api.example.com".remote_send(conn, {"op": "hello", ...})— Sends a dict to the server. The data is serialized to JSON and transmitted over TCP.remote_recv(conn)— Waits for the server's response. Returns the deserialized value.
How remote channels work under the hood
Remote channels use TCP with length-prefixed JSON serialization:
- When you call
remote_send(conn, value), NOVA serializesvalueto a JSON string - It prefixes the JSON with its byte length (4 bytes, little-endian)
- It sends both over the TCP connection
- On the receiving end,
remote_recvreads the 4-byte length, reads exactly that many bytes, then deserializes the JSON
This is the same deep-copy semantics as local channels — the receiver always gets an independent copy, never a shared reference. No data races, no shared mutable state.
DO: Use remote channels for building distributed systems — same safety as local channels. DON'T: Send extremely large values (100MB+) over remote channels. The entire value is serialized to JSON in memory first. For large data, break it into smaller chunks.
Practical distributed example
Server (receives jobs, sends results)
// worker_server.nova — receives computation jobs fn main() listener = remote_bind(9000) print("Worker listening on :9000") loop conn = remote_accept(listener) spawn job = remote_recv(conn) print("Computing: {job}") result = job["n"] * job["n"] // square the number remote_send(conn, {"result": result}) remote_close(conn)
Client (sends jobs, receives results)
// main.nova — sends jobs to worker fn remote_square(host, n) conn = remote_connect(host, 9000) remote_send(conn, {"n": n}) reply = remote_recv(conn) remote_close(conn) reply["result"] fn main() print(remote_square("127.0.0.1", 7)) // 49 print(remote_square("worker2.lan", 12)) // 144 (different machine!)
Why this matters: Most languages have completely different APIs for local vs. distributed work. NOVA uses send/recv everywhere. Moving from local to distributed is replacing channel() with remote_connect() — the rest of your code stays the same.
Distributed fan-out — work across multiple machines
The local fan-out pattern (spawn N tasks, collect N results) scales directly to distributed computing. Instead of spawning local tasks, connect to N remote workers:
fn distributed_map(workers, items, handler_name) result_ch = channel() num_items = len(items) for i in 0..num_items // exclusive range — this alone visits i = 0..num_items-1, all N items worker_host = workers[i % len(workers)] // round-robin item = items[i] spawn fn() conn = remote_connect(worker_host, 9000) remote_send(conn, {"op": handler_name, "data": item}) result = remote_recv(conn) remote_close(conn) send(result_ch, result) results = [] for _ in 0..num_items push(results, recv(result_ch)) results // Run: distribute image processing across 3 GPU servers workers = ["gpu1.cluster", "gpu2.cluster", "gpu3.cluster"] image_urls = ["/img/photo1.jpg", "/img/photo2.jpg", "/img/photo3.jpg"] results = distributed_map(workers, image_urls, "process_image") print("Processed {len(results)} images across {len(workers)} machines")
Line-by-line:
workers[i % len(workers)]— Round-robin assignment: item 0 goes to worker 0, item 1 goes to worker 1, item 3 goes back to worker 0. Distributes work evenly.- Each
spawn fn()handles one item on one worker. All spawn concurrently — if you have 100 items and 10 workers, 100 green tasks run concurrently, each waiting for its remote worker. The slowest worker determines the total time, not the sum of all workers. for _ in 0..num_items: push(results, recv(result_ch))— Collects all results.0..num_itemsis deliberate, not0..num_items - 1— NOVA's..is exclusive of its right bound, so0..num_itemsis exactlynum_itemsiterations, matching thenum_itemssends above one-for-one. Getting this off by one is the classic channel bug: one call too few silently drops the last result, one too many parks a green task forever waiting on a send that never comes (see the DO/DON'T box in §18).
Distributed vs local vs no-op — the same code scales
| Pattern | Code | When to use |
|---|---|---|
| Local (no concurrency) | results = map(items, compute) | Small lists, fast operations |
| Local concurrent | results = pmap(items, compute) | CPU-bound work, single machine |
| Local green tasks | spawn fn() send(ch, compute(item)) | I/O-bound, fine-grained control |
| Distributed | remote_send(conn, item); remote_recv(conn) | More CPU than one machine, large data |
The channel-based mental model is the same for all four. Start with map(), switch to pmap() for speed, switch to remote channels to scale beyond one machine — the logic stays identical, only the transport changes.
Distributed computing compared to other languages
| Language/framework | Distributed model | Different from local code? |
|---|---|---|
| NOVA | Remote channels — same send/recv API | No — only the connect call changes |
| Python (Ray) | @ray.remote decorator + .remote() calls | Yes — requires annotations and different syntax |
| Go (gRPC) | Protobuf schema + generated stubs + HTTP/2 | Yes — entirely different API from local functions |
| Java (Akka) | Actor refs + message classes + serialization | Yes — significant infrastructure |
| Erlang/OTP | PID-based messaging, same send API | No — same ! send whether local or remote |
NOVA follows Erlang's approach: the same message-passing model works locally and remotely. The key difference from Erlang: NOVA uses JSON serialization over TCP (interoperable with any language) rather than Erlang's binary term format.
Binary term encoding — term_encode and term_decode
Remote channels default to length-prefixed JSON specifically for interoperability — any language with a JSON parser can talk to a NOVA node, which is why remote_send/remote_recv are the right default whenever the other end of the wire might not be NOVA. But NOVA also ships a separate, lower-level binary wire format modeled on Erlang's External Term Format: term_encode(value) -> bytes and term_decode(data) -> value. Reach for it when you control both ends of the connection and want a denser, faster-to-parse representation than JSON text — no quoting or escaping, no number-to-string-to-number round trip, and direct support for the same list/dict/nested shapes NOVA already handles natively.
// Encode a NOVA value into a compact binary format — an alternative to JSON for wire transfer msg = {"cmd": "ping", "ts": datetime_timestamp()} wire = term_encode(msg) // Send the raw bytes over a TCP connection — NOT remote_send/remote_recv, which speak JSON only tcp_send_bytes(conn, wire) // --- on the other side of the connection --- data = tcp_recv_bytes(conn) decoded = term_decode(data) print(decoded["cmd"]) // "ping" print(decoded["cmd"] == msg["cmd"]) // true — the round trip is lossless
true
Unlike remote_send/remote_recv, which handle length-prefix framing for you internally, term_encode/term_decode are pure data-transformation functions — they turn a NOVA value into bytes and back, nothing more. If you send the result over a raw TCP stream (as above), you are responsible for your own message framing, exactly like the hand-rolled length-prefix example in Chapter 33 — TCP is a byte stream with no built-in concept of "one message," binary or JSON.
DO: Reach for term_encode/term_decode for high-throughput traffic between NOVA nodes you control, where wire-format interoperability with other languages doesn't matter. DON'T: Use it to talk to a non-NOVA service, a browser, or anything expecting JSON/REST — the binary format is NOVA-specific, and remote_send/remote_recv's JSON framing remains the right default whenever the other side isn't guaranteed to be NOVA.
Remote procedure calls — remote_spawn and call_by_name
remote_send/remote_recv (above) are manual message passing: both sides have to already agree on what a given message MEANS and write the matching code to handle it. remote_spawn(conn, fn_name, args) is a level up — genuine RPC. It asks whatever peer is on the other end of conn to run one of ITS OWN named top-level functions, by string name, with a list of arguments, and get the result back. On the wire it's still nothing more exotic than a [fn_name, args] message; all the magic lives on the receiving side, where call_by_name(fn_name, args) looks the name up in the compiled program's own function registry — built at compile time from every top-level fn in the binary — and invokes it with args deep-copied in, exactly like any other cross-task call. This is also a real security boundary, not just an implementation detail: a peer can only ever invoke a function that is ALREADY compiled into the target's own binary — remote_spawn has no way to ship new code over the wire, only to name-dispatch existing code.
fn add(a, b) a + b fn requester(port) conn = remote_connect("127.0.0.1", port) remote_spawn(conn, "add", [15, 27]) // ask the peer to run its own add(15, 27) reply = remote_recv(conn) print(reply) // 42 remote_close(conn) fn main() port = 19933 spawn requester(port) listener = remote_bind(port) conn = remote_accept(listener) req = remote_recv(conn) // [fn_name, args] == ["add", [15, 27]] result = call_by_name(req[0], req[1]) // resolves "add" and calls add(15, 27) remote_send(conn, result) remote_close(conn)
DO: Use remote_spawn/call_by_name when the SAME NOVA binary runs on every node in a homogeneous cluster and you want to dispatch work by function name instead of hand-writing a message-type switch for every operation. DON'T: Expose a call_by_name-driven endpoint to an untrusted network without your own allowlist — every top-level function name compiled into the binary is technically reachable by anyone who can open a connection; production code should validate req[0] against a known set of RPC-safe names before passing it to call_by_name.
36. Performance guide
What is this? NOVA's promise is C-level performance — within 1–5% of hand-written C compiled with clang -O2. That means 50–100× faster than Python while remaining simpler to write. But a few patterns can silently make your code 150× slower. Understanding WHY they matter lets you write fast code by default.
How NOVA compiles your code
NOVA uses a 4-stage compilation pipeline. Each stage's output feeds the next:
- NOVA source — your
.novafiles - LLVM IR — low-level intermediate representation. NOVA's compiler generates this.
- Clang
-O2— LLVM's optimizer turns IR into optimized machine code (constant folding, instruction selection, register allocation) - Native executable — runs directly on your CPU at full speed
Any code pattern that produces the same LLVM IR as equivalent C achieves the same performance. The NOVA compiler's job is to generate IR that matches what a C compiler would produce.
Here is proof: this NOVA function and the C function below produce byte-for-byte identical machine code:
// NOVA — 1..n+1 because .. is exclusive of its right bound; this visits i = 1..n, matching C's i <= n fn sum_squares(n) total = 0 for i in 1..n + 1 total = total + i * i total
/* C — NOVA generates the same machine code */ int64_t sum_squares(int64_t n) { int64_t total = 0; for (int64_t i = 1; i <= n; i++) total += i * i; return total; }
Why this is fast: The compiler sees that total and i are integers. It generates native CPU add and mul instructions. No boxing, no dynamic dispatch, no overhead. Zero-cost abstraction: you write zero type annotations, the compiler generates C-identical code.
DON'T: Translate a C for (i = 1; i <= n; i++) to NOVA's for i in 1..n — that drops the last iteration, since NOVA's .. is exclusive of its right bound (1..n visits 1..n-1). DO: Write 1..n + 1 (or restate the loop as 1..n and consciously mean "up to but not including n") — the + 1 is not a stylistic quirk, it's what makes the bound match a C <= loop exactly.
Why lowercase type names on struct fields matter
// FAST: lowercase → native fmul/fadd — identical to C type Vec3 x: float // 64-bit float, stored directly in struct y: float z: float // SLOW: capital → dynamic "any" type — 150× SLOWER type Vec3Bad x: Float // heap-allocated pointer to dynamic value — DON'T y: Float z: Float
Why this matters: Lowercase float tells the compiler the field is always a 64-bit IEEE 754 double. It generates three fmul and two fadd instructions for a dot product — exactly what C produces. Capital Float makes the field a dynamic any type: a heap-allocated pointer to a tagged value. Every arithmetic operation must first check "is this a float? an int? a string?" at runtime. This tag check + branch + pointer dereference happens on EVERY arithmetic operation, turning 5 fast instructions into hundreds. Result: 150× slower.
Rule #1: ALWAYS use lowercase type names in struct fields: x: float not x: Float, n: int not n: Int, s: string not s: String. This is the single most impactful performance rule in NOVA. A single capital letter can destroy performance.
Measured performance benchmarks
| Benchmark | NOVA vs C (clang -O2) | Notes |
|---|---|---|
| Scalar integer math | ~1.0× (identical) | Compiles to same instructions |
| Struct field math (lowercase types) | 1.04× (within 4%) | Near-native |
| Struct passed to function | 1.21–1.32× | ABI overhead — future optimization |
| Sieve of Eratosthenes | ~1.0× | List access speed |
| Green task spawn (10k tasks) | ~100ms total, ~10µs each | Heap-allocated fiber |
| Struct field with capital Float | 150× SLOWER than C | Dynamic dispatch — DON'T DO THIS |
The fast patterns
// FAST: lowercase types on struct fields type Vec x: float // native CPU register y: float // native CPU register // FAST: join instead of concat in loop parts = [] for i in 0..1000 // exclusive range — 1000 items, i = 0..999 push(parts, str(i)) result = join(parts, ",") // O(n) // FAST: iterators avoid intermediate lists total = iter_reduce( iter_filter(iter_range(1, 1000001), x => x % 2 == 0), 0, (acc, x) => acc + x )
The slow patterns (and how to fix them)
| Slow pattern | Why | Fast fix |
|---|---|---|
x: Float on struct field | Dynamic dispatch, heap pointer dereference per op | Use x: float (lowercase) — 150× speedup |
result = result + piece in loop | O(n²) — copies entire string every iteration | push(parts, piece); join(parts, "") — O(n) |
| Sending large dicts/lists over channels in a tight loop | Deep copy on every send | Compute locally, send only the small result |
| Recursion with depth >10k | Stack overflow (32KB fiber stack) | Use a while loop with explicit stack list |
map() then filter() | Two intermediate lists allocated | iter_map + iter_filter + iter_collect — zero intermediate lists |
| Sleep in a hot loop for polling | Unnecessary latency + wasted CPU | Use channels — recv parks the task, zero CPU until data |
String building — the O(n²) trap explained
// SLOW: O(n²) — copies entire result string on EVERY iteration result = "" for item in large_list result = result + item + ", " // Iteration 1: copies 0 chars // Iteration 100: copies ~500 chars // Iteration 1000: copies ~5000 chars // Total: 0 + 5 + 10 + ... + 5000 = O(n²) // FAST: O(n) — build a list, then join once parts = [] for item in large_list push(parts, item) result = join(parts, ", ") // join: calculates total length ONCE, allocates ONCE, copies each part ONCE // ALSO FAST: buffer approach buf = buffer_create() for item in large_list buf_append(buf, item) buf_append(buf, ", ") result = buf_to_str(buf) // buffer pre-allocates, grows geometrically — amortized O(1) per append
For 1,000 items, the slow version performs ~500,000 character copies. The fast version performs ~5,000. For 10,000 items: 50M vs. 50K — a 1,000× difference.
Large values over channels
// SLOW: deep-copying large_data (a 10,000-entry dict) on every iteration for i in 0..10000 send(ch, large_data) // copies all 10,000 entries × 10,000 iterations // FAST: compute locally, send only the small result for i in 0..10000 result = compute(large_data, i) send(ch, result) // sends a small number, not the entire dataset
Channel sends always deep-copy. Keep the large data local and send only the small result. If multiple tasks need access to the same large read-only dataset, pass it once at task creation (it's copied once when spawning).
The arena advantage — why Forge has zero GC pauses
Forge uses a per-request arena allocator. All memory allocated during a request (strings, dicts, structs built in the handler) is freed in one atomic operation when the request ends:
- No GC pauses — Java, Go, and Python periodically pause your server to find and free garbage. NOVA never does this. Each request's memory is a watermark that resets to zero at request end.
- No per-allocation overhead — malloc must scan free lists, update bookkeeping, potentially lock. Arena allocation is a pointer bump — one addition instruction.
- Zero memory growth — long-running servers hold flat memory because every request starts with an empty arena. No accumulation.
The tradeoff: arena memory lasts only for the request. For data that needs to outlive a request (caches, session stores), use RC (reference-counted) heap allocation.
See also: this per-request behavior isn't Forge-specific magic — it's the same arena_enter()/arena_exit() bracket (and the arena_create() handle form for more manual control) documented under "Arena Allocator" in the Collections chapter. Forge simply calls arena_enter() for you at the start of every request and arena_exit() at the end; you can use the exact same primitive directly in your own batch-processing code for the identical zero-GC-pause win.
Green tasks are extremely cheap
NOVA's concurrency primitive is the green task — a cooperatively-scheduled coroutine with a ~32KB stack:
// Spawning 10,000 green tasks costs roughly 10ms total — ~1µs per task for i in 0..10000 spawn fn() sleep(100) // park the task, not the OS thread // Compare: 10,000 OS threads would use 10GB of RAM and take seconds to create
| Concurrency primitive | Creation cost | Memory per unit | When to use |
|---|---|---|---|
| NOVA green task | ~1µs | ~32KB | Default: concurrent connections, work items |
| OS thread | ~10µs | ~1MB | Only for C FFI that blocks, or true CPU isolation |
| Python coroutine | ~1µs | ~1KB but GIL prevents true parallelism | I/O-only concurrency |
The practical implication: you can spawn one green task per HTTP connection, per database query, per item to process. The scheduler handles thousands of parked tasks without burning CPU.
Performance comparison: NOVA vs C vs Python
The NOVA-vs-C columns are this project's own measured ratios (1x = identical to C, from the perf-regression gate that runs before every compiler commit). The Python column is the well-known, widely-documented order of magnitude for CPython's interpreter overhead on compute-bound code — not a benchmark run in this repo against a specific Python program, so treat it as "roughly this ballpark," not a precise measurement.
| Operation | NOVA (measured) | C (clang -O2) | Python 3 (typical, not benchmarked here) | Notes |
|---|---|---|---|---|
| Integer loop | ~1x | 1x (baseline) | ~50x slower | NOVA generates identical machine code to C |
| Struct field math (lowercase) | ~1.04x | 1x | ~150x slower | NOVA within 4% of C |
| Struct field math (Capital) | ~150x slower! | 1x | ~150x slower | Both equally slow — avoid Capital types |
| Float array operations | ~1.05x | 1x | ~100x slower | NOVA within 5% of C |
| String concat in loop | 1.2–2x | 1x | varies | Use join() for loops |
| Dict lookup | 1.5–3x | N/A | ~5–10x slower | Open-addressing hash table |
| Green task spawn | ~38µs avg (10k tasks incl. 10k parked, in 382ms) | N/A | N/A | An OS thread is well known to cost far more per spawn — we haven't measured our own OS-thread baseline to put a multiplier on it here |
We don't have an HTTP-round-trip number for Forge that we're confident enough in to publish next to a named competing framework — when we run that benchmark properly, it'll go here.
The compiler is the genius — you write simple code
The NOVA compiler handles optimization. You do NOT need to:
- Annotate types on local variables — the compiler infers them from context
- Manually inline hot functions — LLVM's optimizer does this automatically
- Write SIMD intrinsics for most workloads — LLVM auto-vectorizes
- Manage memory allocation for request-scope data — arenas handle it
- Write lock-free algorithms for basic concurrency — use channels instead
You DO need to:
- Use lowercase type names in struct fields:
x: float, notx: Float - Use
join()orbuffer_create()for string building in loops - Design channels to carry small coordination values, not large datasets
DO: Profile before optimizing. NOVA's default performance is within 2–5x of hand-written C for most code. The first version of your code is probably fast enough. DON'T: Rewrite clean NOVA code in a "more C-like" style — the compiler sees through abstractions. Ugly code is not faster code.
Process erasure — spawn-free programs pay zero concurrency tax
The compiler statically scans a compiled program's whole call graph for any use of spawn. If it finds none, anywhere, ALL of the concurrency machinery — the green-task scheduler, park/unpark bookkeeping, channel synchronization primitives — is compiled away entirely. A single-process NOVA program isn't "a concurrent program that happens to only run one task" underneath; the compiler builds it as if the concurrency runtime never existed, matching a sequential C program instruction for instruction.
// No spawn anywhere in this program — the scheduler is never linked in fn fib(n) if n < 2 n else fib(n - 1) + fib(n - 2) fn main() print(fib(30)) // 832040
DO: Write straight-line sequential code without artificially avoiding features to "save" concurrency overhead — if spawn never appears, there is no overhead left to save; the compiler already removed it. DON'T: Assume this decision is per-function — process erasure is a whole-program decision. One spawn anywhere in the program pulls the scheduler into the final binary for every function in it, not just the ones that spawn something.
Float specialization — proven-float math becomes a single instruction
Type inference tracks, register by register, whether a value is PROVABLY float — not merely typed float, but statically guaranteed float at that exact point in the program, with no path where it could arrive as a boxed any. When sqrt, abs, sin, cos, floor, ceil, or round is called on a provably-float value, the compiler inlines it directly to the matching native LLVM float intrinsic — the function call disappears from the compiled output entirely. When float-provenance can't be proven (the value arrived through an untyped or any-typed parameter), the identical source line instead compiles to a real runtime call that checks the value's type tag before doing the math.
fn distance(x: float, y: float) -> float sqrt(x * x + y * y) // x, y are provably float — sqrt inlines to llvm.sqrt.f64 print(distance(3.0, 4.0)) // 5.0 // No type annotation — x, y could be ANY type at this call site fn distance_dynamic(x, y) sqrt(x * x + y * y) // provenance not established — compiles to a real nova_rt_sqrt() call print(distance_dynamic(3.0, 4.0)) // 5.0 — same answer, real call overhead to get it
5.0
DO: Annotate float-heavy hot-path parameters with lowercase float — that's what lets the compiler prove provenance all the way through a chain of arithmetic and math calls, turning every one of them into a single instruction instead of a runtime-checked call. DON'T: Expect this specialization on a value that passes through an untyped parameter or an any-typed collection — the compiler needs unbroken proof of float-ness from the value's origin all the way to the call site.
Automatic string interning
Every string LITERAL in your source is deduplicated within its compilation unit. The runtime keeps an intern table — FNV-1a hashing, thread-safe under lock — so two occurrences of the same literal text, even in completely unrelated functions, resolve to the SAME underlying string object instead of two separately-allocated copies. This is invisible at the language level: you never call anything like Java's String.intern(); strings behave exactly as documented in Chapter 3. What changes is memory — a literal repeated across ten error paths, or a header name checked on every request, is allocated exactly once for the whole program, not once per occurrence.
// A route table with many repeated method-name literals — // interning means "GET" is allocated once, not once per route entry routes = [ {"method": "GET", "path": "/users"}, {"method": "GET", "path": "/users/:id"}, {"method": "POST", "path": "/users"}, {"method": "GET", "path": "/health"}, ] get_routes = filter(routes, r => r["method"] == "GET") print(len(get_routes)) // 3 — every "GET" literal above is the SAME interned string
DO: Write literal strings normally, everywhere you need them — repeating the same short literal across many functions (status codes, header names, error messages) costs nothing extra in memory; the compiler already deduplicates it. DON'T: Introduce a shared "constant string" variable purely to save memory on a duplicated literal — interning already does that for you; reach for a named const when you want one place to EDIT the value, not to save memory.
How to measure performance
// Wall-clock timing t0 = now_ms() your_function() print("took {now_ms() - t0}ms") // High-precision for fast operations t0 = now_ns() tight_loop() print("took {now_ns() - t0}ns") // Rule: always measure, never guess. // The slowest line is never where you expect it.
37. Annotations and metaprogramming
What is this? Annotations (written @name) attach compile-time behavior to a function, type, or field without you writing that behavior by hand. The compiler reads the annotation, generates the supporting code, and wires it in — you write one line, the compiler writes the rest. This is NOVA's answer to Python decorators, Java/Kotlin annotations, and Rust's derive macros — but resolved entirely at compile time. There is no reflection tax and no runtime annotation-processing pass: an annotation either expands into real generated functions (like @builder) or rewrites the function body at the IR level (like @memo). Either way, what ships is ordinary compiled code with zero abstraction overhead.
@test — compile-time test discovery
The compiler scans every module for @test-annotated functions and synthesizes __nova_run_tests() — a generated function that calls each one, counts how many returned true, and returns that count. It is complementary to the test_run / test_summary framework from Chapter 16: test_run is for assertion-style tests you wire up explicitly in main(); @test is for small boolean checks you never have to register by hand.
@test fn t_add() -> bool 1 + 1 == 2 @test fn t_sort() -> bool sort_ints([3,1,2]) == [1,2,3] fn main() let passed = __nova_run_tests() print("{passed} tests passed")
Every @test function takes zero arguments and returns bool — true means pass, false means fail. __nova_run_tests() is generated for you; its name is reserved and you never define it yourself.
DO: Use @test for small, fast, pure checks you want colocated right next to the code they verify. DON'T: Reach for @test when a check needs setup/teardown or multiple named assertions — use test_run (Chapter 16) for those; it reports per-assertion detail that a plain bool return cannot.
Zero-boilerplate test runs — skip main() entirely
The example above still writes fn main() and manually calls __nova_run_tests(). That's necessary once your file has ANY other main-worthy logic — but when a file contains @test functions and has NO main() anywhere at all, the compiler goes a step further: it synthesizes the calling code too, generating a complete entry point that runs every @test function, tallies the results, and prints the same "{passed} tests passed" summary shown above — with nothing left for you to write beyond the @test functions themselves. This is the same "no main() needed for simple programs" rule from Chapter 2, applied to test files specifically: a file that is ONLY tests needs no entry point of any kind.
// pure_test.nova — no fn main() anywhere in this file @test fn t_addition() -> bool 2 + 2 == 4 @test fn t_string_length() -> bool len("nova") == 4
nova run pure_test.nova
DO: Drop main() entirely from files that are pure test suites — it removes the last piece of ceremony between "write a test" and "run a test." DON'T: Expect this shortcut in a file that ALSO defines application logic you run normally — the zero-main test-runner synthesis only activates when the compiler finds @test functions and NO main() at all; the moment you add a main(), you're back to calling __nova_run_tests() yourself (or using test_run/test_summary) as shown above.
@memo — automatic memoization
@memo wraps a function with a cache keyed by its argument tuple. The first call with a given input computes and stores the result; every later call with the same input returns the cached value instantly — no recomputation.
@memo fn fib(n: int) -> int if n < 2 n else fib(n - 1) + fib(n - 2) print(fib(40)) // instant — cached results
The cache is recursion-safe — fib can call itself while its own memo table is still being populated — and deadlock-free under concurrent access from multiple tasks; the runtime synchronizes cache writes internally. @memo is also available as a trailing modifier on the signature line, which reads better for short one-liners:
fn fib(n: int) -> int memo
DO: Use @memo on pure, deterministic functions — classic recursive/DP code (Fibonacci, edit distance, coin change) turns from exponential to linear time for free. DON'T: Put @memo on a function with side effects or non-deterministic output (reading the clock, RNG, I/O) — the cache will silently return a stale answer forever.
@redact — field-level security
@redact masks a struct field wherever the struct is turned into text: show, print, and to_json. The underlying value is untouched in memory — this is a display-time mask, not encryption — so the field still works normally for comparisons, hashing, and logic inside your code.
type Credentials username: string @redact password: string print(Credentials("admin", "s3cret")) // Credentials { username: admin, password: [REDACTED] }
DO: Put @redact on passwords, API keys, tokens, and PII fields on any struct that might ever reach a log line or an error message. DON'T: Assume @redact protects the value in memory or over the wire — it only masks the string you print, log, or JSON-encode; use encryption for data at rest or in transit.
@builder — builder pattern generation
@builder generates a fluent construction API for a type: Type__builder() to start, one Type__set_field(b, value) per field, and Type__build(b) to finish. Each setter is immutable-style — it returns a new builder value rather than mutating in place — so you reassign as you chain.
@builder type Config host: string port: int debug: bool let c = Config__builder() let c = Config__set_host(c, "localhost") let c = Config__set_port(c, 8080) let cfg = Config__build(c)
This is NOVA's answer to Java's Lombok @Builder or Rust's derive_builder crate — except there is no separate build step, no annotation processor, and no macro crate to add: the generated functions are ordinary compiled NOVA functions from the moment the type is declared.
@entity — entity metadata (a separate mechanism from Forge's ORM)
@entity("table_name") turns a plain struct into a table-mapped entity: the compiler generates two SQL-producing functions, <T>__create_table_sql() and <T>__insert_sql(), from the struct's field names and types. This is independent of Forge's ORM (Chapter 26) — forge_orm does not use @entity at all; it derives table/column names and SQL types directly from plain reflection (type_name, field_names, field_types, field_get) on an ordinary, unannotated struct, and additionally handles per-dialect column types, identifier quoting, and auto-increment primary keys across SQLite/PostgreSQL/MySQL — none of which @entity's two generated functions do on their own. Reach for @entity only when you want the two raw SQL strings themselves (e.g. to feed a migration tool you're writing); reach for forge_orm to actually persist and query structs.
@entity("users") type User id: int name: string email: string let sql = User__create_table_sql() // CREATE TABLE users (id INTEGER, name TEXT, email TEXT) let insert = User__insert_sql() // INSERT INTO users (id, name, email) VALUES (?, ?, ?)
The rest of Tier 2 — service, DI, middleware, validation, and resilience metadata
@entity above is the most fully wired-up Tier 2 annotation for generating raw SQL text on its own. The remaining eleven Tier 2 annotations follow the identical mechanical pattern — attach @name or @name(arg), get back one or more <Target>__xxx() query functions built from the same name — but nothing in the compiler or standard library reads their output for you. Hold onto this one fact for all eleven: they publish metadata, they do not enforce it. @retry(5) does not make a function retry. @timeout(2000) does not cancel a call after two seconds. @cache(300) does not cache a single byte. Each one only answers "does this function/type claim this property, and what's the configured value?" — the retry loop, the deadline enforcement, the cache store is code you (or a library you bring in) still have to write, by reading the generated function and acting on it.
@service / @service("name") — service registry metadata
Marks a function as a named service entry point for a microservice registry or dependency-injection container to enumerate, and records what it depends on by reading its own parameter types — so the dependency list can never drift out of sync with the signature the way a hand-maintained config file can.
@service("payment_gateway") fn start_payments(db: string, replicas: int) -> bool true fn main() print(start_payments__service_name()) print(start_payments__is_service()) print(start_payments__dependencies())
Drop the argument and write bare @service to derive the name from the function name in snake_case instead of spelling it out twice.
DO: Use @service("name") to give the service a stable public identifier that survives a function rename — the registry key stops being tied to the identifier. DON'T: Assume start_payments__is_service() returning 1 means anything is registered anywhere — nothing calls these functions automatically; you still write the startup loop that walks your annotated functions and populates a real registry.
@inject — dependency injection metadata
Applied to a type, not a function: marks a struct as an injectable component and derives its dependency list directly from its own fields — each field becomes one name:type entry a constructor-style injector can walk, so the dependency list is always exactly the struct's current field list, never a second copy you maintain by hand.
@inject type UserService db: string logger: string fn main() print(UserService__is_injectable()) print(UserService__inject_deps()) print(UserService__inject_name())
DO: Rely on field declaration order — inject_deps() lists dependencies in the exact order the struct declares its fields, the order a hand-written constructor call needs them in. DON'T: Expect UserService__inject_name() to equal the type name verbatim — it's the snake_case form (UserService → user_service); key your container's lookup table by that exact casing, not the type name string.
@middleware / @middleware("name") — middleware registration metadata
Marks a function as a named middleware step and publishes that name for a router-building routine elsewhere in your own code to enumerate. This is a separate, compile-time-only mechanism from Forge's actual middleware chain.
@middleware("requestLogger") fn logRequests(req) -> bool true fn main() print(logRequests__middleware_name()) print(logRequests__is_middleware())
Bare @middleware (no argument) uses the function's own name verbatim as the middleware name — unlike @service, it is not converted to snake_case.
DO: Use the generated __middleware_name/__is_middleware functions to build your own compile-time-checked list of middleware steps. DON'T: Confuse this with Forge's real middleware chain — forge.use(app, forge.mw_cors()) (Chapter 30) is a separate runtime API that actually runs on every request; @middleware only produces two introspection functions and never touches any request pipeline by itself.
@validate — validation metadata
Applied to a type: publishes the struct's own field/type shape as data — <T>__validate_fields() and <T>__field_count() — so a generic validation routine can walk any @validate-annotated struct without knowing its shape ahead of time.
@validate type SignupForm email: string password: string age: int fn main() print(SignupForm__has_validation()) print(SignupForm__validate_fields()) print(SignupForm__field_count())
DO: Use validate_fields()/field_count() to drive a shape-agnostic validation loop that works on any @validate-annotated struct. DON'T: Confuse this with forge.validate(body, rules) (Chapter 30) — that is a distinct runtime API that checks values against an explicit rule list per field; @validate only publishes the struct's field:type shape, it does not check anything against any rule by itself.
@retry / @retry(N) — retry policy metadata
Records how many attempts a caller-side retry loop should make for an operation prone to transient failure — a flaky network call, a lock-contention retry — as data read via <fn>__max_retries(), instead of a magic number duplicated at every call site.
@retry(5) fn fetch_price(symbol: string) -> float 99.5 fn main() print(fetch_price__max_retries()) print(fetch_price__is_retryable())
Bare @retry (no argument) defaults __max_retries() to 3.
DO: Read __max_retries() inside your own retry-loop wrapper, so the attempt count lives next to the function it governs instead of scattered across every call site. DON'T: Expect calling fetch_price() itself to retry on failure — @retry never wraps the function body; the loop that catches a failure and calls fetch_price() again is code you write yourself.
@timeout / @timeout(ms) — timeout policy metadata
Declares an operation's time budget as data — <fn>__timeout_ms() — for a generic "call with a deadline" wrapper to look up per function, instead of being told the budget externally at every call site.
@timeout(2000) fn call_external_api(url: string) -> string "response" fn main() print(call_external_api__timeout_ms()) print(call_external_api__has_timeout())
Bare @timeout (no argument) defaults __timeout_ms() to 5000.
DO: Pair @timeout with select_timeout(...) (Chapter 17), which actually enforces a deadline — pass __timeout_ms() as its ms argument instead of hardcoding the number at the call site. DON'T: Expect the annotated function to be interrupted automatically after the configured budget — @timeout publishes the number, it does not start a timer or cancel anything on its own.
@singleton — singleton marker metadata
Applied to a type: flags it as intended to have exactly one instance for the program's lifetime — a connection pool, a config object, a logger — and gives it a canonical, snake_case name a hand-rolled global-instance registry can key on, so that key can never drift from the type's own name.
@singleton type ConnectionPool max_conns: int fn main() print(ConnectionPool__is_singleton()) print(ConnectionPool__singleton_name())
DO: Use singleton_name() as the exact key into a hand-rolled global-instance dict, so the key is generated rather than retyped. DON'T: Write @singleton("pool") expecting to override the name — the compiler accepts an argument syntactically but ignores its value entirely; singleton_name() is always derived from the type name, never from an argument.
@observable — reactive field metadata
Applied to a type: marks its fields as ones a reactive/change-detection system should watch. <T>__observable_fields() gives that system the exact field list to wire watchers for — regenerated from the struct's live field list on every compile, so it can never fall out of sync with the struct the way a hand-written watcher list can.
@observable type FormState email: string submitted: bool fn main() print(FormState__is_observable()) print(FormState__observable_fields())
DO: Walk observable_fields() once at startup to wire up your reactive framework's watchers automatically. DON'T: Write @observable("email") expecting to watch only that one field — an argument is accepted syntactically but ignored; observable_fields() always lists every field on the struct.
@async — async marker metadata
A pure introspection flag on a function — distinct from NOVA's implicit concurrency model (spawn, channels, automatically-async I/O; Chapter 17). Use it when a piece of your own code (a dispatcher, a code generator) needs to treat certain handler functions differently at a metadata level, not to make anything actually run concurrently.
@async fn stream_updates(conn) -> bool true fn main() print(stream_updates__is_async())
DO: Use @async to flag handlers for a dispatcher you write that needs to branch on the flag. DON'T: Confuse this with what actually makes a NOVA function concurrent — real concurrency (spawn, channels, implicit-async I/O) behaves identically whether or not a function carries this annotation; @async changes zero runtime behavior by itself.
@cache / @cache(ttl) — cache policy metadata
Declares a caching intent and a TTL in seconds as queryable metadata — <fn>__is_cached()/<fn>__cache_ttl() — for an external caching layer (an HTTP response cache, a CDN rule) to read, instead of a config file that has to be kept in sync with which handlers are actually cacheable.
@cache(300) fn get_homepage(req) -> string "homepage" fn main() print(get_homepage__is_cached()) print(get_homepage__cache_ttl())
Bare @cache (no argument) defaults __cache_ttl() to 60 seconds.
DO: Tell @cache apart from @memo (earlier in this chapter) — @memo actually wraps the function with a real cache; @cache only publishes a policy for an external layer to read and enforce. DON'T: Expect get_homepage__is_cached() returning 1 to mean repeat calls skip execution — with @cache alone they don't; only @memo changes call behavior.
@event / @event("name") — event handler metadata
Marks a function as an event handler and gives it a canonical, introspectable event name, so a startup routine can walk every @event-annotated function and build a pub/sub subscription map automatically instead of hand-registering each handler with the event bus one call at a time.
@event("user.created") fn on_user_created(user) -> bool true fn main() print(on_user_created__event_name()) print(on_user_created__is_event_handler())
DO: Use event_name() to build your subscription map at startup by walking every @event-annotated function once. DON'T: Assume the bare form @event (no argument) converts the function name to snake_case the way @service/@singleton do — @event's default is the function's name exactly as written; only an explicit @event("name") argument changes it.
@comptime — compile-time evaluation
@comptime marks a function whose result the compiler folds into a constant at build time — the call disappears entirely from the compiled output and is replaced by its answer.
@comptime fn table_size() -> int return 16 * 4 // folded to 64 at compile time const FIB20 = fib(20) // computed at compile time
TRAP: A bare expression body (no explicit return) folds to 0 under @comptime — the compile-time evaluator only tracks the value passed to return. Always write return <expr> in a @comptime function, even though ordinary NOVA functions don't need it.
@cdecl — C calling convention
@cdecl forces the standard C calling convention onto a function so its address can be handed to a C API that expects a callback — a comparator for qsort, a signal handler, a windowing library's event hook. See Chapter 31 (FFI) for the full C-interop story.
@cdecl fn compare(a: int, b: int) -> int a - b // Can be passed as a callback to C functions like qsort
@export — C-ABI library export
@export makes a NOVA function callable from outside NOVA entirely — it is emitted with a stable C-ABI symbol so it can be linked into a shared library and called from C, Python (via ctypes), Java (via JNI), or any language with a C FFI.
@export fn novalib_add(a: int, b: int) -> int a + b
@cdecl and @export solve opposite directions of the same problem: @cdecl lets NOVA call into other-language callback slots; @export lets other languages call into NOVA.
@deprecated — deprecation warning
@deprecated("message") emits a one-time runtime warning to stderr the first time the function is called, pointing callers at the replacement — without breaking anyone who still depends on the old function.
@deprecated("use new_api() instead") fn old_api() -> int 42
@log — entry logging
@log injects a call to log_fn_entry(name) as the very first statement in the function body — before any of your own code runs — so every invocation is recorded to stderr without you writing a single logging call inside the function itself. Reach for it on functions you don't want to touch by hand for logging: a payment path, an auth check, a public API boundary — instead of scattering print/log calls through the implementation where a future refactor could quietly delete one.
@log fn process_payment(amount: int) -> bool // log_fn_entry("process_payment") is auto-injected here, before this line runs print("charging {amount}") true fn main() process_payment(500)
DO: Put @log on functions where you need proof every call happened — audit trails, compliance-sensitive paths, chasing down an intermittent "is this even being called?" bug. DON'T: Expect @log to capture arguments, return values, or timing — it only records that entry happened and the function's name. For argument/return tracing, add explicit print calls yourself; for timing, use the now_ns() profiling pattern from Chapter 36.
@get / @post / @put / @delete / @patch — declarative HTTP routing
These annotations turn an ordinary function into an HTTP route handler. The compiler collects every @get/@post/@put/@delete/@patch-annotated function across your program and generates __nova_register_routes(app), which wires each one into the router in a single pass — no manual app.get("/path", handler) call per route. Compare to Flask's @app.route or Spring MVC's @GetMapping: same declarative shape, but resolved entirely at compile time — zero reflection, zero startup-time route scanning.
import forge @get("/hello") fn h_hello(req) -> string "Hello, world!" @post("/users") fn h_create_user(req) -> string forge.json(201, {"status": "created"}) fn main() let a = app() a = __nova_register_routes(a) serve_app(a, 8080)
DO: Name handler functions with a consistent prefix (h_ above) so they're easy to spot among plain functions in the same module. DON'T: Forget the a = __nova_register_routes(a) line — without it, the annotated functions exist but are never attached to any route.
Compile-time features
Beyond annotations, three plain-syntax features move work from runtime to build time: const folds whole function calls into literal values, static_assert turns a broken invariant into a build failure instead of a 3am page, and requires/ensures put function contracts directly in the signature.
const — compile-time constants
A const binding is not just "cannot be reassigned" (that's the difference between let and reassignment elsewhere) — its initializer is evaluated during compilation. If the initializer is itself a function call, the compiler runs that function at compile time and bakes in the result, as long as the function's inputs are all known constants.
const MAX_SIZE = 1024 const PI = 3.14159 fn fib(n: int) -> int if n < 2 then return n fib(n - 1) + fib(n - 2) const FIB20 = fib(20) // computed at compile time → 6765
FIB20 costs nothing at runtime — the compiled program simply contains the literal 6765, exactly as if you had typed it yourself. Recursive fib(20) made 21,891 calls; all of them happened while building your program, not while running it.
The = sign itself is optional on a const declaration: const RETRY_LIMIT 3 (no equals sign) parses identically to const RETRY_LIMIT = 3. This is a pure spelling choice — the compile-time-evaluation behavior described above is identical either way.
const MAX_SIZE = 1024 // with = const RETRY_LIMIT 3 // without = — parses identically print(MAX_SIZE) // 1024 print(RETRY_LIMIT) // 3
3
DO: Write the = — const PI = 3.14159 reads unambiguously as an assignment to anyone coming from another language. DON'T: Rely on the no-equals form in shared code just because it compiles — it saves one character and costs the next reader a double-take the first time they see it.
Module-level let NAME = literal — constant propagation without const
You don't have to write const to get build-time inlining for a simple value. If a top-level let's right-hand side is a plain scalar literal — an int, float, bool, or string — and the compiler can see the binding is never reassigned to anything else, it inlines that value at every point in the module that reads it. Automatically. No annotation at all. The difference from const is what each one promises: const is an explicit contract that lets the initializer be an arbitrary function call the compiler must evaluate at build time (like fib(20) above); a plain module-level let is an opportunistic optimization the compiler performs on literals it can already prove are fixed, with no contract and no ceremony to invoke it.
let MAX_RETRIES = 3 let SERVICE_NAME = "billing-api" fn should_retry(attempt: int) -> bool attempt < MAX_RETRIES // compiles as: attempt < 3 fn log_prefix() -> string "[" + SERVICE_NAME + "]" // compiles as: "[" + "billing-api" + "]" fn main() print(should_retry(2)) print(log_prefix())
[billing-api]
DO: reach for a bare module-level let when the value is a literal and you just want a readable name in place of a magic number or string — no const ceremony needed for the simple case. DON'T: expect this to fire if the initializer is anything other than a literal — a function call, another variable, a computed expression. The moment the right-hand side isn't a literal the compiler can already prove fixed, you're back to needing either const's explicit compile-time contract (for expressions it must evaluate) or an ordinary runtime let (for values that genuinely vary).
Module-level let x = [...] / {...} / channel() — one shared handle for the whole module
A top-level let whose initializer is a literal list, a literal dict, or a bare channel() call is treated differently again: the compiler builds it exactly ONCE, in the module's startup prologue — not once per call, not one copy per function — and every function or lambda defined in that module that reads the name gets back the SAME handle. This is what makes a module-level registry, cache, or broadcast channel work correctly with zero synchronization ceremony: there is genuinely only one dict (or one channel) in existence for the whole module, so a write from one function is immediately visible to every other function that reads it — the same way a Python module-level global or a Go package-level var behaves, except in NOVA it's a deliberate, narrowly-scoped compiler rule tied to one specific syntactic shape (a literal-only right-hand side), not an implicit side effect of "everything at module scope is secretly global".
let registry = {} fn register(id: int, name: string) registry[id] = name fn lookup(id: int) -> string registry[id] fn main() register(1, "alice") register(2, "bob") print(lookup(1)) print(lookup(2)) print(len(registry))
bob
2
DO: use it for state that genuinely belongs to the whole module — an in-memory cache, a request counter, a shared config dict, or (with let events = channel()) a broadcast channel every handler in the module needs to send to or receive from. DON'T: assume a fresh instance appears per call, per request, or per test run — it's the exact same handle for the entire lifetime of the process. State written by one request handler is still sitting there on the next request; if you actually need per-request isolation, build the container inside the function that needs it instead of at module scope.
static_assert — compile-time assertion
static_assert(condition) (and its two-argument form static_assert(condition, message)) is checked while the compiler is compiling your program. If the condition is false, the build fails right there — the assertion never generates a single instruction in the final binary.
static_assert(1 + 1 == 2) static_assert((4 < 8) and (9 >= 9)) static_assert(1 == 1, "math works")
DO: Use static_assert to pin down invariants a refactor could silently break — buffer sizes that must stay a power of two, enum counts that must match a lookup table's length, platform assumptions (static_assert(sizeof_int() == 8)). DON'T: Confuse this with assert from Chapter 16 — that one runs (and can fail) every time the program executes; static_assert runs once, at build time, and produces zero runtime code either way.
requires / ensures — design by contract
requires declares a precondition checked at function entry; ensures declares a postcondition checked on every return path, including early returns. Inside an ensures clause, the identifier result is bound to the value the function is about to return. This is NOVA's take on Eiffel's design-by-contract and Ada's Pre/Post aspects — but written inline in the signature instead of in a separate contract clause or attribute.
fn safe_div(a: int, b: int) -> int requires b != 0 a / b fn abs_val(x: int) -> int ensures result >= 0 if x < 0 then return 0 - x x
A violated requires means the caller made a mistake — it fires before the body runs, so the function's own logic never has to defensively re-check what the contract already guarantees. A violated ensures means the function itself has a bug — it fires after every return, catching a broken implementation before the wrong value ever reaches the caller.
DO: Put requires/ensures on functions where a violated precondition would otherwise fail silently or corrupt data several calls later — division, array indexing helpers, invariant-preserving mutations. DON'T: Duplicate a check the type system already gives you for free (there's no need for requires n is int — NOVA's inference already guarantees that).
More Tier 2 annotations: @inject, @middleware, @singleton, @async, @observable
Like @entity above, these five are metadata-only: none of them changes what the annotated function or type actually does. Each one generates a small set of __name-prefixed companion functions that report facts about the annotation back at runtime — whether it's present, and what argument it was given. A framework — Forge's own service container, or bootstrap code you write yourself — reads those generated functions to wire things together; the annotation never executes anything on its own.
@inject type EmailSender smtp_host: string @middleware("auth_check") fn require_login(req, next) if req.headers["Authorization"] == "" return forge.text(401, "unauthorized") next(req) @singleton type ConnectionPool max_size: int @async fn fetch_remote(url) -> string http_get(url) @observable type Cart items: list total: float print(EmailSender__is_injectable()) // true print(require_login__is_middleware()) // true print(require_login__middleware_name()) // auth_check print(ConnectionPool__is_singleton()) // true print(fetch_remote__is_async()) // true print(Cart__is_observable()) // true print(Cart__observable_fields()) // [items, total]
true
auth_check
true
true
true
[items, total]
DO: Treat these as pure metadata — the annotation itself adds no logging, retries, or DI wiring by itself. A registry (your own, or a framework's) is expected to call the generated __is_X/__x_name functions and act on what it finds. DON'T: Expect @middleware to automatically insert a handler into a request pipeline — Forge's own dispatch is wired separately (forge.use(...), Chapter 30); the annotation only publishes metadata that a middleware registry can then consume.
Annotation reference
Tier 1 annotations are general-purpose and covered above. Tier 2 annotations are domain-specific — ORM, service registration, request validation, resilience policy — and follow the identical @name / @name(args) pattern; see the Forge chapters (25–30) for where each is used in context.
| Annotation | Type | What it does |
|---|---|---|
@test | Tier 1 | Test discovery |
@memo | Tier 1 | Memoization |
@redact | Tier 1 | Field masking |
@builder | Tier 1 | Builder pattern |
@comptime | Tier 1 | Compile-time eval |
@cdecl | Tier 1 | C ABI |
@export | Tier 1 | Library export |
@deprecated | Tier 1 | Deprecation warn |
@log | Tier 1 | Entry logging |
@get / @post / etc. | Tier 1 | HTTP routing |
@entity | Tier 2 | ORM metadata |
@service | Tier 2 | Service registry |
@validate | Tier 2 | Validation |
@retry | Tier 2 | Retry policy |
@timeout | Tier 2 | Timeout policy |
@cache | Tier 2 | Cache policy |
@event | Tier 2 | Event handler |
@inject | Tier 2 | Dependency-injection metadata |
@middleware | Tier 2 | Middleware registration metadata |
@singleton | Tier 2 | Singleton marker |
@async | Tier 2 | Async function marker |
@observable | Tier 2 | Observable/reactive marker |
Hot-code reload
NOVA can load a compiled shared library at runtime, look up a symbol in it, call through that symbol, and later swap in a rebuilt version of the same library — without restarting the process. This is the mechanism behind live-editable game logic, plugin systems, and zero-downtime patching of long-running servers.
let lib = hot_load("plugins/physics.dll") let step_fn = hot_sym(lib, "physics_step") hot_call1(step_fn, world) // After editing the DLL: hot_reload(lib) let step_fn = hot_sym(lib, "physics_step") hot_call1(step_fn, world) // runs new version
DO: Re-fetch the symbol with hot_sym after every hot_reload — the old function pointer becomes stale the moment the library is swapped. DON'T: Hold onto data structures defined inside the hot-loaded library across a reload unless their layout is guaranteed stable — a changed struct layout in the new build will misread old in-memory data.
Calling by arity, releasing, and checking compatibility
hot_call1 above is one member of a small family — hot_call0 through hot_call3 — that invokes a looked-up symbol with anywhere from zero to three arguments, matching whatever C function signature the plugin actually exports. When a hot-loaded library is no longer needed, hot_unload(handle) releases it back to the OS; every symbol you looked up from it becomes invalid the instant you call it afterward.
let lib = hot_load("plugins/physics.dll") let init_fn = hot_sym(lib, "physics_init") hot_call0(init_fn) // physics_init() -- no arguments let collide_fn = hot_sym(lib, "physics_collide") hot_call2(collide_fn, body_a, body_b) // physics_collide(body_a, body_b) let raycast_fn = hot_sym(lib, "physics_raycast") let hit = hot_call3(raycast_fn, origin, direction, max_dist) // 3 arguments // Done with the plugin for this session: hot_unload(lib) // init_fn/collide_fn/raycast_fn are now dangling
Every hot_call* variant, like hot_call1, passes and returns plain 64-bit values across a raw C ABI boundary, not a typed NOVA function call — the plugin side must independently agree on argument count and meaning. There is no hot_call4 or beyond; a plugin function needing more than three arguments should accept a single struct pointer or dict handle instead and unpack it on the C side.
Before trusting a freshly loaded plugin, compare abi_version() against the ABI the plugin was built against — a mismatch means the plugin's assumptions about struct layout or calling convention may not match the running host, and calling into it is unsafe.
print("runtime ABI: {abi_version()}") // runtime ABI: 65536
DO: Call hot_unload on every library you loaded once you are done with it — an unloaded shared library is not automatically released just because your NOVA program has no more references to its handle. DON'T: Call a symbol obtained via hot_sym after the library it came from has been hot_unloaded or hot_reloaded — the old function pointer now points into unmapped or replaced code, and calling it is undefined behavior, not a NOVA-level error you can catch.
Hot-reload file watcher
Instead of calling hot_reload on a fixed schedule, you can watch specific paths and only reload when something on disk actually changed. hot_reload_watch(path) registers a path — typically the plugin file itself — and records its current modification time, returning a small integer watch id. hot_reload_check() re-stats every registered path and returns the list of watch ids whose modification time changed since the last check — an empty list if nothing changed. hot_reload_path(id) recovers the original path string for a given watch id, useful for logging which one triggered.
let lib = hot_load("plugins/physics.dll") let w = hot_reload_watch("plugins/physics.dll") while true let changed = hot_reload_check() if len(changed) > 0 print("reloading: {hot_reload_path(w)}") hot_reload(lib) sleep(500)
hot_reload_watch polls with a plain filesystem stat call — it does not push events, and pointing it at a directory only catches an entry being added or removed directly inside that directory, not a content edit to a file nested underneath it. For reliable per-edit detection, watch the exact file you expect to change (the plugin binary itself, or a single source file), not its containing folder.
DO: Check len(hot_reload_check()) > 0 — a returned list is a non-null handle either way, so a bare if hot_reload_check() would fire on every single poll whether or not anything actually changed. DON'T: Poll with no sleep in the loop — hot_reload_check() re-stats every watched path on each call, so an unthrottled loop turns file watching into a busy-loop syscall storm.
ECS — Entity Component System
An Entity Component System is the standard data layout for games and simulations: entities are just IDs, components are named key/value data attached to an entity, and systems are ordinary functions that query for entities with a given component and act on them. NOVA's built-in ECS gives you this without a game-engine dependency.
let world = ecs_world() let player = ecs_entity(world) ecs_set(world, player, "pos_x", 100) ecs_set(world, player, "health", 100) let alive = ecs_query(world, "health") for e in alive let hp = ecs_get(world, e, "health") print("entity {e}: hp={hp}")
ecs_query(world, "health") returns every entity that currently has a "health" component — a game's main loop is typically a handful of these queries run once per frame, one per system (movement, collision, rendering), rather than one big class hierarchy of GameObject subtypes.
Checking and removing — ecs_has and ecs_destroy
ecs_query finds every entity with a component. ecs_has(world, entity, component_name) is the single-entity yes/no question — does THIS entity have THIS component right now — without allocating a list just to check membership. ecs_destroy(world, entity) is the ECS equivalent of deleting an object: it removes every component currently attached to that entity, so it stops appearing in any future ecs_query or ecs_has check. NOVA's ECS never recycles entity ids, so a destroyed entity's id is simply gone — it never silently becomes a fresh entity later.
let world = ecs_world() let enemy = ecs_entity(world) ecs_set(world, enemy, "health", 0) // took fatal damage if ecs_has(world, enemy, "health") let hp = ecs_get(world, enemy, "health") if hp <= 0 ecs_destroy(world, enemy) // remove all of the enemy's components print("enemy destroyed") print(ecs_has(world, enemy, "health")) // 0 -- component is gone
0
Checking ecs_has before ecs_get matters because ecs_get on a component the entity doesn't have returns 0 — the same value as a real health reading of zero. ecs_has is how you tell "the component is absent" apart from "the component's value happens to be zero."
DO: Call ecs_destroy once an entity is truly gone from the simulation (an enemy died, a projectile expired) so it stops showing up in every subsequent ecs_query. DON'T: Assume a zero value from ecs_get means "component absent" — it is also the value of a real, present zero. Always confirm with ecs_has first when the difference between "absent" and "zero" matters to your logic.
Sized numerics
Plain int and float are 64-bit and cover the vast majority of code. When you need to match a wire format, a GPU buffer layout, or a C struct exactly, NOVA also has explicit fixed-width numeric types: i8 u8 i16 u16 i32 u32 i64 u64 f32 f64. A sized integer wraps at its width — it does not overflow into a wider type, and does not error.
let a = 255u8 + 1 // 0 — wraps at width let b: u8 = 300 // 44 — narrows let c = u8(x) // explicit conversion let xs: u8[] = [1, 2, 3, 300] // 4 BYTES; 300 narrows to 44 let ds: i32[] = [10, 20, 30] // 12 bytes let fs: f64[] = [1.5, 2.25] // 16 bytes
A typed array like u8[] is a genuinely packed byte buffer — 4 elements of u8 occupy exactly 4 bytes, not 4 boxed values behind pointers. This is what makes sized-array code match C's memory layout for FFI and file-format work.
DO: Reach for a sized type only when something outside NOVA dictates the width (a file format, a C struct, a network protocol, a GPU buffer). DON'T: Mix explicit widths in one expression — u8 + i32 is a type error, not an implicit promotion; convert explicitly with i32(x) first.
Real f32 — genuine IEEE binary32, not "f64 wearing a label"
NOVA's f32 is not f64 stored in a smaller field and printed with fewer digits — it is a real 32-bit IEEE 754 binary32 value that rounds to binary32 precision after every single operation, the same way a C float or a GPU vertex buffer does. That distinction matters the moment you exchange data with anything outside NOVA built around 32-bit floats: a 3D model file, a shader's vertex layout, an embedded sensor's fixed-format telemetry packet. If NOVA computed in 64-bit precision internally and only truncated once at the end, values that must round-trip bit-for-bit through a 32-bit pipeline would silently drift from what the other side computes.
let a: f32 = 16777217.0 // narrows to 16777216.0 — nearest binary32 value print(a) let b = 0.1f32 + 0.2f32 // rounds to binary32 after the add print(b) let c = 1.0f32 / 3.0f32 print(c) let d = 0.1 + 0.2 // plain f64, for comparison print(d)
0.300000011920929
0.333333343267441
0.3
16777217.0 is 2^24 + 1 — the smallest integer binary32 cannot represent exactly, because a float's 23-bit mantissa runs out of precision right there; binary64 (f64) has 52 mantissa bits and represents it exactly, which is why the same literal without f32 stays 16777217.0. 0.1f32 + 0.2f32 and 1.0f32 / 3.0f32 land on visibly different digits than their f64 counterparts (0.3 and 0.333333333333333) for the same reason: every operand and every intermediate result is rounded to binary32, not computed at full precision and truncated once at the end.
Mixing the two widths in one expression is a type error, exactly like mixing u8 and i32: 1.0f32 + 2.0 does not compile. Convert explicitly with f32(v) or f64(v):
let e: f32 = 3.5 let widened = f64(e) // f32 -> f64, exact — every binary32 value fits in binary64 let narrowed = f32(widened) // f64 -> f32, rounds to nearest binary32 if needed // let bad = e + 2.0 ← ERROR: f32 + f64 is a type error, not an implicit promotion
DO: Use f32 when something outside NOVA dictates 32-bit floats — a GPU buffer, a binary file format, a C float field over FFI. Use plain float (which is f64) for everything else — it carries more precision and there's no CPU performance reason to reach for f32. DON'T: Assume f32 arithmetic gives the same digits as float arithmetic rounded for display — it's a genuinely different, lower-precision computation at every step, not a display-only truncation applied at the end.
Packed typed arrays — std/collections/typedarray
The T[] annotation sugar above (let xs: u8[] = [1, 2, 3, 300]) is the right tool when you already know the elements at the point you declare the binding. std/collections/typedarray is the explicit builtin API underneath that sugar, for the cases the annotation can't cover: building an array whose size isn't known until runtime, constructing one from an existing plain list, or running map/filter/fold over one while it stays packed the whole time — a plain list of boxed values allocates a pointer per element; a typed array never does.
import std/collections/typedarray let a = unwrap(ta_new(ta_u8(), 1000)) // 1000 BYTES, not 8000 — one byte per element let b = unwrap(ta_of_list(ta_i32(), [1, 2, 3])) let doubled = unwrap(ta_map(b, x => x * 2)) // closures work — result stays packed i32 let total = ta_fold(b, 0, (acc, v) => acc + v) print(json_stringify(b)) // [1,2,3] — a real JSON array, not an opaque handle print(json_stringify(doubled)) print(total) match ta_at(b, 99) // ta_at returns a Result — never panics on a bad index Ok(v) => print("value: {v}") Err(e) => print("out of range") let mid = b[1:3] // slicing returns a NEW packed array of the same kind print(json_stringify(mid))
[2,4,6]
6
out of range
[2,3]
Every kind constructor mirrors the sized-numeric type names above: ta_i8 ta_u8 ta_i16 ta_u16 ta_i32 ta_u32 ta_i64 ta_u64 ta_f32 ta_f64. The float kinds (ta_f32/ta_f64) read and write through a separate, float-typed accessor family instead of the plain one used above — ta_atf in place of ta_at, plus ta_putf/ta_pushf/ta_sumf — because a packed float slot can't be handed back through the same call path as a packed integer slot without either boxing it or silently truncating it:
let fa = unwrap(ta_of_list(ta_f64(), [1.5, 2.5, 3.0])) print(ta_atf(fa, 1)) // 2.5 print(ta_sumf(fa)) // 7.0
7.0
DO: Reach for std/collections/typedarray when the array's size or contents aren't known at the point you write the binding, or when you need map/filter/fold to stay packed instead of materializing a boxed list. Use the T[] annotation sugar instead when a literal is right there in the let. DON'T: Call ta_at (the plain accessor) on a ta_f32/ta_f64 array, or ta_atf on an integer-kind array — the accessor family must match the array's kind.
SIMD primitives
SIMD (Single Instruction, Multiple Data) functions apply one operation across an entire float array in a single CPU instruction sequence, instead of one scalar op per loop iteration. NOVA exposes the common vector ops directly as builtins so you don't need to write intrinsics by hand.
let a = [1.0, 2.0, 3.0, 4.0] let b = [5.0, 6.0, 7.0, 8.0] let c = simd_add(a, b) // [6.0, 8.0, 10.0, 12.0] let d = simd_dot(a, b) // 70.0 let s = simd_scale(a, 10.0) // [10.0, 20.0, 30.0, 40.0]
All three ops are element-wise except simd_dot, which reduces to a single scalar (the sum of pairwise products). Check simd_ready(a) before relying on the fast path in performance-critical code — it reports whether the array's memory is aligned for vectorized instructions; a misaligned array still works, just falls back to a scalar loop internally.
simd_sub, simd_mul, simd_sum — the rest of the vector op family
simd_add/simd_dot/simd_scale are not the whole SIMD surface. simd_sub and simd_mul round out element-wise arithmetic (subtraction and multiplication, the same shape as simd_add), and simd_sum is a horizontal reduction — collapsing one vector down to a single scalar, the building block simd_dot itself uses internally after the pairwise multiply.
let a = [10.0, 20.0, 30.0, 40.0] let b = [1.0, 2.0, 3.0, 4.0] let diff = simd_sub(a, b) // [9.0, 18.0, 27.0, 36.0] let prod = simd_mul(a, b) // [10.0, 40.0, 90.0, 160.0] let total = simd_sum(a) // 100.0 -- horizontal sum of a single vector
[10.0, 40.0, 90.0, 160.0]
100.0
simd_sum(a) is what simd_dot(a, b) is conceptually built from: simd_dot(a, b) is equivalent to simd_sum(simd_mul(a, b)), just computed in one fused pass instead of two. Reach for simd_sum directly whenever you need a plain total (an average, an energy/magnitude calculation) rather than a dot product against a second vector.
DO: Combine simd_mul + simd_sum when you need the pairwise-product-then-total shape for something other than a literal dot product (a weighted average, for instance). DON'T: Expect simd_sub(a, b) to work on arrays of different lengths — like the rest of the SIMD family it operates elementwise pair-by-pair, so mismatched lengths are a runtime error, not a silent truncation to the shorter length.
Profiling
The built-in profiler lets you time named regions of code across a whole run, not just a single benchmark loop. Start a region with a label, stop it, and repeat across as many named regions as you want — the profiler aggregates by label automatically.
let h = prof_start("parse") let ast = parse(source) prof_stop(h) print(prof_report()) prof_export_flame("profile.folded")
prof_report() returns a human-readable summary (call count, total time, and average per label) for the terminal. prof_export_flame(path) writes the same data in the folded-stack text format that flame-graph tools (like Brendan Gregg's flamegraph.pl) consume directly, so you can visualize where time actually goes instead of guessing.
DO: Use now_ms()/now_ns() (Chapter 36) for a quick one-off timing question; reach for the profiler when you need to compare several regions across a real workload or hand a flame graph to someone else. DON'T: Leave prof_start/prof_stop pairs in production hot paths permanently — each pair has its own small overhead, so wrap profiling in a debug flag for code that runs millions of times per second.
Three more pieces round out the profiler beyond the handle-based prof_start/prof_stop pair above: reading a region's accumulated time back out for an automated check, clearing everything between independent runs, and a string-keyed alternative for call sites where carrying a handle variable across the region is awkward.
let h = prof_start("parse") let ast = parse(source) prof_stop(h) // prof_get_ns reads the SAME named region back out as a plain number — // use it to gate CI on a performance budget instead of eyeballing prof_report() by hand assert(prof_get_ns("parse") < 50_000_000, "parse phase regressed past 50ms") // prof_enter/prof_exit are the string-keyed variant — no handle to carry prof_enter("codegen") let ir = codegen(ast) prof_exit("codegen") print(prof_report()) // now reports BOTH "parse" and "codegen" — same table, either API prof_reset() // wipes every region's time and call count before the next run
prof_get_ns(name) works no matter which API measured the region — the handle from prof_start is only a convenience for the call site; both forms write into the same underlying named-region table. prof_reset() clears every region's accumulated time and call count, which matters for a benchmark harness comparing several configurations back to back: without it, configuration 2's numbers add on top of configuration 1's instead of starting fresh.
DO: Reach for prof_enter/prof_exit when a region can't naturally hold a handle variable across its scope — inside a callback, for example, where threading a handle through would be awkward. DON'T: Let the name passed to prof_enter drift from the name passed to its matching prof_exit — they're paired purely by string, not by call-site position, so a typo ("codegen" vs. "codegn") silently opens a second, orphaned region instead of closing the one you meant, and both then report a wrong elapsed time.
Debugging: DAP integration and programmatic breakpoints
NOVA has two related but distinct debugging surfaces, and neither one requires an IDE attached to use from code. The first speaks the Debug Adapter Protocol (DAP) directly — the JSON-over-stdio protocol VS Code, Antigravity, and most modern editors use to talk to a running debuggee. The second is a lower-level, programmatic breakpoint and call-stack API you can drive entirely from your own code — a test harness, a custom in-house debugger, or a post-mortem crash reporter that has nothing to do with an IDE at all.
DAP wire-protocol helpers
dap_log(category, msg) writes a line to the attached IDE's Debug Console. dap_breakpoint(file, line) reports that execution stopped at a source location. dap_send(msg) is the raw escape hatch underneath both: it transmits an already-built DAP protocol message directly over the wire, for anything the two higher-level helpers don't cover. You reach for these three when building or extending a NOVA DAP server that an editor attaches to — not in ordinary application code, where the higher-level dbg_* functions below are the more direct fit.
dap_log("console", "worker pool started") dap_breakpoint("server.nova", 88) dap_send(msg) // pass a pre-built DAP protocol message straight through
Programmatic breakpoints and call stack
dbg_enable()/dbg_disable() turn the whole mechanism on and off — it costs nothing when disabled. dbg_set_bp(file, line) registers a breakpoint and returns an id you can later pass to dbg_remove_bp(id); dbg_list_bps() returns every breakpoint currently registered. dbg_push_frame(name, file, line)/dbg_pop_frame() maintain an explicit call stack so that dbg_backtrace() has something to report, and dbg_hook(fn) registers a callback NOVA invokes on each step or breakpoint hit — the piece you'd use to drive a custom debugger UI instead of a full DAP round-trip.
dbg_enable() let bp = dbg_set_bp("server.nova", 42) // returns a breakpoint id fn process_request(req) dbg_push_frame("process_request", "server.nova", 42) defer dbg_pop_frame() // pops on every exit path, including the early return below if not req.valid then return err("bad request") ok(handle(req)) let bt = dbg_backtrace() // list of frame info, innermost first dap_log("console", "hit breakpoint at server.nova:42") dbg_remove_bp(bp) dbg_disable() // resume full-speed execution — no more frame/step overhead
DO: Pair every dbg_push_frame with a dbg_pop_frame on every exit path, the same way you'd pair a lock acquire with its release — defer dbg_pop_frame() placed right after the push is the safest way to guarantee this, since defer runs on early returns and error paths too. DON'T: Let an unbalanced push/pop reach production. A missing pop leaves a stale frame on the stack forever, so dbg_backtrace() reports a wrong call chain for every frame captured afterward — actively misleading during exactly the incident you're using it to diagnose.
Appendix A: Quick reference
Complete language and built-in function reference for NOVA. All keywords, all core built-in functions, all operators.
Keywords
| Keyword | Meaning |
|---|---|
fn | Declare a function |
type | Declare a struct |
enum | Define a sum type (variant type) |
trait | Define a set of required methods |
let | Bind a name (alternative to bare assignment) |
return | Explicit early return from a function |
if / else | Conditional expression or statement |
while | Loop while a condition is true |
for / in | Iterate over a list, dict, string, or range |
loop | Infinite loop (exit with break or return) |
match | Pattern match an expression |
break | Exit the nearest enclosing loop |
continue | Jump to the next iteration of the nearest loop |
spawn | Start a new green task |
send | Put a value on a channel |
receive | Block until a value arrives on a channel |
select | Block until any one of several channels has a value |
channel | Create a new channel |
import | Import a module |
extern fn | Declare a C function for FFI |
unsafe | Block where safety checks are relaxed |
try | Unwrap Ok or propagate Err (like Rust's ?) |
true / false | Boolean literals |
null | Absence of a value |
and / or / not | Logical operators (NOT &&/||/!) |
in / not in | Membership test |
as | Type cast |
matches | Regex pattern test |
Operators
| Operator | Meaning |
|---|---|
+ - * / % | Arithmetic |
== != < <= > >= | Comparison |
and or not | Logical (short-circuits) |
& | ^ << >> | Bitwise |
in not in | Membership test |
x => body | Single-parameter closure |
(a, b) => body | Multi-parameter closure |
a..b | Exclusive integer range (gives a,a+1,...,b-1 — b itself is NOT included) |
+= -= *= /= %= | Compound assignment |
matches | Regex test: s matches "pattern" |
Built-in functions — core
| Function | Description |
|---|---|
print(v) | Print value followed by newline |
str(v) | Convert any value to string |
int(v) | Convert string/float to int (truncates) |
float(v) | Convert string/int to float |
bool(v) | Convert to bool |
len(v) | Length of string, list, dict, or bytes |
type_of(v) | Get type name as string |
Built-in functions — collections
| Function | Description |
|---|---|
push(list, v) | Append to list |
pop(list) | Remove and return last element |
insert(list, i, v) | Insert at index |
remove(list, v) | Remove first occurrence of value |
remove_at(list, i) | Remove at index |
sort(list) | Sort in place (ascending) |
reverse(list) | Return a new reversed list (does not modify original) |
sort_by(list, keyfn) | Sort ascending by the key each element maps to (stable) |
keys(dict) | List of dict keys |
values(dict) | List of dict values |
delete(dict, key) | Remove key from dict |
contains(v, x) | Test element/key presence |
merge(d1, d2) | Merge two dicts (d2 wins on conflict) |
map(list, f) | Apply f to each element, return new list |
filter(list, f) | Keep elements where f returns true |
reduce(list, init, f) | Fold to single value |
flatten(lists) | Flatten list of lists |
sum(list) | Sum integer list |
join(list, sep) | Join string list with separator |
enumerate(list) | List of [index, value] pairs |
Built-in functions — strings
| Function | Description |
|---|---|
split(s, sep) | Split by separator, returns list |
find(s, sub) | Index of first occurrence (-1 if not found) |
slice(s, a, b) | Substring from a to b (exclusive) |
upper(s) / lower(s) | Case conversion |
trim(s) | Remove leading/trailing whitespace |
ltrim(s) / rstrip(s) | Left/right whitespace trim |
replace(s, from, to) | Replace all occurrences |
starts_with(s, p) | Test prefix match |
ends_with(s, p) | Test suffix match |
char_at(s, i) | Character at index (supports negative) |
ord(c) / chr(n) | Character ↔ code point conversion |
repeat(s, n) | Repeat string n times |
pad_left(s, w, c) | Left-pad to width with character c |
pad_right(s, w, c) | Right-pad to width with character c |
center(s, w, c) | Center-pad to width with character c |
Built-in functions — math
| Function | Description |
|---|---|
abs(x) | Absolute value |
min(a, b) / max(a, b) | Minimum / maximum |
sqrt(x) | Square root |
pow(base, exp) | Power (base ^ exp) |
floor(x) / ceil(x) | Floor / ceiling |
round(x) | Round to nearest integer |
sin(x) / cos(x) / tan(x) | Trigonometric (radians) |
asin(x) / acos(x) / atan(x) | Inverse trigonometric |
atan2(y, x) | Two-argument arctangent |
exp(x) / log(x) | Exponential / natural log |
log2(x) / log10(x) | Base-2 / base-10 log |
hypot(a, b) | Hypotenuse: sqrt(a² + b²) |
Built-in functions — I/O and file system
| Function | Description |
|---|---|
read_file(path) | Read entire file as string |
write_file(path, s) | Write string to file (overwrites) |
append_file(path, s) | Append to file |
file_exists(path) | Check if file exists |
file_size(path) | File size in bytes |
mkdir(path) | Create directory |
mkdir_p(path) | Create nested directories (like mkdir -p) |
list_dir(path) | List directory contents as string list |
cwd() | Current working directory |
path_join(a, b) | Join path components with OS separator |
path_ext(path) | File extension (e.g., ".nova") |
Built-in functions — concurrency
| Function | Description |
|---|---|
channel() | Create unbounded channel |
channel_bounded(n) | Create bounded channel (blocks sender at capacity) |
send(ch, v) | Send value on channel (deep-copies the value) |
receive(ch) | Receive value from channel (blocks green task) |
select(ch1, ch2) | Wait on multiple channels — first ready wins |
monitor(pid) | Watch for task completion |
reschedule() | Yield current green task, let others run |
pmap(list, f) | Parallel map — apply f to each element concurrently |
String interpolation
name = "world" print("Hello, {name}!") // Hello, world! print("1 + 1 = {1 + 1}") // 1 + 1 = 2 print("len={len(name)}") // len=5 print("escaped: \{not code\}") // escaped: {not code}
1 + 1 = 2
len=5
escaped: {not code}
String interpolation is always active — no f"..." prefix needed like in Python. Any expression can appear inside { }. To write a literal brace, escape it with \{ and \}.
Appendix B: Common patterns
Real-world patterns you will use repeatedly in NOVA programs. Each includes a full line-by-line explanation. These patterns are language-agnostic at the conceptual level — if you have seen them in Python, Go, or Java, the NOVA version will look familiar. The difference is that NOVA's channel model often makes the implementation shorter and clearer than languages that use explicit locks or callbacks.
Patterns in this appendix: Config parsing, retry with backoff, worker pool, pipeline, supervisor/restart, cache with TTL, graceful shutdown, rate limiter, circuit breaker, event bus (pub/sub), connection pool, debounce, throttle, batch processing.
Parse a config file
Config files use a simple key = value format with # for comments. This function parses such files:
fn parse_config(path: string) -> Result content = try read_file(path) result = {} for line in split(content, "\n") line = trim(line) if len(line) == 0 or starts_with(line, "#") continue eq = find(line, "=") if eq < 0 continue key = trim(slice(line, 0, eq)) val = trim(slice(line, eq + 1, len(line))) result[key] = val ok(result) match parse_config("app.conf") Ok(cfg) => host = if contains(cfg, "host") then cfg["host"] else "localhost" port = if contains(cfg, "port") then int(cfg["port"]) else 8080 print("connecting to {host}:{port}") Err(e) => print("config error: {e}")
Line-by-line:
content = try read_file(path)— Reads the file. If missing,trypropagates the error and the function returnsErr(...).if len(line) == 0 or starts_with(line, "#")— Skips empty lines and comment lines.eq = find(line, "=")— Finds the=position. Returns-1if not present.key = trim(slice(line, 0, eq))— Extracts everything before=and strips whitespace.val = trim(slice(line, eq + 1, len(line)))— Everything after=, trimmed.if contains(cfg, "host") then cfg["host"] else "localhost"— Safe key access with a default value.
Retry with exponential backoff
When a network operation might fail (database connection, API call), retry it with increasing delays:
fn with_retry(max_attempts: int, f) attempt = 0 last_err = "unknown error" while attempt < max_attempts r = f() match r Ok(v) => return ok(v) Err(e) => last_err = e attempt = attempt + 1 if attempt < max_attempts sleep_ms(100 * attempt) // 100ms, 200ms, 300ms err("failed after {max_attempts} attempts: {last_err}") result = with_retry(3, fn() connect_to_database("mydb.db")) match result Ok(db) => print("connected") Err(e) => print("gave up: {e}")
Line-by-line: r = f() calls the function on each attempt. Ok(v) => return ok(v) — success exits immediately. sleep_ms(100 * attempt) is linear backoff (100ms → 200ms → 300ms). For true exponential: use sleep_ms(int(pow(2.0, float(attempt))) * 100). fn() connect_to_database(...) wraps the call in a closure so it can be retried.
Pipeline — chaining transformations
A pipeline passes data through a series of transformation functions, one after another:
fn pipeline(data, steps) result = data for step in steps result = step(result) result processed = pipeline(raw_items, [ items => filter(items, item => item.active), items => map(items, item => normalize(item)), items => sort(items), items => items[0:100] ])
Line-by-line: result = step(result) — each step receives the previous step's output. Step 1 filters, step 2 maps, step 3 sorts, step 4 takes first 100. This is a composable functional pipeline — easy to add or remove steps.
Worker pool — parallel work distribution
Spawn N green tasks that all pull work from a shared channel — the standard pattern for CPU-bound parallelism:
fn worker_pool(num_workers: int, jobs: list, handler) -> list work_ch = channel() result_ch = channel() for _ in 0..num_workers spawn loop job = recv(work_ch) if job == -1: break // poison pill — worker exits send(result_ch, handler(job)) for job in jobs send(work_ch, job) // distribute jobs to workers for _ in 0..num_workers send(work_ch, -1) // one poison pill per worker results = [] for _ in 0..len(jobs) push(results, recv(result_ch)) results results = worker_pool(4, [1, 2, 3, 4, 5, 6, 7, 8], x => x * x) print(results) // [1, 4, 9, 16, 25, 36, 49, 64]
Line-by-line:
work_ch / result_ch— Two channels: one for distributing jobs, one for collecting results.for _ in 0..num_workers→spawn— Spawns N workers (..is exclusive, so0..num_workersis exactlynum_workersiterations). Each runs alooppulling fromwork_ch.job = recv(work_ch)— Multiple workers compete for the same channel — whichever is free gets the next job.if job == -1: break— The-1"poison pill" is the signal for a worker to exit.- Third
forloop: send one poison pill per worker so every worker eventually exits. This count MUST match the number spawned exactly — one pill short and a worker blocks onrecv(work_ch)forever; the loop bound is0..num_workersfor the same reason as the spawn loop. - Final
forloop: collect alllen(jobs)results (one per job, order not guaranteed) — again0..len(jobs), notlen(jobs) - 1, or the last result is silently dropped.
Group-by / build a map from a list
// Group orders by customer orders = [{"user": "alice", "total": 50}, {"user": "bob", "total": 20}, {"user": "alice", "total": 30}] by_user = {} for o in orders u = o["user"] if not contains(by_user, u): by_user[u] = [] push(by_user[u], o) // by_user["alice"] = [{user: alice, total: 50}, {user: alice, total: 30}]
Concurrent fetch — pmap for parallel HTTP requests
urls = ["http://api1.com/data", "http://api2.com/data", "http://api3.com/data"] // Fetch all concurrently, results in same order as input results = pmap(urls, url => http_get(url)) for body in results print(len(body), "bytes")
pmap is simpler than the worker pool for pure I/O work: it spawns one green task per item and collects all results in input order. Use the worker pool pattern instead when you need to limit concurrency (e.g., at most 4 concurrent database connections).
Error propagation chain
fn process_config(path) text = try read_file(path) // Err if file not found data = try from_json(text) // Err if invalid JSON port = try parse_int(data["port"]) // Err if port not an integer ok(port) match process_config("config.json") Ok(port) => print("Starting on port {port}") Err(e) => print("Config error: {e}")
Each try short-circuits the whole function on failure, propagating the error to the caller. The caller handles ONE error from ANY step — no need for nested error handling.
HTTP API client helper
A reusable wrapper around HTTP calls that handles JSON encoding/decoding automatically:
fn api_get(base_url, path) resp = http_get(base_url + path) json_decode(resp) fn api_post(base_url, path, data) resp = http_post(base_url + path, json_encode(data)) json_decode(resp) // Usage users = api_get("http://localhost:8080", "/api/users") for user in users print(user["name"]) new_user = api_post("http://localhost:8080", "/api/users", {"name": "Alice"}) print(new_user["id"])
http_get(url)— Returns the response body as a string.json_decode(resp)— Parses the JSON string into a NOVA dict or list. This is the return value ofapi_get.http_post(url, json_encode(data))— Encodes the NOVA dict to{"name":"Alice"}, sends it as the POST body, returns the response body string.
State machine — modeling complex workflows
A state machine is a program that can be in one of several states, transitioning between them based on events. Enums make the state explicit and the compiler ensures you handle every case:
enum State Idle() Running(progress: int) Done(result: string) Failed(error: string) fn tick(state) match state Idle() => Running(0) // start Running(p) => if p >= 100: Done("completed") else: Running(p + 10) Done(r) => Done(r) // terminal state Failed(e) => Failed(e) // terminal state state = Idle() while true match state Done(r) => print("Finished: {r}"); break Failed(e) => print("Error: {e}"); break _ => state = tick(state)
enum State— All possible states are named. Each variant carries different data:Runninghas a progress %,Donehas a result string.fn tick(state)— A pure function that takes the current state and returns the next state. No side effects, no mutation.Idle() => Running(0)— Transition rule: when idle, start running at 0%.Running(p) => if p >= 100: Done("completed") else: Running(p + 10)— If progress reached 100, transition to Done. Otherwise increment progress.Done(r) => Done(r)— Terminal state: once done, stay done. The state machine stops changing.- The
while trueloop callstickuntil Done or Failed, then breaks. Progress goes: Idle → Running(0) → Running(10) → ... → Running(100) → Done.
Graceful shutdown — SIGINT/SIGTERM handling
Long-running servers need to handle shutdown signals without dropping in-flight requests. tcp_accept has no timeout parameter — it blocks until a connection arrives — so polling shutdown_requested() from the SAME loop that calls it doesn't work; the accept loop could be parked for minutes waiting for the next connection. The real pattern (this is what Forge's own server loop does): a SEPARATE watcher task polls the shutdown flag and closes the listener socket out from under the blocking accept call, which is what actually wakes it up:
// Runs as its own green task. Closing the listener is what unblocks tcp_accept below. fn shutdown_watcher(server) while not shutdown_requested() sleep_ms(100) print("Shutdown signal received — closing listener") tcp_close(server) fn main() server = tcp_listen("0.0.0.0", 8080) print("Server running — press Ctrl+C to stop") spawn fn() shutdown_watcher(server) loop conn = tcp_accept(server) // blocks until a client connects OR the listener is closed if conn < 0: break // negative return = listener was closed — shutdown in progress spawn fn() handle_request(conn) print("Server stopped cleanly")
Line-by-line:
shutdown_requested()— returnstrueafter the process receives SIGINT (Ctrl+C) or SIGTERM. NOVA sets this flag before running any cleanup handlers.shutdown_watcher(server)— runs concurrently (spawned before the accept loop starts) purely to poll the shutdown flag every 100ms. It doesn't touch requests at all — its only job is callingtcp_close(server)once shutdown begins.tcp_close(server)— closing the listener socket is what unblocks atcp_acceptcall that's currently parked waiting for the next connection; there's no separate timeout mechanism to wait on.if conn < 0: break—tcp_acceptreturns a negative value when the listener was closed instead of a client connecting. This is the loop's only exit condition — a genuine new connection is always>= 0.- When shutdown is requested: the watcher closes the listener, the accept loop's
breakfires on the next unblock, in-flightspawned handlers finish naturally (green tasks are not killed), then "Server stopped cleanly" prints.
DON'T: Reach for a tcp_accept_timeout(listener, ms) function — it doesn't exist; tcp_accept takes exactly one argument and always blocks. DO: Use a separate watcher task that closes the listener — this is exactly what Forge's own built-in server does internally, so forge.serve() already handles graceful shutdown for you; the pattern above is what to reach for only when you're calling tcp_listen/tcp_accept directly instead of using Forge.
Registering cleanup with at_exit
The watcher above closes exactly one resource — the listener socket — by hand. Real long-running programs usually accumulate more than one: a database connection, an open log file, a metrics buffer that needs a final flush before the process disappears. Remembering to close every one of them at every possible exit point — falling off the end of main(), an explicit exit(), or a Ctrl+C — is exactly the kind of bookkeeping that gets forgotten under pressure. at_exit(fn) registers a callback once, right next to wherever the resource was created, and NOVA guarantees it runs before the process actually terminates — no matter which of those three ways triggered the exit.
fn main() log_file = file_open("server.log") at_exit(fn() file_close(log_file)) // guaranteed to run, however the process exits metrics = buffer_create() at_exit(fn() print("Flushed {buf_len(metrics)} bytes of metrics")) print("Server running -- press Ctrl+C to stop") while not shutdown_requested() sleep_ms(100) print("Shutdown requested -- both at_exit callbacks now run automatically")
Shutdown requested -- both at_exit callbacks now run automatically
Flushed 0 bytes of metrics
DO: Register an at_exit callback right next to the line that opens the resource — the same locality as Go's defer, just scoped to the whole process instead of one function call. DON'T: Treat at_exit as a substitute for closing resources you're actually done with well before shutdown — it only guarantees cleanup runs once, at the very end; a server that only ever releases idle connections via at_exit will hold onto every one of them for its entire runtime.
Reloading configuration on SIGHUP
shutdown_requested() has a gentler sibling: reload_requested(), tied to SIGHUP instead of SIGINT/SIGTERM. Long-running Unix services have traditionally used SIGHUP (kill -HUP <pid>) to mean "re-read your configuration, don't restart" — and reload_requested() returns 1 exactly once per SIGHUP received since it was last polled, because it consumes the flag on read. That's what makes it safe to poll in a tight loop: each SIGHUP fires the reload logic exactly once, never twice, and never gets silently missed between polls the way a plain boolean flag could if two SIGHUPs arrived close together. Unlike shutdown_requested() — typically checked once, as a loop's exit condition — reload_requested() is meant to be polled continuously for the entire lifetime of the process.
fn read_config() return from_json(read_file("config.json")) fn main() config = read_config() print("Loaded config: max_conns = {config["max_conns"]}") while not shutdown_requested() if reload_requested() config = read_config() print("Config reloaded: max_conns = {config["max_conns"]}") sleep_ms(100) print("Shutting down")
Config reloaded: max_conns = 200
Shutting down
DO: Poll reload_requested() from inside the same long-running loop where you already poll shutdown_requested() — it's designed to be checked every iteration, not just once at startup. DON'T: Poll reload_requested() from more than one task or loop — since it consumes the flag on read, only the first caller to poll after a given SIGHUP ever sees the 1; the signal isn't broadcast to every reader, it's delivered exactly once to whoever asks first.
Rate limiting — token bucket
Prevent abuse by limiting how many requests a client can make per time window. The token bucket algorithm: a bucket holds N tokens, each request consumes one, tokens refill at a fixed rate:
type RateLimiter max_tokens: int // bucket capacity tokens: int // current token count refill_ms: int // ms between refills last_refill: int // timestamp of last refill fn make_limiter(max_tokens, refill_ms) RateLimiter{ max_tokens: max_tokens, tokens: max_tokens, refill_ms: refill_ms, last_refill: time_ms() } fn allow(limiter) now = time_ms() elapsed = now - limiter.last_refill if elapsed >= limiter.refill_ms limiter.tokens = limiter.max_tokens // refill to full limiter.last_refill = now if limiter.tokens > 0 limiter.tokens = limiter.tokens - 1 return true // request allowed false // bucket empty — reject request // Usage: 10 requests per second per client IP limiters = {} // ip -> RateLimiter fn handle_request(req) ip = req["ip"] if not contains(limiters, ip) limiters[ip] = make_limiter(10, 1000) // 10 tokens, refill every 1s if not allow(limiters[ip]) return "429 Too Many Requests" process_request(req)
Key design decisions: The token count is per-IP, stored in a dict. When the bucket is empty (tokens == 0), requests are rejected with HTTP 429. The refill happens lazily — only when a request arrives, we check if enough time has elapsed. This avoids background timers entirely.
In Forge: Use the built-in middleware: use(app, mw_rate_limit(10, 1000)) — 10 requests per second, per IP. The pattern above shows what the middleware does internally.
Circuit breaker — fail fast when a service is down
A circuit breaker prevents cascading failures. When a downstream service fails N times in a row, the circuit "opens" and all requests fail immediately (without waiting for a timeout) until the service recovers:
type CircuitBreaker failures: int max_failures: int open_until: int // timestamp when circuit can close again open_ms: int // how long to stay open after tripping fn make_breaker(max_failures, open_ms) CircuitBreaker{failures: 0, max_failures: max_failures, open_until: 0, open_ms: open_ms} fn call(breaker, f) if time_ms() < breaker.open_until return err("circuit open — service unavailable") result = f() match result Ok(v) => breaker.failures = 0 // success resets failure count ok(v) Err(e) => breaker.failures = breaker.failures + 1 if breaker.failures >= breaker.max_failures breaker.open_until = time_ms() + breaker.open_ms print("Circuit OPEN — will retry after {breaker.open_ms}ms") err(e) // Usage db_breaker = make_breaker(3, 10000) // open after 3 failures, stay open 10s result = call(db_breaker, fn() query_database()) match result Ok(data) => print(data) Err(e) => print("Database unavailable: {e}")
Three states: CLOSED (normal, requests pass through), OPEN (too many failures, fail immediately), HALF-OPEN (after cooldown, allow one test request). The pattern above implements CLOSED and OPEN. For HALF-OPEN, check time_ms() >= breaker.open_until and allow exactly one request — if it succeeds, reset; if it fails, extend the open period.
Event bus — pub/sub within a process
Decouple components by publishing events on a bus. Subscribers receive events without knowing who sent them:
// Event bus: map of event name -> list of subscriber channels bus = {} fn subscribe(bus, event) if not contains(bus, event): bus[event] = [] ch = channel() push(bus[event], ch) ch // return channel so subscriber can receive fn publish(bus, event, data) if contains(bus, event) for ch in bus[event] spawn fn() send(ch, data) // non-blocking send per subscriber // Publisher ch_login = subscribe(bus, "user.login") ch_purchase = subscribe(bus, "order.placed") // Analytics subscriber spawn fn() loop event = recv(ch_login) log_info("Analytics: user login — {event}") // Emit events publish(bus, "user.login", {"user": "alice", "ts": time_ms()}) publish(bus, "order.placed", {"order_id": 42, "total": 99})
Why use an event bus? The login handler does not need to know about the analytics system. The analytics system does not need to know about the auth module. Both talk through a named event. Adding a new subscriber (audit log, email notification) requires zero changes to the publisher — just one new subscribe call.
Connection pool — reusing expensive resources
Opening a database connection is slow (tens of milliseconds). A connection pool creates a fixed number of connections upfront and loans them to tasks on demand:
fn make_pool(size, connect_fn) ch = channel_bounded(size) // bounded channel acts as semaphore for _ in 0..size // exclusive range — 0..size is exactly `size` connections, not size-1 send(ch, connect_fn()) // pre-create all connections ch fn with_conn(pool, f) conn = recv(pool) // borrow a connection (blocks if all busy) result = f(conn) send(pool, conn) // return connection when done result // Usage — connect_fn is YOUR resource-opening function; make_pool/with_conn are generic // over any expensive resource, not tied to a specific database API (see §26 for the real // sqlitex/forge_db functions if the resource specifically is a SQLite connection) pool = make_pool(5, fn() sqlitex.db_open("myapp.db")) // Multiple tasks share the pool — never more than 5 concurrent connections spawn fn() result = with_conn(pool, fn(conn) sqlitex.db_query(conn, "SELECT * FROM users", [])) print(result)
How it works: The bounded channel of size N contains N connections. recv(pool) borrows one — if all N are in use, the task parks and waits. send(pool, conn) returns the connection so another task can use it. The channel's blocking behavior automatically implements the "wait for available connection" semantics. This is the canonical NOVA pool pattern: O(1) code, zero locks, correct by construction.
Debounce — run only after events stop arriving
Debouncing prevents a function from running on every rapid-fire event. Instead, it waits until the events stop arriving for a quiet period, then runs once. Classic use case: search-as-you-type (don't hit the API on every keystroke, wait until the user stops typing).
fn make_debounce(action, delay_ms) // Returns a fn. Calling it resets the timer. Action fires only after quiet period. trigger_ch = channel_bounded(1) // bounded so fast calls don't queue up; carries plain ints, not bools spawn fn() // debounce worker loop recv(trigger_ch) // wait for first trigger loop next = recv_timeout(trigger_ch, delay_ms) if next == -1: break // -1 is the timeout sentinel — quiet for delay_ms, fire! action() fn() // Non-blocking send: try_send returns 1 if the trigger was enqueued, 0 if the // bounded channel is already full — either way we don't care about the result here try_send(trigger_ch, 1) // Usage: search triggered at most 300ms after last keystroke search = make_debounce(fn() do_search(current_query), 300) // Call search() on every keystroke — it intelligently delays: search() // user types 'h' search() // user types 'e' (20ms later) search() // user types 'l' (40ms later) // ... user pauses for 300ms ... // do_search fires exactly once here
Inner loop explained: After the first trigger arrives, the inner loop keeps draining the channel with a 300ms timeout. If another trigger arrives within 300ms, the inner loop continues — resetting the quiet period. When 300ms passes with no new trigger, recv_timeout returns -1 and the inner loop breaks, firing the action. This is exactly how browser-side JavaScript debounce libraries work, implemented in pure NOVA with channels.
DON'T: Compare a recv_timeout result to null — it never returns null; on timeout (or a closed, empty channel) it returns the sentinel -1, regardless of the channel's element type. Checking == null silently never matches, so a loop written that way never breaks and hangs forever waiting for a message that isn't coming. DO: Compare against -1 explicitly, and keep timeout-polled channels carrying plain ints so -1 can never collide with a legitimate payload value.
Throttle — run at most once per interval
Throttle is the complement of debounce. Where debounce fires AFTER events stop, throttle fires AT MOST ONCE per time window regardless of how many events arrive. Use case: rate-limiting API calls, updating a UI display at most 60fps, sending metrics at most once per second:
fn make_throttle(action, interval_ms) last_run = 0 fn() now = time_ms() if now - last_run >= interval_ms last_run = now action() // else: too soon, skip this call // Throttled metrics sender: no matter how often called, sends at most 1/sec send_metrics = make_throttle(fn() push_metrics_to_server(), 1000) // These all happen within 200ms — only the first fires send_metrics() // fires: last_run was 0 send_metrics() // skipped: only 50ms since last run send_metrics() // skipped: only 120ms since last run
Debounce vs Throttle:
| Pattern | When action fires | Use case |
|---|---|---|
| Debounce (300ms) | 300ms after the LAST event | Search-as-you-type, resize handlers |
| Throttle (1000ms) | At most once per 1000ms, on the FIRST event | Metrics, API rate limiting, 60fps UI updates |
| No limiting | On every event | Only when every event matters and processing is cheap |
Batch processing — accumulate then process
Batch processing waits until N items accumulate (or a timeout expires), then processes all at once. Common in database writes (batch INSERTs are 10-100× faster than individual INSERTs), log shipping (send 1 HTTP request with 100 events instead of 100 requests), and analytics aggregation:
fn make_batcher(flush_fn, max_size, max_wait_ms) input_ch = channel() spawn fn() loop batch = [] deadline = time_ms() + max_wait_ms while len(batch) < max_size remaining = deadline - time_ms() if remaining <= 0: break // select_timeout returns [index, value] — index is -1 on timeout, value is // only meaningful otherwise. This is the SAFE timeout form for a channel // carrying dicts/structs: recv_timeout's bare -1 sentinel can't be told apart // from a real payload once the payload isn't an int; select_timeout keeps the // timeout signal in a separate slot (r[0]) instead of overloading r[1]. r = select_timeout(input_ch, remaining) if r[0] == -1: break // timeout — flush what we have push(batch, r[1]) if len(batch) > 0 flush_fn(batch) input_ch // return the channel to send items into // Batch writes: flush when 100 items ready OR 500ms has passed write_ch = make_batcher(fn(batch) db_batch_insert(batch), 100, 500) send(write_ch, {"user": "alice", "event": "login"}) send(write_ch, {"user": "bob", "event": "purchase"})
The batcher either flushes when the batch reaches max_size items, or when max_wait_ms milliseconds pass with fewer items. This "size OR time" dual condition is the standard batch-processing contract: latency is bounded (you always flush within max_wait_ms), and throughput is maximized (you use the full batch size when events are fast).
Appendix C: Troubleshooting
If you encounter an error, search for it here first.
"cannot find module 'forge'"
What it means: The compiler cannot locate forge.nova in the standard library.
How to fix: Check that NOVA_HOME is set and points to the directory containing lib/:
ls $NOVA_HOME/lib/forge.nova
If the file is missing, set NOVA_HOME to the NOVA installation root:
# Linux/macOS: export NOVA_HOME=/path/to/nova # Windows (PowerShell): $env:NOVA_HOME = "C:\path\to\nova"
"type mismatch: expected float, got Int"
What it means: A struct field uses a capital type name (Int, Float) — these are dynamic "any" types, not concrete numeric types.
How to fix: Change all capital type names in your type blocks to lowercase:
// WRONG — capital Float is a dynamic type (150× slower) type Vec2 x: Float // CORRECT — lowercase float is a concrete 64-bit float (C speed) type Vec2 x: float
Green task hangs or starves other tasks
What it means: A CPU-bound task runs in a tight loop without yielding. Green tasks are cooperatively scheduled — a task that never yields prevents others on the same thread from running.
How to fix: Add reschedule() inside any long-running loop:
spawn fn() i = 0 while i < 10_000_000 i = i + 1 if i % 100_000 == 0 reschedule() // let other tasks run
reschedule() costs ~1 microsecond and lets the scheduler run other waiting tasks. Every 100,000 iterations is a good default for CPU-heavy loops.
"arena object not found in heap"
What it means: A value was created in a Forge request handler's arena (temporary per-request memory freed at request end) but something uses it after the arena was freed.
How to fix: Store long-lived values (caches, counters, sessions) at module scope, not inside handler functions:
// WRONG — cache is per-request arena, freed after response forge.get(app, "/data", fn(req) cache = {} // dies at end of request! ... ) // CORRECT — cache is module scope, lives forever cache = {} forge.get(app, "/data", fn(req) cache["key"] = "value" // module-scope dict, survives request forge.json(cache) )
"index N out of bounds"
What it means: You accessed element N of a list or string that has fewer than N+1 elements. Indices start at 0, so a list of 5 elements has valid indices 0–4.
How to fix: Always check len() before accessing:
if i < len(items) print(items[i]) else print("index {i} out of range, list has {len(items)} items")
Function returns unexpected 0 or empty string
What it means: The last expression in your function is print() or an assignment — both return null (which appears as 0 or "").
// BUG — print() is the last line, so the function returns null fn add(a, b) print(a + b) // FIX — the value to return is the last line fn add(a, b) a + b
Key rule: The last line of a function is its return value. If you want to both compute and print, put the expression last:
fn add(a, b) result = a + b print("sum = {result}") result // last line — this is what the function returns
Channel receive blocks forever
Common causes: The spawned task crashed before sending; you are receiving on the wrong channel (variable name typo); the sender exited without sending.
How to fix: Use a timeout to avoid hanging indefinitely. recv_timeout(ch, ms) returns the sentinel -1 on timeout — NOT null — so check against -1:
result = recv_timeout(ch, 5000) // wait up to 5 seconds if result == -1 print("timed out — no value received after 5 seconds")
If the channel carries something other than a plain int (a dict, struct, or string), -1 can't reliably stand in for "no value" — use r = select_timeout(ch, 5000); if r[0] == -1 { ... } else { value = r[1] } instead, which reports the timeout in a separate slot from the payload.
"variable 'x' is not defined"
Common causes: typo in variable name; variable defined inside an if block used outside; variable defined in a different function.
// WRONG — result only defined inside if if condition result = compute() print(result) // Error: result not defined if condition was false // CORRECT — define before, assign inside result = null if condition result = compute() print(result) // Works: null or the computed value
Quick reference table
| Problem | Root cause | Fix |
|---|---|---|
| Struct math 150× slower than expected | Capital type on struct field — e.g. x: Float | Change to lowercase: x: float |
&& or || causes syntax error | Wrong boolean operators | Use and, or, not |
String { interpolates unexpectedly | All NOVA strings interpolate by default | Use \{ for a literal brace |
7 / 2 gives 3 not 3.5 | Integer division truncates | Use 7.0 / 2.0 for float result |
Float comparison with == fails intermittently | IEEE 754 rounding — 0.1 + 0.2 ≠ 0.3 | Use abs(a - b) < 0.000001 |
| String building in a loop is slow | s = s + piece is O(n²) | push(parts, piece), then join(parts, "") at end |
| Stack overflow on deep recursion | 32KB fixed stack, ~3000-deep limit | Convert to explicit loop with stack list |
| Dict lookup returns wrong type | Dict values are typed dynamically | Cast explicitly: int(d["count"]) |
Range 0..4 stops before 4 | NOVA ranges are EXCLUSIVE of the right bound — 0..4 gives 0,1,2,3, never 4 | Use 0..len(list) to visit every index — NOT 0..len(list) - 1, which drops the last element |
| Closure captures wrong value | Closures capture by VALUE at creation time | Correct behavior — no late binding like Python |
Appendix D: Standard library modules
NOVA ships with 40+ standard library modules in $NOVA_HOME/lib/. Import with import module_name. Organized by category.
Web framework — building web applications
| Module | What it does | When to use |
|---|---|---|
forge | Full HTTP server with routing, middleware, WebSocket, SSE, JSON helpers, query/body parsing, cookies | Any web application or REST API — the main Forge module |
forge_db | Database connection pooling, query helpers, transaction support | When your web app needs to talk to SQLite or PostgreSQL |
forge_auth | JWT creation/verification, CSRF protection, session management | User authentication and authorization |
forge_html | HTML builder functions (h, p, div, ul, li, a, form, table) — generate HTML without template strings | Build HTML pages programmatically, auto-escape user content |
forge_otp | OTP-style supervisors, agents (stateful processes), job queues | Fault-tolerant process supervision (restart crashed processes) |
forge_dist | Distributed Forge — run across multiple servers | Horizontal scaling when one server is not enough |
forge_mq | Message queue — asynchronous task processing | Background job processing (sending emails, processing uploads) |
forge_compress | Response compression middleware (gzip) | Reduce response sizes by 60-80% |
forge_pg | PostgreSQL database adapter | When you need PostgreSQL instead of SQLite |
forge_mysql | MySQL database adapter (pooled) | When you need MySQL instead of SQLite/PostgreSQL |
forge_orm | Agnostic ORM over SQLite/PostgreSQL/MySQL — struct-driven CRUD, fluent query builder, migrations, transactions, N+1-safe relation loading | Persisting and querying structs without writing per-database SQL (see Chapter 26) |
Cryptography and security
All cryptography modules are written in pure NOVA (no C library dependencies). They pass standard KAT (Known Answer Test) vectors.
| Module | What it does | When to use |
|---|---|---|
forge_crypto | SHA-256/512/384/MD5, HMAC, PBKDF2, HKDF, AES-CTR/GCM, ChaCha20-Poly1305, X25519 key exchange, Ed25519 signatures | Any time you need to hash, encrypt, sign, or derive keys |
forge_x509 | X.509 certificate parsing and field extraction | Reading SSL/TLS certificates |
forge_p256 | ECDSA P-256 signature verification | Verify P-256 signatures (common in web PKI and JWTs) |
forge_rsa | RSA PKCS#1 v1.5 and PSS signature verification | Verify RSA signatures (common in JWT tokens and certificates) |
forge_tls | TLS 1.3 key schedule, record layer, handshake, Finished verification | Building blocks for TLS 1.3 connections |
forge_chain | X.509 certificate chain validation (PKI) | Verify that a certificate is signed by a trusted CA |
Data and math
| Module | What it does | When to use |
|---|---|---|
bignum | Arbitrary-precision integers — numbers with hundreds of digits | Cryptography, financial calculations, combinatorics |
complexnum | Complex number arithmetic (add, mul, abs, conjugate) | Signal processing, physics, fractal rendering |
rational | Exact rational fractions (no floating-point rounding) | Financial calculations, symbolic math |
matrixx | Matrix operations (multiply, transpose, determinant, inverse) | Linear algebra, transformations, physics |
prng | Seedable pseudorandom number generator (xoshiro256**) | Simulations, games, deterministic testing |
bitset | Compact bit arrays (set/clear/test, union, intersection) | Compact boolean arrays, permission flags, Bloom filters |
Text and encoding
| Module | What it does | When to use |
|---|---|---|
strx | Extended string operations (word wrap, title case, levenshtein distance) | When built-in string functions are not enough |
basex | Base32, Base58, Base64 encoding and decoding | Encode binary data as text (email, URLs, cryptocurrency) |
graphemex | Unicode grapheme cluster operations — correctly handle emojis, accented letters | Correct Unicode text handling beyond ASCII |
csvx | CSV parsing and generation | Reading and writing spreadsheet-compatible data files |
urlx | URL parsing, query string encoding/decoding, path manipulation | Construct or decompose URLs |
deflatex | DEFLATE compression and decompression | gzip/ZIP file handling |
Utilities
| Module | What it does | When to use |
|---|---|---|
corex | Core utilities (deep equality, deep clone, type checks) | General-purpose programming |
collx | Collection utilities (group_by, chunk, zip, interleave, frequencies) | List/dict transformations beyond map/filter/reduce |
setops | Set operations (union, intersection, difference) on lists | Mathematical set operations |
getin | Nested data access (get_in, assoc_in, update_in) for deep dicts/lists | Complex JSON structures: get_in(data, ["users", 0, "address", "city"]) |
uuid | UUID v4 generation | Unique IDs for database records, session tokens |
proptest | Property-based testing — generate random inputs and check properties | Testing functions against thousands of random inputs |
Built-in (no import needed): print, len, str, int, float, bool, push, pop, split, join, trim, upper, lower, contains, starts_with, ends_with, slice, sort, reverse, map, filter, reduce, zip, enumerate, min, max, abs, floor, ceil, round, sqrt, log, now_ms, now_ns, sleep_ms, channel, send, recv, select, spawn, read_file, write_file, append_file, file_exists, list_dir, mkdir, http_get, http_post, tcp_listen, tcp_accept, tcp_connect, sha256, sha512, random_bytes, json_encode, from_json_safe, args, env, exec, exit, cwd, pid, log_info, log_warn, log_error, log_debug
Sequence and functional toolkits (std/core, std/functional, std/itertools)
Beyond the always-available builtins in Chapter 10 — map, filter, reduce, sort_by — NOVA ships four more layers of sequence and dict combinators as explicit stdlib imports. Each is imported by its full path (for example import std/core/seq), and once imported its functions are called by their bare, unqualified name — unlike the forge/collx/getin-style modules elsewhere in this appendix, which are called through a module-name prefix like collx.group_by(...). Reach for these once the Chapter 10 builtins stop being enough on their own: std/core/* rounds out the basics with Result-returning and default-aware variants; std/functional/* gives you a point-free combinator toolkit for building pipelines; std/itertools/* ports the shape of Python's itertools (chunking, windowing, run-length encoding, cartesian products) onto plain lists.
import std/core/seq — sequence pipeline with Result-based reduction
| Function | What it does |
|---|---|
seq_fold(xs, init, f) | Fold left with an explicit seed, like reduce |
seq_reduce(xs, f) -> Result | Fold with no seed — takes it from xs[0]; Err on an empty list instead of crashing |
seq_find(xs, pred) -> Result | First element matching pred, as Ok(v)/Err(e) instead of a sentinel value |
seq_any(xs, pred) / seq_all(xs, pred) | Existential / universal quantifiers (same shape as any_match/all_match) |
seq_count(xs, pred) | Count elements matching pred |
seq_take(xs, k) / seq_drop(xs, k) | First/remaining k elements — bounds-clamped, never errors when k > len(xs) |
seq_zip(a, b) | Pair two lists (same as the builtin zip) |
seq_map(xs, f) / seq_filter(xs, pred) / seq_reverse(xs) | Same shape as the Chapter 10 builtins, for pipelines that stay inside this module |
seq_flat_map(xs, f) | Map then flatten one level — the only flat_map in the language; there's no core builtin for it |
import std/core/seq scores = [55, 72, 91, 60] total = seq_fold(scores, 0, (a, x) => a + x) print(total) // 278 match seq_find(scores, x => x > 80) Ok(v) => print(v) // 91 — first score above 80 Err(e) => print("none found")
91
The Result-returning shape of seq_reduce/seq_find is the real reason to reach for this module over the plain builtins: an empty list or a missing match becomes an explicit Err you're forced to handle in a match, instead of a crash or a silently wrong sentinel value.
import std/core/list — generic list combinators
| Function | What it does |
|---|---|
list_contains(xs, target) | Membership test (same as contains/in) |
list_index_of(xs, target) | Same as the builtin index_of |
list_unique(xs) | Deduplicate, keeping first-seen order |
list_concat(a, b) | Concatenate two lists (same as a + b) |
list_flatten(xss) | Flatten one level (module-level equivalent of the builtin flatten) |
list_sum(xs) | Sum a list (same as the builtin sum) |
list_chunk(xs, n) | Split into groups of size n; the last group may be shorter |
import std/core/list tags = ["a", "b", "a", "c", "b", "a"] uniq = list_unique(tags) print(uniq) // [a, b, c] — first-seen order pages = list_chunk([1, 2, 3, 4, 5], 2) print(pages) // [[1, 2], [3, 4], [5]] — last chunk is shorter
[[1, 2], [3, 4], [5]]
import std/core/dict — dict combinators
| Function | What it does |
|---|---|
dict_get_or(d, k, default) | Same as the builtin get |
dict_keys_where(d, pred) | Keys whose VALUE matches pred |
dict_map_values(d, f) | New dict with every value transformed by f — keys untouched, non-mutating |
dict_count(d, pred) | Count entries whose value matches pred |
dict_any_value(d, pred) | True if any value matches pred |
import std/core/dict scoresByName = {"alice": 92, "bob": 78, "carol": 95} top = dict_keys_where(scoresByName, v => v >= 90) print(top) // [alice, carol] curved = dict_map_values(scoresByName, v => v + 5) print(curved["bob"]) // 83 — original scoresByName is unchanged
83
import std/core/sort — stable, non-mutating sorts
| Function | What it does |
|---|---|
sort_ints(xs) | Ascending, non-mutating sort of an int list |
sort_ints_desc(xs) | Descending, non-mutating sort of an int list |
sort_strings(xs) | Lexicographic, non-mutating sort of a string list |
sort_by(xs, less) | Insertion sort driven by a two-argument comparator less(a, b) -> bool |
import std/core/sort readings = [42, 7, 19, 3] print(sort_ints(readings)) // [3, 7, 19, 42] people = [{"name": "Bo", "age": 40}, {"name": "Al", "age": 25}] by_age = sort_by(people, (a, b) => a["age"] < b["age"]) print(by_age[0]["name"]) // Al — youngest first
Al
DO: Reach for this module's sort_by(xs, less) when a two-argument boolean comparator is more natural for your comparison than a key-extraction function. DON'T: Assume the Chapter 10 builtin's sort_by(xs, keyfn) signature still applies once import std/core/sort is in scope — the import SHADOWS the builtin name for the rest of that file with this module's two-argument less(a, b) -> bool form. Pick one signature per file and don't mix them.
std/functional/* — the point-free functional toolkit
Twelve small modules, each centered on one functional idiom, all sharing an (f, xs) argument order — the function comes FIRST, reversed from the (xs, f) order used by the Chapter 10 builtins. That order is what makes these compose cleanly with std/functional/fchain's pipe/compose helpers, where you're chaining functions together rather than calling one function on one list.
Module (import std/functional/...) | Functions |
|---|---|
fmap | fm_map(f, xs) · fm_map_indexed(f, xs) · fm_flat_map(f, xs) |
ffilter | ff_filter(pred, xs) · ff_reject(pred, xs) · ff_partition(pred, xs) -> [kept, rejected] |
freduce | fr_foldl(f, init, xs) · fr_foldr(f, init, xs) · fr_scanl(f, init, xs) (running accumulator, length+1) |
fchain | fch_pipe2/3(x, f, g[, h]) · fch_compose2(x, f, g) · fch_apply_all(x, f, g, h) |
faccumulate | fa_sum_by(f, xs) · fa_product_by(f, xs) · fa_max_by_value(f, xs) · fa_count_where(pred, xs) |
fgroupby | fg_group_by(keyfn, xs) (global groups) · fg_partition_by(pred, xs) (consecutive runs) · fg_count_by(keyfn, xs) |
fpredicate | fpr_all/any/none/count(pred, xs) · fpr_find_index(pred, xs) -> int |
fiterate | fi_apply_n(f, n, x) · fi_iterate(f, x, n) -> [x, f(x), ...] |
fsortby | fs_sort_by(keyfn, xs) · fs_min_by/fs_max_by(keyfn, xs) |
fzipwith | fz_zip_with(f, a, b) · fz_zip_with3(f, a, b, c) · fz_zip_with_index(f, xs) |
ftakewhile | ftw_take_while(pred, xs) · ftw_drop_while(pred, xs) · ftw_span(pred, xs) -> [taken, dropped] |
func | f_identity · f_compose(f, g) · f_pipe2(f, g) · f_const(k) · f_apply_n · f_memoize1(f) (returns a closure) |
fcombinator | fc_flip(f, a, b) · fc_on(f, g, a, b) · fc_apply/fc_apply2 · fc_twice(f, x) |
import std/functional/fgroupby import std/functional/ftakewhile import std/functional/fzipwith grouped = fg_group_by(x => x % 3, [1, 2, 3, 4, 5, 6]) print(grouped[0]) // [3, 6] — remainder 0 prefix = ftw_take_while(x => x < 5, [1, 3, 7, 2]) print(prefix) // [1, 3] — stops at the first element >= 5, even though 2 later would pass sums = fz_zip_with((a, b) => a + b, [1, 2, 3], [10, 20, 30]) print(sums) // [11, 22, 33]
[1, 3]
[11, 22, 33]
std/itertools/* — the itertools toolkit
Eleven small modules porting the shape of Python's itertools onto plain NOVA lists, plus a self-contained itertools aggregator module that re-exports the most common ones under shorter names. Everything here is eager — these build real lists, they don't stream.
Module (import std/itertools/...) | Functions |
|---|---|
it_chunk | itc_chunk(xs, size) · itc_chunk_pad(xs, size, pad) · itc_chunk_count · itc_evenly(xs, n) |
it_flatten | itf_flatten(xss) · itf_concat(a, b) · itf_concat_all(lists) · itf_repeat_concat(xs, n) |
it_group | itg_group_consecutive(xs) · itg_rle(xs) · itg_rle_decode(pairs) · itg_run_lengths(xs) |
it_interleave | iti_interleave(a, b) · iti_intersperse(xs, sep) · iti_roundrobin(lists) · iti_weave(a, b) |
it_partition | itp_split_at(xs, i) · itp_partition_parity(xs) · itp_halve(xs) · itp_split_runs(xs, n) |
it_product | itpr_cartesian(a, b) · itpr_cartesian3 · itpr_count · itpr_pairs_upper(xs) (all i < j pairs) |
it_rotate | itr_rotate_left/right(xs, k) · itr_cycle_take(xs, n) · itr_reverse(xs) |
it_take_drop | itt_take/drop(xs, n) · itt_take_last/drop_last · itt_slice(xs, start, stop) · itt_step(xs, step) |
it_unique | itu_unique(xs) · itu_count_distinct · itu_duplicates(xs) · itu_is_unique(xs) |
it_window | itw_window(xs, k) · itw_pairwise(xs) · itw_windows_count · itw_triples(xs) |
it_zip | itz_zip/zip3 · itz_unzip(pairs) · itz_zip_longest(a, b, fill) · itz_enumerate(xs) |
it_accumulate | ita_sums(xs) (prefix sums) · ita_products · ita_maxima/minima · ita_diffs(xs) |
itertools | it_range · it_chain · it_zip · it_take/drop · it_windowed · it_chunked · it_enumerate · it_flatten · it_repeat (self-contained aggregator surface) |
import std/itertools/it_chunk import std/itertools/it_window import std/itertools/it_zip import std/itertools/it_accumulate rows = itc_chunk([1, 2, 3, 4, 5], 2) print(rows) // [[1, 2], [3, 4], [5]] pairs = itw_pairwise([1, 2, 3, 4]) print(pairs) // [[1, 2], [2, 3], [3, 4]] — every consecutive overlapping pair padded = itz_zip_longest([1, 2, 3], [10, 20], 0) print(padded) // [[1, 10], [2, 20], [3, 0]] — unlike zip, keeps every element from the LONGER list running = ita_sums([1, 2, 3, 4]) print(running) // [1, 3, 6, 10] — prefix sums
[[1, 2], [2, 3], [3, 4]]
[[1, 10], [2, 20], [3, 0]]
[1, 3, 6, 10]
DO: Pick ONE implementation per concept and stick to it within a file — flatten alone exists as the Chapter 10 builtin, std/core/list's list_flatten, and std/itertools/it_flatten's itf_flatten, all doing the same one-level flatten. DON'T: Mix two implementations of the same concept in one file just because both happen to be imported — it makes it unclear at a glance which semantics apply, and if the two ever drift (different edge-case handling for non-list elements, for instance) you'll get behavior that depends on which spelling happened to be used at each call site.
Quick examples: important modules
A 5-line sample of each frequently-used module. These run as-is — copy, add an import, and they work.
import collx — collection utilities
import collx // group_by: group a list into a dict of lists by key function people = [{"name": "Alice", "dept": "Eng"}, {"name": "Bob", "dept": "Mkt"}, {"name": "Carol", "dept": "Eng"}] grouped = collx.group_by(people, p => p["dept"]) print(grouped["Eng"]) // [{name:Alice,...}, {name:Carol,...}] // chunk: split a list into groups of size n pages = collx.chunk([1, 2, 3, 4, 5, 6], 2) print(pages) // [[1,2], [3,4], [5,6]] // frequencies: count occurrences of each element words = ["the", "quick", "the", "fox", "the"] counts = collx.frequencies(words) print(counts["the"]) // 3
import getin — deep nested access
import getin // Navigate deep JSON structures without crashing on missing keys api_response = { "data": {"user": {"address": {"city": "New York"}}} } city = getin.get_in(api_response, ["data", "user", "address", "city"]) print(city) // "New York" (or null if any key is missing) // assoc_in: create updated nested dict (original is unchanged) updated = getin.assoc_in(api_response, ["data", "user", "age"], 30)
import csvx — CSV parsing and generation
import csvx // Parse CSV file → list of dicts (header row becomes keys) rows = csvx.parse_file("data.csv") for row in rows print(row["name"], row["age"]) // Generate CSV from a list of dicts data = [{"name": "Alice", "score": 95}, {"name": "Bob", "score": 87}] csv_text = csvx.generate(data, ["name", "score"]) write_file("output.csv", csv_text) // output.csv: name,score\nAlice,95\nBob,87
import urlx — URL parsing and building
import urlx // Parse a URL into its components u = urlx.parse("https://api.example.com/users?page=2&limit=10") print(u["host"]) // api.example.com print(u["path"]) // /users print(u["query"]["page"]) // 2 // Build a URL from parts full_url = urlx.build("https", "api.example.com", "/search", {"q": "hello world", "limit": "5"}) print(full_url) // https://api.example.com/search?q=hello+world&limit=5
import proptest — property-based testing
import proptest // Test a property: reverse(reverse(xs)) == xs, for any list of ints proptest.check("reverse is its own inverse", 1000, fn() xs = proptest.gen_int_list(0, 100) assert_eq(reverse(reverse(xs)), xs) ) // Runs 1000 random inputs; prints OK or the failing case // generate a random string of printable ASCII s = proptest.gen_string(5, 20) // between 5 and 20 chars n = proptest.gen_int(-1000, 1000) // random int in range
import std/collections/setops — set algebra on plain lists
import std/collections/setops a = [1, 2, 3] b = [3, 4, 5] print(set_union(a, b)) // [1, 2, 3, 4, 5] print(set_intersection(a, b)) // [3] print(set_difference(a, b)) // [1, 2] — in a, not in b print(is_subset([1, 2], a)) // true
[3]
[1, 2]
true
Reach for this module instead of a hand-rolled Set (Chapter 10) when the data is already plain lists and you only need one-off set algebra on them — no separate set object to build and tear down. It preserves first-seen order and de-duplicates by ==, so the output is deterministic even though the underlying operation is set math over ordinary lists.
If a snippet in this tutorial doesn't compile, that's a bug in NOVA. The Language Spec is the authoritative source for what NOVA should do.
Module selection guide
| I want to... | Import | Key function |
|---|---|---|
| Build a web server | forge | forge.app() + forge.get/post + forge.serve |
| Hash a password | forge_crypto | pbkdf2_sha256(password, salt, 100000) |
| Parse a CSV file | csvx | csvx.parse_file(path) |
| Access deeply nested JSON | getin | getin.get_in(data, ["a", "b", "c"]) |
| Generate unique IDs | uuid | uuid.v4() |
| Arbitrary-precision math | bignum | bignum.add(a, b), bignum.pow(base, exp) |
| Group a list by property | collx | collx.group_by(xs, key_fn) |
| Property-based testing | proptest | proptest.check(name, N, fn() ...) |
Appendix E: NOVA vs other languages — side by side
The best way to understand a new language is to see code you already know written in it. Each example below shows the same program in NOVA and in a language you may already know. Study what's shorter, what's clearer, and what's missing.
Hello World + environment variable
| NOVA | Python | Go |
|---|---|---|
let mut name = env("USER")
if name == ""
name = "stranger"
print("Hello, {name}!") |
import os
name = os.environ.get("USER", "stranger")
print(f"Hello, {name}!") |
package main
import (
"fmt"
"os"
)
func main() {
name := os.Getenv("USER")
if name == "" { name = "stranger" }
fmt.Printf("Hello, %s!\n", name)
} |
NOVA has the shortest version. Go requires a package declaration, import block, and explicit func main(). Python needs an import and uses an f"" prefix for string interpolation. NOVA's strings always interpolate — no prefix, no import.
Sum a list — functional style
| NOVA | Python | JavaScript |
|---|---|---|
nums = [1, 2, 3, 4, 5] total = reduce(nums, fn(acc, x) acc + x, 0) print(total) // 15 |
from functools import reduce nums = [1, 2, 3, 4, 5] total = reduce(lambda acc, x: acc + x, nums, 0) print(total) |
const nums = [1, 2, 3, 4, 5]; const total = nums.reduce((acc, x) => acc + x, 0); console.log(total); |
Concurrent tasks — fetch 3 URLs in parallel
| NOVA | Python (asyncio) | Go |
|---|---|---|
urls = ["https://a.com", "https://b.com", "https://c.com"] results = pmap(urls, url => http_get(url)) print(results) |
import asyncio, aiohttp
async def fetch_all(urls):
async with aiohttp.ClientSession() as s:
return await asyncio.gather(
*[s.get(u).read() for u in urls]
)
results = asyncio.run(fetch_all(urls)) |
var wg sync.WaitGroup
results := make([]string, len(urls))
for i, url := range urls {
wg.Add(1)
go func(i int, url string) {
defer wg.Done()
resp, _ := http.Get(url)
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
results[i] = string(b)
}(i, url)
}
wg.Wait() |
NOVA's pmap is one line. Python needs asyncio, aiohttp, async def, await, and an event loop. Go needs a WaitGroup, a goroutine per URL, manual result collection with index, and wg.Wait(). NOVA's concurrent model requires no annotations — the scheduler transparently handles parallelism.
Error handling — reading a file that may not exist
| NOVA | Rust | Python |
|---|---|---|
fn load(path)
if not file_exists(path)
print("Warning: {path} not found")
return []
process(read_file(path)) |
fn load(path: &str) -> Vec<Item> {
match fs::read_to_string(path) {
Ok(text) => process(&text),
Err(e) => {
eprintln!("Warning: {}", e);
vec![]
}
}
} |
def load(path):
try:
with open(path, "r") as f:
return process(f.read())
except FileNotFoundError as e:
print(f"Warning: {e}")
return [] |
Rust's fs::read_to_string returns a Result, so the failure path is a match arm. NOVA's read_file returns a plain string and would crash on a missing file, so the idiom is to check first with file_exists — an explicit guard rather than an explicit Result, but the same property Rust is going for: the failure path is visible in the code, not hidden the way a Python exception is (you'd have to read the implementation, or the docs, to know open() can raise).
Struct with methods — a 2D vector
| NOVA | Python | Java |
|---|---|---|
type Vec2
x: float
y: float
fn Vec2.length(self)
sqrt(self.x * self.x + self.y * self.y)
fn Vec2.scale(self, s)
Vec2(self.x * s, self.y * s)
v = Vec2(3.0, 4.0)
print(v.length()) // 5.0 |
import math
class Vec2:
def __init__(self, x, y):
self.x, self.y = x, y
def length(self):
return math.sqrt(self.x**2 + self.y**2)
def scale(self, s):
return Vec2(self.x * s, self.y * s)
v = Vec2(3.0, 4.0)
print(v.length()) # 5.0 |
record Vec2(double x, double y) {
double length() {
return Math.sqrt(x*x + y*y);
}
Vec2 scale(double s) {
return new Vec2(x * s, y * s);
}
}
// var v = new Vec2(3.0, 4.0);
// System.out.println(v.length()); |
NOVA separates data declaration (type) from behavior — methods are just functions named fn TypeName.method_name(self, ...), defined wherever you like, with no wrapping impl block (unlike Rust) and no keyword marking them as methods at all beyond the dotted name. That also means you can add a method to a struct from a different file than the one that declares it — there's no "orphan rule" to work around. Python bundles data and methods in class, requiring __init__ boilerplate. Java's record (modern Java) is the closest to NOVA in conciseness.
Pattern matching on enum variants
| NOVA | Rust | Python (3.10+ match) |
|---|---|---|
enum Shape
Circle(radius: float)
Rect(w: float, h: float)
fn area(s)
match s
Circle(r) => 3.14159 * r * r
Rect(w, h) => w * h |
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } =>
std::f64::consts::PI * radius * radius,
Shape::Rect { w, h } => w * h,
}
} |
from dataclasses import dataclass
@dataclass
class Circle: radius: float
@dataclass
class Rect: w: float; h: float
def area(s):
match s:
case Circle(radius=r): return 3.14 * r * r
case Rect(w=w, h=h): return w * h |
All three have structural pattern matching but with different ceremony. Rust requires namespace-qualified variant names (Shape::Circle), explicit f64 type annotations, and curly braces. Python's match is new in 3.10 and requires dataclasses. NOVA's version has no annotations, no namespace prefixes, and no decorators.
A minimal REST API server
| NOVA (Forge) | Python (FastAPI) | Go (net/http) |
|---|---|---|
import forge
fn main()
app = forge.app()
forge.get(app, "/", fn(req) forge.resp_json(200, {"status": "ok"}))
forge.serve_app(app, 8080) |
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"status": "ok"}
# uvicorn main:app --port 8080 |
package main
import (
"encoding/json"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
http.ListenAndServe(":8080", nil)
} |
All three are roughly equal in conciseness for a single endpoint. The Forge pattern scales: adding 20 more routes is 20 more forge.get() lines, each identical in structure. FastAPI's decorator approach is clean but requires a separate ASGI server (uvicorn) to actually run. Go's standard library is verbose for JSON encoding. Forge generates a single static binary with no runtime dependency.
Appendix F: CLI command reference
Every nova subcommand in one place. Several are taught in depth where they first become relevant — project scaffolding and the build/run/compile/emit/wasm/eval family in Chapter 1, the whole-suite test/bench/coverage commands in Chapter 16 — this appendix lists all of them for quick lookup, and covers the remaining commands (project hygiene, static checks, formatting, debugging, dependency management, and utilities) in full below.
Quick reference
| Command | Description |
|---|---|
nova new <name> | Create project skeleton (--api default, or --microservice/--frontend/--fullstack/--lib) — Chapter 1 |
nova init | Create nova.toml in the current directory — Chapter 1 |
nova setup | Pre-compile the runtime cache — one-time, ~38× faster builds — Chapter 1 |
nova clean | Remove .ll/.exe build artifacts — below |
| Command | Description |
|---|---|
nova run [file] | Build and run (default -O0) — Chapter 1 |
nova build [file] | Build to executable (default -O2) — Chapter 1 |
nova compile <file> | Compile to LLVM IR only (.ll) — Chapter 1 |
nova emit <file> | Print LLVM IR to stdout (--asm, --target) — Chapter 1 |
nova wasm <file> | Compile to a runnable WASM bundle — Chapter 1 |
nova eval "<expr>" | Tree-walk interpret a single expression — Chapter 1 |
| Command | Description |
|---|---|
nova test | Run all *_test.nova files in tests/ and ./ — Chapter 16 |
nova bench <file> | Build -O2, time N runs (min/mean/max) — Chapter 16 |
nova cov <file> | Build with coverage, report per-line coverage (alias nova coverage) — Chapter 16 |
nova check <file> | Parse + type-check only, no codegen — below |
nova lint <file> | Static checks: tabs, line length, TODOs — below |
nova fmt <file> | Format source in place (alias nova format) — below |
| Command | Description |
|---|---|
nova repl | Interactive read-eval-print shell — Chapter 1 |
nova debug <file> | Build with debug info and launch lldb — below |
nova lsp / nova --lsp | Start the LSP server for IDE integration — Chapter 1 |
| Command | Description |
|---|---|
nova get <package>[@ver] | Add a dependency to nova.toml — below |
nova install | Download all dependencies from nova.toml — below |
| Command | Description |
|---|---|
nova version / nova --version | Show installed version — below |
nova self-test | Run the compiler's own internal test suite — below |
nova clean
Every build leaves artifacts behind: the intermediate .ll LLVM IR file and the compiled binary. Normally the next build just overwrites them, but stale artifacts cause real confusion — running an old binary because you forgot to rebuild, or accidentally committing generated files. nova clean removes every generated .ll/.exe artifact in the project, so the next build starts from a guaranteed-clean state.
nova build server.nova ls nova clean ls
server.nova
DO: Run nova clean before a release build if you suspect a stale artifact might be masking a bug — it forces everything to regenerate. DON'T: Treat it as required commit hygiene — a .gitignore entry for *.ll/*.exe already keeps them out of version control; nova clean is for local disk hygiene, not something CI needs to call.
nova check — fast syntax and type verification
nova run and nova build both compile all the way to a native binary — even a tiny syntax slip pays the full LLVM codegen cost before you find out. nova check stops after type inference: it parses the file and runs the complete Hindley-Milner type checker, reporting every syntax and type error, without generating IR or invoking LLVM at all. It is the fastest possible feedback loop for "does this compile" — reach for it while actively writing code, and save the full nova run for when you actually want to execute something.
fn add(a, b) a + b result = add(3, "four")
nova check bad.nova
A clean file produces no output at all — nova check is silent on success, printing only when something is wrong.
DO: Run nova check as your save-time sanity check, especially in a large file where a full nova build takes noticeably longer than a type-check alone. DON'T: Treat a clean nova check as proof the program is correct — it only proves the program is well-typed and parses; a type-correct program can still have logic bugs that only running it (nova test) would catch.
nova lint
nova lint runs a set of static, non-type-related style checks over a file: tabs (NOVA is indentation-based and expects spaces, so a stray tab is a common source of confusing "unexpected indent" errors), line length, and leftover TODO comments. These are not compile errors — a file with lint warnings still builds and runs fine — but they flag exactly the kind of small thing that is easy to miss in a self-review and untidy in a shared codebase.
nova lint messy.nova
messy.nova:14: line exceeds 100 characters (127)
messy.nova:22: TODO comment found: // TODO: handle the empty-list case
3 warnings
DO: Run nova lint before opening a pull request — every warning it finds is something a human reviewer would otherwise have to point out by hand. DON'T: Expect it to catch bugs — it only checks formatting and hygiene conventions (tabs, line length, stray TODOs), not correctness; that is nova check's and nova test's job.
nova fmt
nova fmt rewrites a file's whitespace into NOVA's canonical formatting in place — the same role gofmt plays for Go or rustfmt plays for Rust. Because the formatter is part of the toolchain rather than a personal preference, every NOVA codebase ends up looking the same, and code review can focus on logic instead of spacing. The alias nova format does exactly the same thing, for anyone who reaches for the longer name out of habit.
nova fmt messy.nova
nova format messy.nova # identical aliasDO: Run nova fmt on save (most editors can be configured to run a formatter automatically) so formatting is never a discussion in code review. DON'T: Hand-align columns of code expecting nova fmt to preserve your manual spacing — a formatter's entire point is that it decides the whitespace, not you.
nova debug — source-level debugging with lldb
When a compiled NOVA binary crashes — a segfault, an assertion failure, a panic with no useful message — nova debug builds the file with debug information embedded (source-line mappings, so the debugger shows NOVA source instead of raw machine addresses) and launches lldb directly on the resulting binary, ready for you to run it, hit the crash, and inspect the call stack and variable values at the point of failure. This is the same lldb that Rust and Swift developers already use — NOVA does not invent its own debugger.
nova debug crash.nova
Current executable set to 'crash' (x86_64).
(lldb) run
Process 4821 launched
Process 4821 stopped
* thread #1, stop reason = EXC_BAD_ACCESS
frame #0: crash`process_item(items=0x0, index=3) at crash.nova:12
(lldb) print items
(NovaList *) items = nullptr
(lldb)
DO: Reach for nova debug the moment you see a crash with no NOVA-level error message (a raw segfault, not a caught Err) — exactly the class of failure a source-level debugger is for. DON'T: Use it as your everyday tool for logic bugs — print() and nova test's assertions are faster for "why is this value wrong"; lldb earns its cost only once the program has already crashed and you need the call stack that led there.
Dependency management: nova get and nova install
nova init (Chapter 1) creates an empty nova.toml manifest. Two more commands manage what is listed inside it: nova get <package>[@version] adds a dependency to the manifest, optionally pinned to a version, and nova install reads the manifest and downloads everything it lists. The two-command split mirrors Cargo's cargo add + cargo build or npm's npm install <pkg> --save + npm install: recording a dependency is a separate, explicit step from fetching it, so a manifest committed to version control fully describes what a fresh checkout needs — nova install alone is enough to reproduce it on any machine.
nova get http_client@1.2.0 nova get json_schema nova install
Added json_schema@latest to nova.toml
Installing dependencies from nova.toml...
http_client@1.2.0 ... done
json_schema@0.4.1 ... done
2 packages installed
The resulting manifest looks like this:
[package] name = "blog" version = "0.1.0" [dependencies] http_client = "1.2.0" json_schema = "0.4.1"
DO: Commit nova.toml to version control and run nova install (not nova get) on a fresh checkout — get is for adding a new dependency, install is for fetching everything already recorded. DON'T: Hand-edit version numbers in nova.toml without re-running nova install — the manifest and the downloaded packages must agree, or you are building against a version you never actually fetched.
Utility commands: nova version and nova self-test
Two small commands round out the CLI. nova version prints the installed compiler's version — useful for bug reports, and for confirming a nova alias actually points at the binary you think it does. nova self-test runs the compiler's own internal test suite — the same kind of check the compiler's authors run before a release — a fast way to confirm a given nova binary is not corrupted or a bad build before trusting it with real work, for example right after copying a new binary onto a machine you have not used NOVA on before.
nova version nova self-test
Running compiler self-test...
lexer ... ok
parser ... ok
type inference ... ok
codegen ... ok
reconverge (gen5 == gen6) ... ok
All self-tests passed
DO: Run nova self-test after installing or updating the nova binary, before relying on it for real work. DON'T: Confuse nova self-test (validates the compiler itself) with nova test (runs your project's *_test.nova files) — they check entirely different things.
Appendix G: The NOVA framework roadmap
NOVA's identity is one developer, one language, building anything. Forge (Chapters 25–30 of this tutorial) is the first of nine planned frameworks, each targeting a domain that today requires switching languages and toolchains entirely. Because every NOVA framework compiles through the same pipeline as the application code calling it, a handler that calls into two different frameworks compiles as one unit — the compiler optimizes across the boundary the way it optimizes across any other function call, instead of stopping at a language or process boundary the way a polyglot stack does.
| # | Framework | Domain |
|---|---|---|
| 1 | Forge | Web backend (HTTP, WebSocket, ORM, auth) |
| 2 | Reactor | Game engine (ECS, physics, rendering) |
| 3 | Cortex | AI/ML (tensors, training, inference) |
| 4 | Mesh | Distributed systems (CRDTs, Raft, remote spawn) |
| 5 | Prism | GUI — web frontend (browser DOM/WASM) + desktop (native) |
| 6 | Pulse | Data/analytics (dataframes, streaming, ETL) |
| 7 | Sentinel | Security (crypto, post-quantum, HSM, Secret<T>) |
| 8 | Edge | Embedded/IoT (MCU, freestanding, drivers) |
| 9 | Ops | DevOps (infra-as-code, drift detection, cloud SDKs) |
Status: Forge is real today — roughly 570 modules, covered across this entire tutorial. Ops has no core-model blockers and is next in line to start. The remaining seven are architecturally scoped against NOVA's Values/Processes/Channels core (each domain's primitives map onto that same model — a game entity is a value, a physics step is a process, a network sync is a channel) but are not yet built.
DO: Treat everything in this appendix except Forge as a roadmap, not a shipped API — there is no import reactor or import cortex to reach for yet. DON'T: Wait on these to start building with NOVA today — Forge plus the core language (Chapters 1–24) is already a complete, production-usable stack for backend and full-stack work.
The NOVA Language Specification
The authoritative reference for NOVA as implemented by gen3_test.exe. For a guided introduction, read the Tutorial first. This document assumes you know what NOVA looks like.
1. Lexical structure
Encoding and indentation
Source files are UTF-8. NOVA is indentation-sensitive — blocks are introduced by indenting one level deeper than the opening line. Tabs and spaces are not interchangeable within the same file.
Comments
// Single-line comment, runs to end of line. // There is no block-comment syntax.
Identifiers
Letters, digits, and underscores; must start with a letter or _. Identifiers starting with _ at the top level are file-private.
Reserved keywords
fn type enum trait let return if else while for in match break continue true false spawn send receive select channel import export as extern ok err try catch
Trap: a reserved keyword used as an identifier fails silently, not with a clear parse error — for most keywords
NOVA's lexer does not put keywords on a separate token stream from identifiers — a word like match or type is lexed as a KW token carrying that exact text, and most of the parser only checks for a specific keyword string at the one position where it expects one (the start of a statement, a branch of an expression). Every other position simply reads whatever token is there next. That means writing a keyword where an identifier belongs does not reliably fail at the point of the mistake — the parser can silently walk the token straight into the keyword's OWN dedicated parse branch instead, building a parse tree — and therefore generating code — that has nothing to do with what you actually wrote.
// WRONG — 'match', 'loop', 'type', and 'unsafe' each have a dedicated parse branch. // Using one as a binding name used to silently mis-codegen instead of raising an error. let match = 5 // The compiler now rejects exactly these four with a clear message: // error: 'match' is a reserved keyword and cannot be used as a variable name
DO: treat every word in NOVA's reserved-keyword list as permanently off-limits for identifiers — variable names, function parameters, struct fields — and reach for a close synonym instead (kind for type, attempt for try, selection for select). DON'T: assume the absence of a compile error proves a keyword-shaped identifier is safe. Today only four "hard" keywords with their own dedicated parse branches — match, loop, type, unsafe — are explicitly rejected, and only in the let NAME = ... binding position; every other reserved word, and every other position (a parameter, a field, a bare NAME = ... assignment), is unguarded territory where this trap can still occur. NOVA's contextual keywords — matches, as, in, and, or, not — are the deliberate exception: they ARE safe to use as identifiers by design, e.g. let matches = [].
Literals
| Form | Example | Type |
|---|---|---|
| Integer | 42, -7, 1_000_000 | int (64-bit) |
| Hex integer | 0xFF, 0xCAFE_F00D | int |
| Float | 3.14, 1e10 | float (64-bit) |
| String | "hello", "line\nbreak" | string |
| Bool | true, false | bool |
| List | [1, 2, 3] | list<T> |
| Dict | {"a": 1} | dict<K, V> |
String escapes: \n \t \r \\ \" \0 \xNN. Inside strings, {expr} interpolates; literal braces are \{ and \}.
2. Types
Primitives
int— 64-bit signed, two's-complement, wraps on overflow (no UB)float— 64-bit IEEE 754 doublebool—trueorfalsestring— immutable UTF-8 byte sequenceunit— the type of a no-value expression
Containers
list<T>— dynamic array, heap-allocated, reference-counteddict<K, V>— open-addressing hash map, heap-allocated, reference-counted
User-defined (structs)
type Name field1: T1 field2: T2
Enums (sum types)
enum Name Variant1 Variant2(field: T) Variant3(a: T, b: U)
Generics
Generics are erased at compile time — no monomorphization cost at runtime. Representations are uniform i64 slots.
fn map<T, U>(xs: list<T>, f: T -> U) -> list<U>
3. Values, processes, channels
The entire NOVA computational universe is three primitives:
- Values — every datum. All values have a single owner at any time.
- Processes — every unit of execution. Created by
spawn. Processes do not share heap. Single-process programs compile thespawnmachinery away to zero overhead. - Channels — every cross-process communication. A
sendmay move the value. UnderNOVA_TRACK8=1this is checked statically at compile time.
4. Expressions
| Precedence | Operators |
|---|---|
| Highest | f(args), x.field, x[i] |
unary -, not | |
* / % | |
+ - | |
<< >>, then & ^ | | |
< <= > >= == != | |
in, not in | |
and | |
or | |
=> lambda, if/else ternary, catch | |
| Lowest | = += -= *= /= %= |
+ is overloaded for string concatenation when both operands are strings. == and != compare by value for scalars and strings; by handle identity for containers unless overridden with impl.
Lambda
let double = x => x * 2 let add = (a, b) => a + b
5. Statements
let name = expr declares a binding; the compiler infers the type. name = expr mutates an existing binding. Compound assignment: += -= *= /= %=.
return returns from the enclosing function; the last expression in a block is an implicit return. break exits the nearest loop; continue jumps to the next iteration. import is top-level only.
6. Functions
fn name(params) -> return_type body
Parameters and return type are optional; both are inferred. Functions are first-class values.
Methods
fn TypeName.method(self, args) -> R body
Traits
trait Show fn show(self) -> string type Meters : Show value: float fn Meters.show(self) -> string str(self.value) + "m" fn<T: Show> print_all(xs: list<T>) for x in xs print(x.show())
There is no impl keyword — conformance is declared once, on the type's own line (type Meters : Show), the same place the tutorial declares it. Because the declaration lives on the type, you can only make types you own satisfy a trait; there is no way to retroactively attach a trait to a builtin or a type from another module. The trait-bound generic parameter goes before the function name, same as any other type parameter: fn<T: Show> print_all(...), not fn print_all<T: Show>(...).
7. Type system
NOVA's type system is a Hindley-Milner core extended with subtyping at structural seams. Inference is sound but not complete — the compiler will reject some safe programs, but never accept an unsafe one. In practice you need annotations in two places:
- Public function signatures (documentation + better errors)
- Disambiguating overloads (rare)
8. Modules
Every .nova file is a module. import math looks for math.nova in: the importing file's directory → ./nova_packages/math/math.nova → stdlib search path. Top-level names are public unless they start with _.
| Form | Effect |
|---|---|
import math | Binds math.* qualified |
import math as m | Binds under alias m |
import math { sin, cos } | Brings into scope unqualified |
9. Memory model
NOVA uses process-isolated, reference-counted, escape-analyzed memory.
- Lists, dicts, strings (non-constant), structs, closures, channels — heap-allocated with refcount
- Integers, floats, bools, constant strings — not heap-allocated
Track 8 escape analysis determines which allocations are process-local. Local containers use _no_rc variants of push/set — eliminating ~20% of RC traffic on container-heavy workloads. For programs that never spawn, NOVA_AUTO_ARENA=1 turns the entire RC machinery into a no-op.
Move semantics on channels: send(ch, x) may move x to the channel. Under NOVA_TRACK8=1, any use of x after the send is a compile error (E1003).
10. FFI
| NOVA type | C type |
|---|---|
int | int64_t |
i32 | int32_t |
float | double |
string | const char * (NUL-terminated) |
ptr | void * |
bool | int (zero/non-zero) |
@opaque — a type the NOVA program hands back opaquely (FILE*, sqlite3*, etc.). out<T> — a pointer-out parameter the C function writes into. @repr(C) — struct with exact C memory layout (removes the NOVA type-hash slot).
@opaque type FILE_t @link("c") extern fn fopen(path: string, mode: string) -> FILE_t @link("sqlite3") extern fn sqlite3_open(path: string, db_out: out<int>) -> int
byval<T> — pass or return a @repr("C") struct BY VALUE, lowered per target (Win64 hidden-pointer-to-copy / SysV register-packed / AAPCS64 register-or-stack) to the exact signature clang would emit for the same C prototype. A plain type name (no byval) still means "pointer to a T" — two different C signatures, so the annotation is mandatory wherever the C side takes or returns the struct itself rather than a pointer to it.
extern fn vec2_sum(v: byval<Vec2>) -> float extern fn make_v2(k: float) -> byval<Vec2>
11. Compilation pipeline
.nova → lexer → parser → type inferrer → IR builder → optimizer → LLVM emitter → clang → .exe
- Lexer — tokenizes UTF-8 source
- Parser — Pratt-style precedence parser builds an AST
- Type inferrer — Hindley-Milner + constraint solving + trait conformance
- IR builder — lowers AST to basic-block IR with named registers
- Optimizer — TCO, dead-block/instruction elimination, constant folding, escape analysis
- LLVM emitter — writes textual LLVM IR with TBAA metadata and DWARF debug info
- clang — links
.llwithnova_runtime.cto produce native executable
Deterministic: the same source compiles to the same .ll byte-for-byte, regardless of host filesystem ordering. Self-hosting: nova_compiler.nova (~31k lines of NOVA) compiles itself.