NVA
From zero to deployed — 37 chapters + 6 appendices

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.

bash
# 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:

bash
# 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:

bash
# 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.

bash
# 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
Created project 'blog' (--api)
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:

bash
mkdir price-checker && cd price-checker
nova init
Created nova.toml

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:

bash
nova setup
Pre-compiling NOVA runtime cache...
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:

  1. Lexing — the compiler reads your .nova file and breaks it into tokens (keywords, identifiers, operators, literals)
  2. Parsing — tokens become an AST (Abstract Syntax Tree) representing the structure of your program
  3. 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
  4. IR (Intermediate Representation) generation — the typed AST is lowered to NOVA's IR, a simplified instruction set that is easier to optimize
  5. LLVM codegen — the IR is translated to LLVM IR, the same format used by C and Rust compilers
  6. Compilation — LLVM compiles the IR to native machine code (.exe on Windows, ELF on Linux)
  7. 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.

bash
# 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
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.

bash
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
define i64 @main() {
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.

bash
nova wasm calc.nova
nova wasm calc.nova -o dist/calc -O1   # custom output path, one step of optimization
Compiled calc.nova → calc.wasm, calc.run.cjs, _wasm_runtime.cjs

$ 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.

bash
nova eval "2 + 3 * 4"
nova eval "len([1, 2, 3, 4, 5])"
nova eval "upper(\"nova\")"
14
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:

FlagApplies toMeaning
-O0run / build / compileNo optimization — fastest compile, slowest generated code
-O2run / build / compileFull optimization — slowest compile, C-level generated code
-o <path>run / build / compile / wasmOutput file path
--target <triple>build / compile / emitCross-compilation target
--oldrun / build / compileUse the legacy non-IR compiler backend
bash
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
debug_server (built with -O0)
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 .ll file 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 --asm for 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.
bash
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 valueLLVM triplePlatform
native / windows / winx86_64-pc-windows-msvcWindows x64 (default on Windows)
linux / linux-x64x86_64-unknown-linux-gnuLinux x64
linux-arm64 / linux-aarch64aarch64-unknown-linux-gnuLinux ARM64
macos / darwin / macos-x64x86_64-apple-darwinmacOS Intel
macos-arm64 / darwin-arm64aarch64-apple-darwinmacOS Apple Silicon
wasm / wasm32wasm32-unknown-unknownWebAssembly

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.

bash
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 every tls_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/.size that 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.

bash
nova lsp
# equivalently:
nova --lsp
NOVA language server started, listening on stdio...

DO: 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.

bash
nova repl
> x = 10
> y = 20
> x + y
30
> upper("nova")
NOVA

DO: 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.

nova
print("Hello, world!")
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

nova
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
Hello, Alice! You are 30 years old.
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.

nova
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]
0007
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

nova
fn greet(name)
    print("Hello, {name}!")

fn main()
    greet("Bob")
    greet("Carol")
Hello, Bob!
Hello, Carol!

Line-by-line:

  • fn greet(name) — declares a function. No type annotation on name. The compiler infers name is a string because you pass "Bob" at the call site. In Java: void greet(String name). In NOVA: just fn 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():

nova
fn add(a, b)
    a + b

fn main()
    result = add(3, 4)
    print("3 + 4 = {result}")
// Output: 3 + 4 = 7
3 + 4 = 7

Line-by-line breakdown:

  • fn add(a, b) — declares a function called add that takes two parameters. No type annotations written. The compiler figures out a and b are integers because you call add(3, 4) below — integer literals tell the compiler the type.
  • a + b — this is the entire body of the function. No return keyword. In NOVA, the last expression in a function is automatically its return value. a + b evaluates to 7, 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() — the main function is the entry point. When fn main() exists, NOVA starts executing there instead of running top-level statements in order.
  • result = add(3, 4) — calls the add function with arguments 3 and 4, stores the return value (7) in a variable called result. No let, no var, no const — 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 is 7). 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

nova
// 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.

nova
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
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.

nova
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)
-9223372036854775808
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:

nova
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.

nova
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
8000

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.

nova
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
approximately equal

Math functions available for floats:

nova
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)
4.0
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.

nova
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
12
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.

nova
// 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.

nova
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
<html>
  <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.

nova
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
\d+\.\d\d
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

nova
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)
42
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 — use parse_int_safe("42") if the input might be invalid (returns Ok(42) or Err(...)).
  • int(3.9) — truncates the decimal part. 3.9 becomes 3. This is NOT rounding — int(3.9) and int(3.1) both return 3. To round, use int(round(3.9)) which gives 4.
  • float(7) / float(2) — converts both ints to float first, then divides. Gives 3.5. Without the float() calls, 7 / 2 would give 3 (integer division).

Booleans

nova
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
false
true
true
true
true
true

Short-circuit evaluation: and and or only evaluate the right side if needed:

nova
// 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:

nova
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.

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.
1
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. -1 is the last element, -2 is second-to-last, etc.
  • len(nums) — returns the number of elements (5). Note: the last valid index is len(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:

nova
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.

nova
// 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
3
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.

nova
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

nova
x = null
if x
    print("has value")
else
    print("is null")    // prints this — null is falsy

print(x == null)        // true
is null
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:

nova
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: float on 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.
nova
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.

nova
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
int: 42
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:

nova
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
struct
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:

nova
// 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.

nova
// 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.

nova
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
Bob
no such user

The same sugar works directly on a struct field or a let-typed local:

nova
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
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:

nova
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:

nova
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
20
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.

nova
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
2
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.

nova
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]
a=2 b=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

nova
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
positive
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 whole if expression. This function returns a string without writing return.
  • else if n == 0 — chained condition. NOVA uses else if (two words), not elif.

If as an expression with then: For short single-line results, use then:

nova
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.

nova
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
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.

nova
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))
7
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

nova
// 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}")
a
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

nova
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
1
3
5
7
9
found 9 at index 3

The loop keyword

For infinite loops, loop is cleaner than while true:

nova
count = 0
loop
    count = count + 1
    if count >= 5
        break
print(count)    // 5
5

Compound assignment operators

nova
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.

nova
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]
a=2, b=1
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.

nova
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
a=10, b=20
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:

nova
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 equivalent dict.items(); NOVA makes it the default dict iteration.
  • result[names[i]] = points[i] — builds a dict by combining two parallel lists. The range 0..len(names) - 1 is exclusive on the right end (half-open: includes a, excludes b), so with 3 names this iterates i = 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.

nova
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
1 = one
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.

nova
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)
Ok(4)
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 a return or break exits 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.

nova
// 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. \t in 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.
  • break in the inner loop — exits only the for c in 1..5 loop. Control returns to the outer loop, which checks if done and 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 formWhen to usePython equivalentGo equivalentRust equivalent
for x in collectionIterating a list, dict, range — you know what you're iteratingfor x in items:for _, x := range itemsfor x in items
while conditionExit condition is known at the top, checked before each iterationwhile condition:for conditionwhile condition
loopExit condition is in the middle or bottom — signals "runs until I break out"while True: + breakfor { }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

nova
// 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. When n reaches 0, the loop stops. The n -= 1 inside the body decrements n each time.
  • if i % 2 == 0: continuecontinue immediately jumps to the next iteration without running any code below it in the loop body. This skips even numbers: when i is 2, 4, 6, 8, or 10, print is never reached.
  • if i == 5: breakbreak immediately exits the entire loop. Execution continues with the first statement after the loop. So we print 0, 1, 2, 3, 4 — not 5.
  • loop with if 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 a while condition 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 patternLLVM constructOptimization opportunities
for i in 0..nInduction variable loopUnrolling, vectorization (SIMD), strength reduction
for x in listPointer-chase over list bufferPrefetching, auto-vectorization if element type is numeric
while condConditional branch at topLoop invariant hoisting
loopUnconditional back-edgeSame 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.

nova
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
processing next item
11

Line-by-line:

  • unless done — runs the body only when done is false. It is exactly if not done, desugared by the compiler before type checking.
  • until count > 10 — repeats the body until the condition becomes true. It is exactly while not (count > 10).
  • Both keywords are pure readability sugar. Anything you write with unless/until you could write with if 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.

nova
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
found!
not found

Line-by-line:

  • else is attached to the loop, not to the if inside it — notice its indentation matches for/while, one level shallower than the if.
  • break skips the else — this is the entire point of the construct.
  • Without this feature, the same logic needs a flag: found = false before the loop, found = true next to the break, then if 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."

nova
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
hit 5 on the way down
reached zero without hitting -1

Line-by-line:

  • while x > 0 ... else — the else's indentation matches while, one level shallower than the loop body, exactly like for...else.
  • First call: break fires when x == 5, so the else clause is skipped entirely.
  • Second call: target -1 is never equal to any x the loop visits — x counts down to 1 and the condition x > 0 goes false before x could ever reach -1 — so the loop ends normally and else runs.

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.

nova
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
in range
ascending
call count: 1

Line-by-line:

  • 0 <= x <= 100 — reads exactly like the math notation it mirrors. No and required.
  • 1 <= next_val() <= 10 — the compiler evaluates the middle term once, stores it, and reuses the stored value for both comparisons — count ends at 1, proving next_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.

nova
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)
5
8
2

Line-by-line:

  • for x in items if x > 0 — the if after 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-if version. 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 > 0 tests 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.

nova
// 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
1
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

nova
fn add(a, b)
    a + b

print(add(3, 4))    // 7
7

Line-by-line breakdown:

  • fn add(a, b) — declares a function named add with two parameters a and b. No types written — the compiler infers them from the call site. In Java this would be int add(int a, int b). In NOVA: just fn 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. No return keyword needed. This is the same as Rust, Ruby, and Kotlin.
  • print(add(3, 4)) — calls add with arguments 3 and 4. The function computes 3 + 4 = 7 and returns 7. print outputs 7.

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.

nova
// 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
89.991
nova
// 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
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

nova
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)
Ok(3)
Err(division by zero)

Line-by-line:

  • if b == 0 — guards against division by zero.
  • return err("division by zero")return exits the function immediately with an error value. Without return, the function would fall through to the last line.
  • ok(a / b) — the last expression is the return value when b != 0. ok() wraps the result in a success value.

Default parameters

nova
fn greet(name, greeting = "Hello")
    print("{greeting}, {name}!")

greet("Alice")                // Hello, Alice!
greet("Bob", "Good morning")  // Good morning, Bob!
Hello, Alice!
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.

nova
fn greet(name: string?) -> string
    "Hello, " + (name ?? "stranger") + "!"

print(greet(some("Alice")))   // Hello, Alice!
print(greet(none()))          // Hello, stranger!
Hello, Alice!
Hello, stranger!

Line-by-line:

  • name: string?name has type Option<string>, not a bare string. Inside the body you must unwrap it before using it as a plain string — here with ?? to supply a fallback.
  • name ?? "stranger" — if name is some(x), this yields x; if it's none(), it yields "stranger". See Chapter 9 for the full ??/Option story.

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:

nova
// 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
17 / 5 = 3 remainder 2
nova
// 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
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:

nova
// 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
10
7
11

Line-by-line breakdown of Form 1:

  • double = x => x * 2 — creates a closure that takes one parameter x and returns x * 2. The => arrow separates the parameter from the body. This is similar to JavaScript's x => x * 2 or Python's lambda x: x * 2. The closure is stored in the variable double and can be called like any function.
  • When you call double(5), the compiler substitutes x = 5 and evaluates 5 * 2 = 10.

Line-by-line breakdown of Form 3:

  • process = fn(x) — the block-body form starts with fn(parameters). No name — it is anonymous. The body is indented below, just like a regular function.
  • y = x * 2 — first statement: computes x * 2 and stores in y.
  • y + 1 — last expression in the closure. Its value is the return value. For process(5): y = 10, then returns 10 + 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.

nova
// 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
6
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:

nova
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
start: batch
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

nova
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
10

Line-by-line breakdown:

  • x = 10 — sets x to 10.
  • f = fn() print(x) — creates a closure that prints x. At this moment, x is 10. The closure captures a copy of 10. It now permanently remembers x = 10 regardless of what happens to the variable x later.
  • x = 20 — changes the variable x to 20. The closure f already captured the old value and does not see this update.
  • f() — calls the closure. Prints 10, not 20.

Why this matters — the Python closure bug:

python
# 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
// 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!
0
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.

nova
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)
110

Line-by-line breakdown:

  • apply(5, x => ...) — the outer closure receives x = 5, computes scaled = 10, then calls apply again with a second, nested block lambda.
  • The inner closure receives y = 10 and computes bonus = y + base = 110 — it reads base from the outermost scope even though it's nested two closures deep, exactly as any closure would.
  • boosted ends up 110; the outer closure's last line, boosted, is its return value, so result is 110.

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:

nova
nums = [1, 2, 3, 4, 5]
squares = map(nums, x => x * x)
print(squares)   // [1, 4, 9, 16, 25]
[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 original nums list 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:

nova
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]
[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 closure x % 2 == 0 tests if x is even.

reduce — combine all elements into a single value:

nova
nums = [1, 2, 3, 4, 5]
total = reduce(nums, 0, (acc, x) => acc + x)
print(total)     // 15
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:

nova
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
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

nova
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
7
12
  • fn apply_twice(f, x)f is a parameter that holds a function. The compiler infers it is a function because you call it with f(...) inside the body.
  • f(f(x)) — calls f on x, then calls f again on the result. For apply_twice(x => x + 1, 5): first call f(5) = 6, second call f(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:

nova
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 separate sort_desc function.
  • 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:

nova
// 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

nova
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
3628800
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 placesShort callbacks for map/filter/reduce
Part of your module's public APIOne-time-use functions in local scope
Methods on structs (impl blocks)Functions stored in data structures or lists
Functions with complex multi-line bodiesAdapters 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:

nova
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 takes n, 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 of make_adder. It closes over n from the outer scope. Each call to make_adder creates a new closure with its own captured copy of n.
  • add5 = make_adder(5) — calls the factory with n = 5. The result is a function that adds 5 to its argument. The closure permanently remembers n = 5.
  • add10 = make_adder(10) — a separate call creates a separate closure with n = 10. add5 and add10 are independent — changing one does not affect the other.
  • add5(3) — calls the closure with x = 3. Evaluates 3 + 5 = 8.

Function factories for validation:

nova
// 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:

PatternNOVAPythonJavaScriptGo
Single-param closurex => x * 2lambda x: x * 2x => x * 2func(x int) int { return x * 2 }
Multi-param closure(a, b) => a + blambda a, b: a + b(a, b) => a + bfunc(a, b int) int { return a + b }
mapmap(xs, x => x*2)[x*2 for x in xs]xs.map(x => x*2)manual loop
filterfilter(xs, x => x>0)[x for x in xs if x>0]xs.filter(x => x>0)manual loop
Function factoryfn make_adder(n) fn(x) x+ndef make_adder(n): return lambda x: x+nn => x => x+nfunc(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:

nova
// 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. normalize only trims and lowercases. not_empty only tests length. This makes each function easy to test and reuse.
  • Composition over complexity: clean_lines is just filter(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_frequency builds 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_by doesn'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.

nova
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
100
[30, 40, 50]
15

Line-by-line:

  • 5 |> double |> square5 flows into double first (double(5) = 10), then 10 flows into square (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) becomes filter(v, pred), matching every existing NOVA HOF signature (collection first, then the closure).
  • 3 |> add(4) — becomes add(3, 4), not add(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.

nova
// 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, 10, 18]
6
HELLO!!!

Line-by-line:

  • data.filter(...).map(...).sort()filter, map, and sort all already take the collection as their first parameter, so UFCS turns the nested form sort(map(filter(data, ...), ...)) into a left-to-right chain that reads in execution order.
  • fn int__double(x: int) -> int — the Type__method naming convention marks this as an extension method on int. It's an ordinary top-level function; the compiler recognizes the naming pattern and makes it callable as 3.double().
  • Resolution order: struct method → module function → builtin. This lets you add methods to any type — including builtins like int and string — 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.

nova
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!
Hello, Alice!
Yo, Eve!
Hey, Bob!

Line-by-line:

  • greet("Alice") — positional call. greeting is 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.

nova
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
10
7
0

Line-by-line:

  • xs: int... — the ... after the type marks xs as variadic. Inside the function body, xs is a plain list[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."

nova
result = x * y where x = 6, y = 7
print(result)      // 42

area = width * height where
    width = 10
    height = 20
print(area)        // 200
42
200

Line-by-line:

  • x * y where x = 6, y = 7 — single-line form: comma-separated bindings.
  • width * height where followed by indented bindings — multi-line form.
  • Scope: width and height exist only for the statement they're attached to — they are not visible on the next line. This is Haskell's where, 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.

nova
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
negative
zero
positive

Line-by-line:

  • Three fn classify(n) heads, same name and arity — since each carries (or omits) a when guard, 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 no when is 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

nova
type Point
    x: float
    y: float

type User
    name:  string
    age:   int
    email: string

Line-by-line:

  • type Point — declares a new struct type named Point. Type names must start with an uppercase letter.
  • x: float — declares a field named x of type float. 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 type declaration.

THE most important rule in NOVA: lowercase field types

This single rule has the largest impact on your program's performance:

nova
// 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 to nova_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

nova
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 User with 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.
nova
// 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:

nova
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
78.53975
31.4159
314.159

Line-by-line:

  • fn Circle.area() -> float — the Circle. prefix says this method belongs to Circle. The -> float declares the return type. Inside this method, self automatically refers to the Circle instance the method was called on. You do NOT declare self as a parameter — NOVA adds it automatically.
  • 3.14159 * self.radius * self.radiusself.radius reads the radius field 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.radius stays 5.0 after calling c.scale(2.0).

How methods compare to other languages:

LanguageMethod definitionSelf reference
NOVAfn Circle.area() -> float — outside the type blockself is implicit (not a parameter)
Pythondef area(self): — inside class Circle:self is an explicit parameter
Rustfn area(&self) -> f64 — inside impl Circle { }self is explicit with borrow annotation
Gofunc (c Circle) area() float64 — receiver before namec (the receiver name)
Javadouble 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).

nova
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
30
10
20
30
40
nova
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)
42
nova
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}
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

nova
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

nova
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:

nova
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 treat Circle as a Shape just because it happens to have matching methods.
  • vs Rust traits: Rust requires a separate impl Shape for Circle { ... } block. NOVA has no impl keyword 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 like int, 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 a Shape-typed parameter only accepts types that wrote : Shape on 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.

nova
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
I am Dog(Labrador)
A very independent Cat(Siamese)

Line-by-line:

  • fn name() -> string inside the trait has no body — it's abstract. Both Dog and Cat must (and do) define their own Type.name(), or the compiler rejects the : Describable conformance as incomplete.
  • fn describe() -> string inside the trait DOES have a body — it's a default. Dog never defines Dog.describe(), so calling d.describe() runs the trait's own version, which in turn calls self.name() — and because self is a Dog at that call site, it resolves to Dog.name(), not some generic placeholder.
  • Cat defines its own Cat.describe(), so that overrides the default entirely — the trait's version is never reached for Cat.

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:

nova
// 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() calls self.width() and self.height() — methods are composable.
  • Methods accept struct parameters: Vec2.add(other: Vec2) and Circle.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.center is a Vec2, so you can call Vec2.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:

nova
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 in self. Called per-item inside catalog_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 mutate self. Reliable when called directly on a single value. The sale loop above builds the discounted Product inline instead of calling it — see the callout just below for why.
  • Filtering is a plain if inside a for loop here, not filter(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.

nova
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.

nova
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
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.

nova
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.

nova
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.

nova
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"
19.99
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.

nova
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
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.

nova
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
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:

nova
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, 4]
[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.

nova
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
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).

nova
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}")
welcome, ada (age 30)

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.

nova
// 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

nova
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.

nova
// 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)))
78.53975

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)

nova
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

nova
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:

nova
// 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:

nova
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 fieldsA value can be ONE of several different "shapes"
A point always has x and yA shape can be a Circle OR Rectangle OR Triangle
A user always has name and emailA response can be Success(data) OR Failure(msg)
A config always has host and portAn 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.

LanguageTagged union syntaxExhaustiveness check?
NOVAenum Color { Red, Green, Blue(v) }Yes — compile error if case missing
Rustenum Color { Red, Green, Blue(u8) }Yes
PythonNo native supportNo
TypeScriptDiscriminated union via interfacesPartial (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.

nova
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. Empty is 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. Each Cons holds 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. The Empty arm 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: both Empty and Cons are handled. If you add a third variant to IntList later, the compiler will flag every match on IntList that doesn't handle it.
  • Cons(_, rest) in list_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.

nova
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. Help and Verbose carry no data. OutputFile carries the file path. Unknown carries whatever string the user typed.
  • fn parse_arg(s) — converts a raw string argument into a typed CliArg. The last expression is the return value — no return keyword.
  • "--help" => Help — a string match arm. Both "--help" and "-h" map to the same Help variant. 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 uses if/else to distinguish --output=... from truly unknown flags.
  • OutputFile(slice(s, 9, len(s))) — strips the "--output=" prefix (9 characters) and wraps the remainder in the OutputFile variant.
  • 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 on CliArg: 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:

nova
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 — extracts number into num, expiry into exp, and discards cvv with _ (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
# 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:

nova
// 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:

SituationUse nullUse Option<T>
Returning a missing item quicklyYes — simplerOverkill
Public API for a libraryNo — callers forget to checkYes — compiler forces the check
Storing optional fields in a structYes — email: string can be nullWorks but verbose
Chaining: "if found, do X, then if found, do Y"Nested ifs get uglyYes — 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.

nova
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)
Red — go: false
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.

nova
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))
Delivered #ORD-8821 — thank you!
Why enums beat strings and integers for state machines
ConcernString/int approachEnum approach
Typos"shiped" silently acceptedCompile error — no such variant
Missing transitionsRuntime crash or wrong behaviorExhaustiveness check at compile time
Associated dataSeparate maps/structs requiredStored directly in the variant
Adding a new stateMust grep every switch manuallyCompiler lists every match that needs updating
PerformanceString 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 patternWhat it doesExample
Literal valueMatches exactly that value0 => "zero"
Enum variant (no data)Matches that variantRed => "stop"
Enum variant (with data)Matches and destructuresOk(v) => use v
Named bindingBinds the matched value to a namex => "got {x}"
Wildcard _Matches anything, discards it_ => "other"
Guard if conditionExtra predicate on top of patternn if n > 100 => "big"
nova
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

nova
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
zero

Line-by-line:

  • 0 => "zero" — matches the literal integer 0. The => separates the pattern from the value.
  • n if n < 0 => ... — binds the value to n, then guards with an if condition. 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.

nova
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
freezing
cold
hot
true
false

Line-by-line:

  • -10..0 => "freezing" — a range pattern reads like a literal but tests membership; equivalent to a guard n 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 like s 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.

nova
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
int:42
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

nova
fn http_status(method)
    match method
        "GET"    => "reading"
        "POST"   => "creating"
        "PUT"    => "updating"
        "DELETE" => "deleting"
        other    => "unknown method: {other}"

Classic FizzBuzz with match

nova
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))
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19

Matching enum variants (destructuring)

nova
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
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:

nova
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). Add and Mul each contain two Expr children — 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) * 4 as 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

nova
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
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.

nova
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
zero
negative
positive even
positive odd
Not Found
4xx Client Error

Line-by-line breakdown:

  • x if x < 0 => "negative" — the pattern x binds the matched value into a variable named x. The guard if x < 0 is 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. Because 0 and x if x < 0 are checked first, by the time we reach this arm, x is guaranteed to be positive.
  • _ => "positive odd" — the fallback wildcard. At this point, x is 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" — the and operator combines two conditions in the guard. In NOVA, and/or/not are 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.

nova
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
200 OK
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/42 can't be exact-matched statically, so the wildcard arm uses an if to check the prefix and extract the ID.
  • slice(path, 7, len(path))slice extracts 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.

nova
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
Alice
not found

Line-by-line:

  • if let Ok(user) = find_user(1) — evaluates find_user(1) and tries to match it against the pattern Ok(user). Since find_user(1) returns ok(User("Alice")), the pattern matches and user is bound to the inner User value for the body.
  • else — runs when the pattern does not match, here whenever the result is Err(...). if let supports exactly one pattern and one fallback — it is not a substitute for match when you need to distinguish between several non-matching cases.
  • Works with any pattern, not just ResultSome(x), a specific enum variant, a literal. Reach for if let whenever a match would 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.

nova
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: c
processing: b
processing: a
queue drained: true

Line-by-line:

  • next_item(queue) — returns Some(value) while the queue still has elements, None once it's empty. Every call mutates queue via pop, which is why the loop makes progress instead of running forever.
  • while let Some(item) = next_item(queue) — the condition is RE-EVALUATED (calling next_item again) at the top of every iteration, not just once up front. The moment it returns None, the pattern fails to match and the loop exits — no explicit if item == None: break needed.
  • 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.

nova
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)
origin
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 against a, the second against b.
  • (0, 0) => "origin" — matches only when both a and b are exactly 0.
  • 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.

nova
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
success
got: 42

Line-by-line:

  • response matches "200 OK" — when the right side is a string, matches is 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 like Ok(v), matches tests whether value has that shape, and — inside the if body — binds v to the extracted inner value.
  • matches vs match: match is a statement that runs different code per case. matches is an expression — it can sit inside if, while, an and/or chain, or a plain assignment like found = 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

nova
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
Result: 5
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.

nova
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 integer: 42
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.

nova
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

nova
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:

nova
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)
FunctionWhat it doesCrashes on?
is_ok(r)Returns true if the result is OkNever
is_err(r)Returns true if the result is ErrNever
unwrap(r)Extracts value from OkYes — crashes if r is Err
unwrap_err(r)Extracts message from ErrYes — crashes if r is Ok
unwrap_or(r, default)Value from Ok, or default if ErrNever
ok(value)Creates an Ok(value) resultNever
err(msg)Creates an Err(msg) resultNever

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.

nova
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:

nova
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
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:

nova
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 -> Result return type annotation signals that this function returns a Result (either Ok(data) or Err(message)). This tells callers they must handle both outcomes.
  • conn = try tcp_connect(host, port)try unwraps the Ok value and stores it in conn. If tcp_connect returns Err (host unreachable, port closed, DNS failure), try immediately returns that error from connect_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 the Ok value (which would just be a confirmation), we just need to ensure the send succeeded. If sending fails (connection dropped), try propagates the error immediately.
  • response = try tcp_recv(conn) — tries to receive the response. If the network is disrupted between sending and receiving, try catches it and propagates. If it succeeds, response holds the received data.
  • tcp_close(conn) — closes the connection. This does NOT use try because we do not care if closing fails — the response is already in response, so the operation succeeded. Cleaning up after success is a "best effort" action.
  • ok(response) — wraps the result in Ok. If we reached this line, all three try operations 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:

nova
// 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 a Result. If it is Ok(n), unwrap_or extracts the port number n. If it is Err(...) (bad format, out of range), unwrap_or discards the error and returns the default 8080 instead. In one line: parse OR fall back to 8080. No match, no boilerplate.
  • match read_file("config.toml") — when you need different behavior for success vs failure, match is cleaner than chained if/else. The compiler forces you to handle both arms.
  • Ok(content) => parse_config(content) — if the file was read successfully, parse it. content is the file contents as a string.
  • Err(e) => — if reading failed (file not found, permission denied), run the alternative block. e is 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 the Err arm, so it is the return value of the whole match.

Choosing between try, match, and unwrap_or:

SituationUseReason
Error should propagate to callertry exprClean, single word, same semantics as Rust's ?
Have a sensible constant defaultunwrap_or(expr, default)One-liner, clear intent, no boilerplate
Need different logic for Ok vs Errmatch result ...Compiler forces you to handle both outcomes
Want to check before usingif 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

LanguageApproachProblem
CReturn -1 or NULL, check manuallyEasy to forget to check; no type enforcement
Java/PythonExceptions — thrown anywhere, caught somewhereError paths invisible in function signature
Go(value, error) tuplesEasy to ignore with _, err := f()
RustResult<T, E> + ? operatorCorrect but verbose error type system
NOVAResult + try keywordNone — 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.

nova
// 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}")
Registered: alice
Error: username too short (minimum 3 characters)

Key observations:

  • Each validator is a pure function: it takes a string, returns Ok(string) or Err(string). They are independently testable.
  • register_user checks each field in turn via nested match; the first Err arm 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 uses match rather than try deliberately — chaining try through more than one user-defined Result-returning function currently loses the unwrapped value's concrete type, a real open compiler gap; try on 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 the Ok arm of their respective match. There is no way to reach the ok({...}) line without all three validators having passed.
  • The caller (main) uses match to handle success and failure explicitly. If you are building a web handler, you would instead return an HTTP 400 response in the Err arm.

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:

nova
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":"alice","email":"alice@example.com"} → 200 {"status":"ok","username":"alice"}
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 Resultmatch 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.

nova
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.

nova
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
got 8
nothing

Line-by-line:

  • -> int? reads as "returns an int, or nothing" — the exact same shape as the -> T or E sugar for Result a few sections from here, but for absence instead of failure.
  • some(x) / none() construct the two cases; Some(n) / None destructure them in a match, 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.

nova
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.

nova
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 Resultwith is a readability layer, not a new execution model.

nova
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.

nova
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.

nova
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 A using conn 1
worker B using conn 1
pool still holds conn 1

Line-by-line breakdown:

  • on_exit_send(pool, conn) — the moment borrow has the connection, it registers "if I exit for any reason before this is cancelled, send conn back to pool." This runs immediately — it does not wait for a crash to "arm" itself.
  • send(pool, conn) then cancel_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 borrow instead panicked between recv and cancel_on_exit_val — say, a bug in code that uses conn — the registered on_exit_send still runs as the task unwinds, and pool gets its connection back anyway. A defer 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.

nova
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
42

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.

nova
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.

nova
// 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.

nova
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.

nova
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
Ok(42)
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.

nova
// 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
5
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

nova
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.

nova
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-a, task-b, task-c]
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

nova
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.

nova
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
68
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.

nova
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
true
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.

nova
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]}")
[["Alice", 91], ["Bob", 84], ["Carol", 77]]
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.

nova
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
2
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.

nova
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
[60, 72, 88, 95]
[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

nova
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.

nova
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)         // false
12
0
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

nova
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:

nova
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?

nova
// 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.

nova
// 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
score=97,3.5!

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

nova
// 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)
3
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.

nova
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
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
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():

nova
// 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]
[1, 4, 9, 16, 25]
[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 of x * x for each x in nums, but only include x values where x % 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.

nova
count = [10, 20, 30]      // "count" also names a registered builtin
doubled = [count[i] * 2 for i in 0..3]
print(doubled)
[20, 40, 60]

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:

nova
// 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.

nova
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)
3
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.
  • words has 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 calling set_add with 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:

nova
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_by takes a list and a key function. The key function receives each element and returns the value to sort by — here the key is p.price, a float, so sort_by sorts ascending numerically by price. sort_by doesn'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.

CollectionOrdered?Duplicates?Key lookup?Best for
[1, 2, 3] listYesYesBy index O(1)Sequences, stacks, queues, ordered results
{"a": 1} dictNo (insertion order preserved)Keys uniqueBy key O(1) avgLookup tables, JSON objects, caches, config
{1, 2, 3} setNoNoMembership O(1) avgUnique elements, deduplication, fast membership tests
"hello" stringYesYesBy index O(1)Text, immutable byte sequences
nova
// 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)
Performance rules of thumb
  • 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 list scans 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.

nova
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.

nova
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.

nova
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.

nova
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")
urgent: put out fire

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.

nova
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.

nova
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
false
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.

nova
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.

nova
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")
2/100 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.

nova
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.

nova
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
48
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.

nova
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.

nova
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))
2 of 5 slots filled
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 stringsmap_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.

nova
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.

nova
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
[5, 2, 1]
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:

nova
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:

nova
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
1024
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 structureUse it when...
Priority QueueYou always need the "most urgent" item next — schedulers, Dijkstra, event simulation
DequeYou need fast push/pop at BOTH ends — stacks, queues, sliding windows
LRU CacheYou need a bounded cache that evicts automatically — memoization, query result caching
CounterYou're tallying frequency of discrete keys — word counts, histograms, analytics
Ring BufferYou need a fixed-memory rolling window — streaming samples, log tails
Sorted MapYou 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

nova
// 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]
[10, 20, 30, 40, 50]
[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.

nova
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]
[0, 2, 4, 6, 8]
[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:

nova
// 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]]
[1, 4, 9, 16, 25]
[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.

nova
// 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]
["breakfast", "coffee", "dinner", "tv"]
[[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

nova
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]
[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

nova
// 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.

nova
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
[0, 1, 4, 9, 16]
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 Optionsome(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 Optionnone() 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.

nova
// 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
10
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:

nova
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")
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:

FunctionWhat 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:

nova
// 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:

nova
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

ConceptNOVAPythonJavaScript
Create from listiter(xs)iter(xs)xs[Symbol.iterator]()
Transform elementsiter_map(it, f)map(f, it)it.map(f) (array)
Filter elementsiter_filter(it, pred)filter(pred, it)it.filter(pred) (array)
Take first Niter_take(it, n)itertools.islice(it, n)No built-in
Collect to listiter_collect(it)list(it)[...it]
Coroutine/generatorfiber_create + fiber_yielddef gen(): yield xfunction* gen() { yield x; }
Infinite sequencesFiber + loopitertools.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

nova
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")
Hello, NOVA!
Second line.

file exists

Line-by-line:

  • write_file("hello.txt", "...") — Creates or overwrites hello.txt with the given string. Warning: if the file already exists, all previous contents are permanently lost. The \n is 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") — Returns true if the file exists, false otherwise. 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

nova
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)
26
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:

nova
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. Unlike read_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) — Returns true when there are no more lines (End Of File).
  • trim(line) — Removes the trailing \n character that each line includes.
  • file_close(f) — Closes the file handle. Always close files when done — open files consume OS resources.
nova
// 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

nova
// 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")
config
data.csv
output.txt
readme.md
src

Path operations

nova
// 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.

nova
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)
file is 4096 bytes
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. Unlike file_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 offset i. This is bounds-checked: reading at or past mmap_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.

nova
// 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")try checks the result. If read_file returns Err(msg), try immediately returns that error from the enclosing function. If it returns Ok(text), try unwraps the text and assigns it to contents. 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). Returns Ok(null) on success or Err(msg) on failure.
  • Ok(_) => null — the wildcard in Ok(_) discards the success value (which is null for write operations that don't return data). The match arm produces null to 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.

nova
// 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. try propagates the error to the caller if the file doesn't exist, so parse_csv returns an Err in 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..b includes a, excludes b), so this iterates i = 1 .. len(lines) - 2 — it deliberately stops one short of the final index. That last skipped element is the empty string split() produces after the trailing \n that most text files end with. The if len(line) == 0: continue guard below handles the case where there is no trailing newline (so lines is 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 of lines. then is 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 integer 30. trim removes 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 uses match to 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

nova
// 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)
true
false
true
true
false

Pattern syntax reference

PatternMeaningExample
.Any character"a.c" matches "abc", "aXc"
\dDigit 0-9"\\d+" matches "42"
\wWord char (letter, digit, _)"\\w+" matches "hello_42"
\sWhitespace"\\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

nova
// 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"]
42

hello NOVA
fooNUMbar
["one", "two", "three"]
["a", "b", "c", "d"]

Real-world examples

nova
// 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
true
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:

nova
// 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

nova
// 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

OperationNOVAPythonJavaScriptGo
Does pattern match?s matches "pattern"bool(re.search("pattern", s))/pattern/.test(s)regexp.MustCompile(...).MatchString(s)
Extract first matchregex_find(s, "pat")re.search("pat", s).group()s.match(/pat/)[0]re.FindString(s)
Replace allregex_replace(s, "pat", sub)re.sub("pat", sub, s)s.replaceAll(/pat/g, sub)re.ReplaceAllString(s, sub)
Split on patternregex_split(s, "pat")re.split("pat", s)s.split(/pat/)re.Split(s, -1)
Named groupsNot 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:

nova
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}
42
"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 become true or false.
  • 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:

nova
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
Alice
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"] — returns 30. JSON numbers are automatically converted to NOVA integers or floats.

What NOT to do

nova
// 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

nova
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"}
{"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

nova
// 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

nova
// 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

nova
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)
2026
6
28
12
0
0
6

Formatting and parsing

nova
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
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

nova
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)
22
15
95400

Measuring elapsed time

nova
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.

nova
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.

nova
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

nova
// 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

OperationNOVAPythonGoJava
Current timestampdatetime_timestamp()datetime.now().timestamp()time.Now().Unix()Instant.now().getEpochSecond()
Format a datedatetime_format(ts, "%Y-%m-%d")dt.strftime("%Y-%m-%d")t.Format("2006-01-02")LocalDate.format(DateTimeFormatter.ISO_DATE)
Parse a datedatetime_parse(s, "")datetime.strptime(s, fmt)time.Parse(layout, s)LocalDateTime.parse(s, formatter)
Add daysdatetime_add_days(ts, 7)dt + timedelta(days=7)t.Add(7 * 24 * time.Hour)date.plusDays(7)
Compare two datests1 < ts2 (integer comparison)dt1 < dt2 (operator overload)t1.Before(t2)date1.isBefore(date2)
Elapsed timetime_ms() - start(time.time() - start) * 1000time.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:

nova
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 addition works (1 assertions)
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 prints ok <name> (<N> assertions). If any assertion fails, it prints FAIL <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_operations is a plain top-level function name, passed as a value (no parens, no fn wrapper 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. Prints All tests passed: N assertions in total on success, or Test results: P passed, F FAILED, T total to 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

FunctionWhat it checksWhen to use
assert_eq(a, b)a equals bMost comparisons: assert_eq(result, 42)
assert_ne(a, b)a does not equal bEnsure values differ: assert_ne(password, "")
assert_true(cond)condition is truthyBoolean results: assert_true(is_ok(r))
assert_false(cond)condition is falsyassert_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 elementLists: assert_contains(names, "Alice")
assert(cond, msg)condition is trueCustom 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.

nova
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()
ok trig identities hold within epsilon (2 assertions)

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

nova
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

nova
// 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.

bash
nova bench fib.nova
nova bench fib.nova -n 50    # override the default 10 iterations
Building fib.nova (-O2)...
Running 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

nova
// 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 0 (1 assertions)
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.

nova
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: 120 is 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 calls test_run with 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_eq prints 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.

nova
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 whole Result including its wrapper. This is stricter than extracting the integer first: it verifies the function produces a proper Ok(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_err extracts the string from Err(msg). Safe here because we already confirmed is_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.

nova
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()
ok checked_add panics on overflow (1 assertions)

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.

nova
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 form test_run("spawn sends result on channel", () => ...) (fat-arrow block lambda, body indented below) would also work for a multi-statement body — only the fn() ... 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 calls send, 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 checks a + 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, send parks 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. producer is its own named function because spawn { ... } 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.

nova
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.

nova
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)
1..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.

FunctionSignatureWhat it does
semver_parse(str)string -> [int, int, int]Splits "2.3.1" into [major, minor, patch]
semver_compare(a, b)(string, string) -> intReturns -1, 0, or 1 — numeric, not lexicographic
semver_satisfies(version, constraint)(string, string) -> boolChecks a version against a constraint like ">=1.0.0"
semver_format(parsed)[int, int, int] -> stringRenders a parsed triple back to "major.minor.patch"
semver_compatible(a, b)(string, string) -> int1 when a and b are compatible under semver rules (same major version)
nova
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()
ok semver parse, compare, satisfy, format, compatible (5 assertions)

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.

bash
nova test
Running tests/math_test.nova...
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.

bash
nova cov math_test.nova
math_test.nova coverage:
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.

PropertyOS threadNOVA green task
Stack size1–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)
SchedulerOS kernel (preemptive)NOVA runtime (cooperative, work-stealing N=1)
Blocking I/OBlocks the whole threadParks the task; carrier thread runs others
Shared memoryAny 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

nova
// 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

nova
// 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)
1
4
9
16

Multiple producers, one channel (fan-in)

nova
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-0
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)

nova
// 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

nova
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.

nova
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
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.

nova
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)
6
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

LanguageConcurrency modelData race possible?Learning curve
NOVAGreen tasks + channelsNo — deep-copy on sendMinimal — just spawn/send/recv
Gogoroutines + channelsYes — shared memory + mutex neededLow
Rustthreads + Arc/Mutex or channelsNo — enforced by borrow checkerHigh (lifetimes)
Pythonthreads + GILYes — but GIL limits parallelismLow (but asyncio adds complexity)
Javathreads + synchronized/locksYes — deadlock and race commonHigh (locks are complex)
Erlangactors + message passingNo — same model as NOVAModerate

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
# 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:

nova
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
200000

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:

nova
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
main sees: [1, 2, 3]
task sees: [1, 2, 3, 4]

Line-by-line:

  • send(ch, data) — NOVA deep-copies data before putting it in the channel. The original data is 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 affect data in 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.

nova
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)
[2, 4, 6, 8, 10]

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:

nova
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:

nova
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: send then recv. 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:

nova
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

MechanismLanguageHow it worksProblems
Mutexes / locksC, C++, Java, GoShared memory; lock before accessDeadlocks, priority inversion, forgotten unlocks, race conditions when locks are missed
Async/awaitPython, JavaScript, Rust, C#Cooperative coroutines; explicit await at every suspension pointColored functions: async infects callers all the way up. Non-async callers can't use async functions.
Goroutines + channelsGoLightweight threads; CSP channels for communicationGoroutines can still share memory through pointers. Channels are not enforced — you can bypass them.
ActorsErlang, Akka, ElixirIsolated processes; message passing onlyMessages are untyped dictionaries. No compile-time guarantee that sender and receiver agree on shape.
Green tasks + channelsNOVAGreen tasks own their data; channels copy values on sendNone 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)

nova
// 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.

nova
// 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:

nova
// 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 a channel_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, send 1 back to first." The fiber trampoline drains this on both normal exit and panic — unlike defer, which only runs on normal exit.
  • cancel_on_exit_val(first, 1) — after the normal send(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 synchronized is a keyword bound to an object's monitor; Go's sync.Mutex is 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 acquisitionselect_timeout(mtx, 500) gives "try to acquire, give up after 500ms." Java needs a completely different API (tryLock(timeout)) for this; NOVA uses the same select_timeout that works on any channel.
  • Change the capacity and it becomes a semaphorechannel_bounded(N) with N tokens pre-loaded is a counting semaphore. Same primitive, different capacity. std/sync/semaphore.nova wraps this as sem_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)

nova
// 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:

nova
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:

nova
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():

nova
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
sum of squares: 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: includes a, excludes b) — 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 of i at the moment of creation. This is critical: if closures captured by reference, all tasks would see the same i (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. send is 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)receive blocks until a task sends. If all tasks are still running, receive parks 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}") — prints 0 + 1 + 4 + 9 + 16 = 30. The // 0 + 1 + 4 + 9 + 16 = 30 comment is for clarity; the actual output is just 30.

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:

nova
// 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")
fetched 3 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:

nova
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
42
0

Line-by-line breakdown:

  • pid = spawn fn() ...spawn returns 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 on ch.
  • 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.

nova
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)
1

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.

nova
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
boom
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:

nova
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 0 done
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. spawn returns 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 for loop — 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.

nova
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
true

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:

nova
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:

nova
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 after timeout_ms milliseconds. Returns null on timeout.
  • The if result == null check — 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:

nova
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 EITHER ch1 or ch2, giving up after 500ms. Since service A responds in 300ms (before the 500ms timeout), result gets "from service A".
  • If both services were down or slow (>500ms), result would be null.

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:

nova
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}")
got data: response from primary-db

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.

nova
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."

nova
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.

nova
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.

nova
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
100
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:

nova
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
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.

nova
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
2
[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.

nova
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.

nova
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
1
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 spawnsched_spawn/sched_spawn_on exist for the framework and scheduler layer underneath it.

nova
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 1
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.

nova
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
4
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

PrimitiveUse forWhat it returns
channel_bounded(N)Backpressure — slow producer when consumer is behindBounded channel (send blocks at capacity N)
channel_bounded(1) + tokenMutual exclusion (channel-as-mutex)recv = acquire, send = release
on_exit_send(ch, val)Crash-safe cleanup — resource returned even on panicCancellation token (int)
cancel_on_exit_val(ch, val)Cancel a registered on_exit_send after normal release1 if found, 0 if not
pmap(list, fn)CPU/IO parallelism over a collectionResults in same order as input
pfilter(list, fn)Parallel filter — predicate tested on all coresMatching elements in original order
pfor(start, end, fn)Parallel for — run body across all coresNothing (side-effects only)
pid = spawn ...; monitor(pid)Restart-on-crash for a single taskA channel; delivers an exit message if the task crashes
monitor(pid)Get notified when a task exitsA channel; receive(mon) blocks until task finishes
recv_timeout(ch, ms)Receive with a deadline — one channelThe value, or null on timeout
select(ch1, ch2)Take first from either channel — no timeoutWhichever value arrives first
select_timeout(ch1, ch2, ms)Take first from either channel — with deadlineWhichever 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

nova
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 filemath_utils.nova:

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 itmain.nova (in the same directory):

nova
// main.nova
import math_utils

print(square(5))    // 25
print(cube(3))      // 27

Line-by-line:

  • import math_utils — Finds math_utils.nova in the same directory. All its functions (square, cube) become available in this file.
  • print(square(5)) — Calls the square function defined in math_utils.nova. You do NOT need to write math_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.

nova
// 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")
3/4
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.

nova
// 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], ".")
nova
// 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
Ok(alice@example.com)

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:

  1. Same directory as the current file — import utils looks for utils.nova next to your program
  2. $NOVA_HOME/lib/import forge looks 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:

ModuleWhat it provides
forgeWeb framework (REST, WebSocket, SSE, middleware)
forge_cryptoSHA-256/512, HMAC, AES, ChaCha20, Ed25519, X25519
csvxCSV parsing and generation
bignumArbitrary-precision integers
complexnumComplex number arithmetic
rationalExact rational number arithmetic
matrixxMatrix operations (multiply, invert, determinant)
prngPseudo-random number generators (PCG, Xorshift)
uuidUUID v4 generation
urlxURL parsing and encoding
strxExtended string operations
collxCollection utilities
bitsetEfficient bit set operations
basexBase encoding (base32, base58, base64)
corexCore utilities (extended assert, functional helpers)
getinNested data access helpers (safe deep field access)
setopsSet operations (union, intersection, difference)
graphemexUnicode grapheme cluster operations
deflatexDEFLATE/zlib compression and decompression
pvecxPersistent (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:

nova
// 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
nova
// 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
nova
// 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
nova
// 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]
nova
// 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.nova has no imports — it only provides primitives. It can be imported by anyone without causing circular dependencies.
  • auth.nova imports utils — authentication logic needs logging. It does NOT import db — that separation keeps auth testable without a real database.
  • db.nova imports sqlitex (stdlib) — database logic is isolated here. If you switch from SQLite to Postgres later, you only change db.nova.
  • main.nova imports 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:

nova
// 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!")
bash
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.

nova
// 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 auth and import db — brings both modules into scope. login(), logout(), find_user(), and save_user() are all available directly. No auth.login() prefix. This is the opposite of Python's from auth import login pattern — in NOVA, everything is imported flat.
  • import models in multiple files — each file that needs User imports models. The compiler deduplicates: the User type 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.

nova
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:

nova
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 = [] and let next_id = [1] — module-level state (in-memory storage). For a production app, replace these with a database using import sqlitex or an external DB driver. next_id is a 1-element list rather than a bare int specifically so a handler function can mutate it in place with next_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 with forge.get/forge.post by passing the function itself (no wrapping fn(req) ... needed when the handler is already exactly the right shape). Each takes a typed req: Request and returns a Response. forge.resp_json(status, x) serializes x to JSON, sets Content-Type: application/json, and sets the given status code — it works on ANY value (struct, list, dict), not just dicts.
  • req.query["text"] — reads the text query parameter from the URL. For POST /todos?text=Buy+milk, this returns "Buy milk" (the + decodes to a space). query is a plain dict field on Request, already parsed by the router — missing keys give an empty string, so always check len(qtext) == 0 for required fields.
  • forge.resp_error(400, "text is required") — returns a 400 Response with a structured error body. The handler returns early with return 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 the params dict — GET /todos/42 gives req.params["id"] == "42" (always a string; convert with int()).
  • forge.serve_app(app, 8080) — starts the HTTP server for an app built with forge.app()/forge.get/forge.post, and blocks until the process is killed. This is the last statement in main(). 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-level forge.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:

nova
// 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).

RuleGoodBad — why
Lowercase snake_caseauth_utilsAuthUtils — won't import as import AuthUtils
Short, descriptive namesdb, auth, usersmy_app_database_helpers — verbose
No hyphensemail_validatoremail-validator — not a valid identifier
Domain-alignedpayments, searchmisc, 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

nova
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
Server listening on port 19876
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

nova
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:

nova
// 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.

nova
// 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
echo: ping
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

FunctionWhat it doesReturns
tcp_listen(port)Start listening for connectionsA 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 serverA connection object
tcp_send(conn, data)Send a string over the connectionnothing
tcp_recv(conn)Receive a string (blocks until data arrives)The received string
tcp_send_bytes(conn, bytes)Send raw bytesnothing
tcp_recv_bytes(conn)Receive raw bytesThe received bytes
tcp_close(conn)Close the connectionnothing

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.

nova
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)
[INFO] accepted connection from 127.0.0.1:54211

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).

nova
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)
no data within 300ms, closing

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.

nova
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
true

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

PropertyTCPUDP
Delivery guaranteeYes — data always arrives or connection errorsNo — packets can be lost silently
Ordering guaranteeYes — data arrives in the same order sentNo — packets can arrive out of order
Speed overheadHigher — connection setup, ACKs, retransmissionLower — fire-and-forget, no handshake
Connection modelPoint-to-point streamsIndividual datagrams, can be broadcast
Use whenFiles, HTTP, API calls, chat — correctness mattersGames, DNS, video streaming, telemetry — speed matters
NOVA functionstcp_listen/accept/connect/send/recvudp_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:

nova
// 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_ch and 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_recv returns 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:

nova
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 sends true to 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). If null, 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.

nova
// 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.

nova
// 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.

nova
// 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.

nova
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:

nova
client_key     = "dGhlIHNhbXBsZSBub25jZQ=="
accept_header  = ws_accept_key(client_key)
print(accept_header)   // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" — value for Sec-WebSocket-Accept
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

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.

nova
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:

nova
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

nova
// 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 and Content-Type header (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

nova
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!
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\n sequences 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:

nova
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_clienthttp_request lives in this module, not in the always-on builtin set. It must be imported before use (unlike http_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 as Err(msg), not a crash and not a fake status code. A successful HTTP round-trip — even one that returns 404 or 500 — is Ok(resp); the status code lives in resp.status, not in whether the Result is Ok or Err.
  • resp.status / resp.body / resp.headersHttpResponse is 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:

nova
// 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:

nova
// 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:

nova
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 returns Result<HttpResponse>. This is more verbose than http_get(url) but gives full control: method, URL, headers, body — and it surfaces network failures as Err instead of a crash.
  • try http_request(...) — since whoami itself returns a Result (via ok(...) at the end), try can unwrap each request: on success it binds the HttpResponse, on failure it immediately returns Err(msg) from whoami — no manual match needed 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. The Bearer prefix 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_decode converts it to a NOVA dict so you can access fields. .body is struct field access, not dict indexing.

Parallel HTTP requests — fetch multiple URLs concurrently:

nova
// 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

FunctionWhen to useReturns
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

MethodSemanticsHas request body?Typical use
GETRead — fetch a resource, never modify itNoLoad a page, fetch a user profile, query a list
POSTCreate — submit data, create a new resourceYesSubmit a form, create a new record, upload a file
PUTReplace — fully replace an existing resourceYesUpdate all fields of a user record
PATCHPartial update — change only specific fieldsYesChange a user's email without touching other fields
DELETERemove a resourceRarelyDelete 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:

nova
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

nova
import forge_crypto

hash = sha256_hex("hello")
print(hash)   // 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
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.
nova
// 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.

nova
// 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
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.

nova
// 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
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
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

nova
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

nova
// 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

nova
// 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
3
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 — never random_int for security.

Cryptographically secure random

nova
// 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:

nova
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
FunctionSpeedUse case
sha256_hexSlow (secure)Passwords, signatures, integrity
hmac_sha256Slow (secure)API auth, JWT, webhooks
hash / fnv1aVery fastHash table keys, deduplication
murmur3Very fastHash tables, bloom filters
crc32FastFile 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.

nova
// 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.)

nova
// 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
true
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

nova
// 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
["hello.txt", "--verbose"]

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 — use int(arguments[0]) to convert to integer.

Environment variables

nova
// 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

nova
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")
linux
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.

nova
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
/usr/local/bin/mytool
/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

nova
// 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 handing cmd to the OS shell to parse and run, just like shell() and system() (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.

nova
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
apple
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.

nova
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}")
clang not found, falling back to gcc
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.

nova
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 way print() would.
  • stdin_read_n(32) — reads up to 32 raw bytes. Unlike readline(), 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

nova
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:

nova
// 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. For novagrep "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. matches is the NOVA keyword for regex test, equivalent to Python's re.search().
  • exit(1) at the end — Exit code 1 when there are zero matches, following the POSIX convention used by the real grep tool. 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:

nova
// 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

OperationNOVAPythonGo
Command-line argsargs()sys.argv[1:]os.Args[1:]
Environment variableenv("VAR")os.environ.get("VAR", "")os.Getenv("VAR")
Run commandshell("cmd")subprocess.check_output(["cmd"])exec.Command("cmd").Output()
Join pathspath_join([a, b])os.path.join(a, b)filepath.Join(a, b)
OS nameos_name()sys.platformruntime.GOOS
Exit with codeexit(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):

nova
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:

ConstantValueConstantValue
TRACE0ERROR4
DEBUG1FATAL5
INFO2OFF6
WARN3
nova
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.

nova
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:

nova
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:

nova
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. The next(req) call runs the actual route handler.
  • log_set_json(true) — switches all log output to JSON format. Every subsequent log_info, log_debug, etc. produces a JSON object instead of plain text.
  • log_set_level(2) — sets the minimum level to INFO. Only log_info, log_warn, log_error, log_fatal produce output. log_debug (1) and log_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 separate log_json function. req.method / req.path are struct fields on Request, 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

FeatureNOVAPython (logging)Go (log/slog)Java (SLF4J)
Import requiredNo — built-inimport loggingimport log/slogimport org.slf4j.*
Log levelstrace/debug/info/warn/error/fatalDEBUG/INFO/WARNING/ERROR/CRITICALDebug/Info/Warn/ErrorTRACE/DEBUG/INFO/WARN/ERROR
JSON outputlog_set_json(true)Custom formatter requiredBuilt-in slog JSON handlerLogback JSON encoder
Structured fieldslog_info("event", {k:v}) — fields are the 2nd arg of any log calllogging.info("msg", extra={...})slog.Info("msg", "key", val)log.info("{}", val)
Set level at runtimelog_set_level(2) — integer constant, not a stringlogging.setLevel(logging.INFO)Programmatic via handlerLogback 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

nova
import forge

fn main()
    app = forge.app()

    forge.get(app, "/", fn(req)
        "Hello from NOVA!"
    )

    forge.serve_app(app, 8080)
bash
nova build server.nova && ./server
curl http://localhost:8080/    # Hello from NOVA!

What happens inside forge.serve() — step by step

  1. Forge calls tcp_listen(8080) to start listening on port 8080.
  2. It enters an accept loop — waiting for clients to connect.
  3. 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.
  4. 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.
  5. 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.
  6. 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

MethodForge functionWhen to use
GETforge.get()Retrieve data (viewing a page, fetching a list). Must NOT change server state.
POSTforge.post()Create new data (submitting a form, creating a record).
PUTforge.put()Replace an existing resource entirely.
PATCHforge.patch()Partially update an existing resource (change one field).
DELETEforge.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

nova
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); the fn(req) ... keyword form does not — it's always exactly one expression. That's why the single-expression list handler above uses fn(req) todos, but every handler that does more than one thing uses req => instead.
  • Route registration order matters: /todos/filter is registered before /todos/:id specifically so the literal segment wins the match — see the comment above it.
  • req.params["id"] — Every Request has a params: dict field, filled by the router at match time from named segments (:id) in the route pattern. Read it directly — there is no param() function.
  • req.query["done"] — Similarly, Request has a query: dict field parsed once from the ?key=value part 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 untyped forge.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:

nova
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_id is wrapped in a 1-element list rather than a bare int specifically so next_id[0] = next_id[0] + 1 can 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 : NewTodo annotation tells from_json exactly which struct to build, so body.title is real field access on a real typed value, not an untyped dict lookup.
  • let id = int(req.params["id"]) — Path parameters live in req.params, a plain dict filled in by the router from the :id segment of the route pattern.
  • forge.resp_json(201, todo) — Serializes ANY value — struct, dict, or list — to a proper Response, in one call. This is the counterpart to forge.get/forge.post-style app routing; the similarly-named forge.json_of returns a raw response STRING instead and pairs with the lower-level forge.serve(port, handler) — the two are not interchangeable.
  • forge.resp_error(404, "todo not found") — Returns a 404 Response. 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:

bash
# 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}
POST /todos → 201 {"id":1,"title":"Learn NOVA","done":false}
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:

FrameworkLanguageConcurrencyMemoryDeploy
Forge (NOVA)NOVAGreen tasks, non-blockingArena, zero GCSingle binary
FlaskPythonThreads or asyncGC, high memoryPython + gunicorn + nginx
DjangoPythonThreadsGC, high memoryPython + WSGI stack
GinGogoroutinesGCSingle binary
Actix-WebRustasync/tokioManual, fastSingle 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

nova
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])
1: Alice <alice@example.com>
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:

nova
// 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:

nova
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:

nova
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 a recv, 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 fast err("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 receives db to 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:

nova
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:

nova
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 by cols. cols must match the SELECT list's order exactly — pool_query_dicts doesn't read column names back from SQLite the way db_query_named does, 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 for CREATE 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, so body.name/body.email are real field access rather than untyped dict lookups on an any-typed result.
  • if len(rows) == 0: return forge.resp_error(404, ...) — If no rows matched, return a 404 Response. The return exits the handler early — the forge.resp_json line 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

nova
// 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 colspool_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:

nova
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:

nova
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:

nova
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.

nova
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:

nova
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)}")
driver=postgres pool_size=20

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.

nova
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.

nova
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:

nova
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:

nova
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.

nova
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):

nova
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:

nova
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.

nova
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.

nova
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:

nova
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_oneauthor 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":

nova
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.

nova
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.

nova
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:

nova
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:

nova
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.

nova
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).

nova
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.

nova
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:

nova
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.

nova
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.

nova
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 typeSQLitePostgreSQLMySQL
int (non-id field)INTEGERINTEGERINTEGER
boolINTEGERINTEGERINTEGER
string (non-id field)TEXTTEXTTEXT
floatREALDOUBLE PRECISIONREAL
int id fieldINTEGER PRIMARY KEYBIGSERIAL PRIMARY KEYBIGINT AUTO_INCREMENT PRIMARY KEY
string id fieldTEXT PRIMARY KEYTEXT PRIMARY KEYVARCHAR(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:

nova
// 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:

nova
// 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.

nova
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.

nova
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.

nova
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:

nova
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.

nova
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.

nova
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])
FunctionWhat 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.

nova
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.

nova
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:

nova
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.

nova
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:

nova
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:

nova
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:

nova
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:

nova
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:

nova
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.

nova
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:

nova
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:

nova
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:

nova
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.

nova
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.

nova
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.

nova
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.

HTTPWebSocket
DirectionClient → Server then Server → ClientBoth directions, any time
ConnectionOpens and closes per requestStays open until closed
Good forAPIs, pages, formsChat, games, live dashboards

WebSocket echo server

nova
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. Unlike forge.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):

javascript
const ws = new WebSocket("ws://localhost:8080/echo");
ws.onmessage = (e) => console.log(e.data);
ws.send("hello");   // Console logs: "You said: hello"
You said: hello

Multi-client chat (broadcast hub)

nova
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) ...) — Like forge.ws, but every client that connects to /chat is 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

nova
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:

javascript
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:

WebSocketSSE
DirectionFull duplex — client and server both sendOne-way — only server sends
ProtocolIts own framing over an upgraded TCP connectionPlain HTTP, chunked text — works through more proxies
ReconnectManual — you write the retry logicAutomatic — built into EventSource
Use forChat, multiplayer games, collaborative editingLive 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

nova
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. The claims dict contains user identity data embedded in the token. "sub" (subject) is the user's ID.
  • forge.bearer_token(req) — Extracts the JWT from the Authorization: Bearer <token> header sent by the client.
  • forge.jwt_verify(token, secret) — Verifies the signature. Returns the claims dict if valid, null if invalid or expired.
bash
# 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:

nova
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:

nova
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_csrf checks the _csrf field 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:

StorageVulnerable toVerdict
localStorageXSS — any injected script can read it and exfiltrate itAvoid for anything sensitive
In-memory JS variableLost on page refresh; still readable by any script running on the pageOK for short-lived SPA sessions
httpOnly cookieCSRF (mitigated by mw_csrf/SameSite) — but NOT readable by JavaScript, so XSS can't steal itRecommended for browser apps
Authorization: Bearer headerNothing extra — but the client (mobile app, CLI, server-to-server) must manage storage itselfCorrect 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

nova
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 the forge_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. Only h1/h2/h3 exist as named helpers — for h4h6 use the generic forge_html.el("h4", {}, text).
  • forge_html.div({}, ...) — Creates a <div>. Its children are built first and concatenated with +, then passed in as the single inner string — 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 with map() or a comprehension, like the three li elements above.
  • forge_html.link("https://novachan.org", "Learn more") — Creates <a href="...">Learn more</a>. The function is named link, not a (a is not a valid NOVA identifier issue — it's just the name Forge chose). link also checks the URL scheme and neutralizes javascript:/data: URLs to "#", closing an XSS vector that a raw <a href> would not.
  • forge_html.page("NOVA", "", body) — Wraps body in 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 with Content-Type: text/html. This is the same forge.html(status, body) used for any text response — there is no separate html_response function.

What curl http://localhost:8080/page receives — a single unbroken HTML string, wrapped and indented here for readability:

<!doctype html><html><head><meta charset="utf-8"><title>NOVA</title></head><body>
<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:

  1. 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.
  2. Inner/body content is NOT auto-escaped. Splicing a raw string into an element's inner argument emits it verbatim. You must call forge_html.esc(text) yourself before interpolating any user-supplied text into body content.
nova
// 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>"  -->  &lt;script&gt;alert(1)&lt;/script&gt;
// 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

nova
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

FunctionHTML outputNotes
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 cellSame (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> documenttitle 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

nova
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:

nova
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.
bash
curl -X POST -H "Content-Type: application/json" \
     -d '{"email":"not-an-email","password":"short","role":"superadmin"}' \
     http://localhost:8080/register
400 Bad Request
{"errors": ["email: invalid format", "password: must be at least 8 characters", "role: must be one of [user, admin]"]}
Validation ruleWhat 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:

nova
// 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:

nova
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

nova
// 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

nova
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:

nova
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

nova
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) — Registers GET /health. It takes exactly one argument — the path is always /health, not caller-supplied. Use forge.health_route_checked(app, checks) if you want it to also verify dependencies (same checks shape as readyz_route below) before reporting healthy.
  • forge_obs.readyz_route(app, checks) — Lives in forge_obs, not forge, and its path is likewise fixed at /readyz — the second argument is checks, not a path string. checks is a list of [name, fn() -> bool] pairs; the route runs them in order and reports 503 not ready: <name> at the first one that returns false, or 200 ready if all pass.
  • forge_compress.mw_compress() — Compression middleware is a separate module (forge_compress) that imports forge, not the other way around — it must be imported on its own even though you also import forge.
  • forge_obs.metrics_registry() — Metrics need somewhere to accumulate counts; this creates that registry. Both mw_metrics (the middleware that records each request) and metrics_prometheus (the function that renders the Prometheus text format) take the SAME registry — mismatch them (e.g. two separate registries) and /metrics silently 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

nova
// 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
1024.0
0.0000026535897933

Linking an external library

nova
// @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 typeC typeNotes
intint64_t64-bit signed integer
floatdouble64-bit IEEE 754
boolint0 or 1
stringconst char*Null-terminated UTF-8
ptrvoid*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:

nova
// 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 named puts exists somewhere in a linked library. extern fn does 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 C char* pointer that puts expects.

Unsafe blocks inside FFI — when C needs raw pointers

Some C functions need raw memory pointers. These require an unsafe block:

nova
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

nova
@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.

nova
// ------ 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_open declaration is just 1 line. The safe wrapper is 8 lines. But every caller now gets input validation, proper error messages, and a Result type instead of an opaque int return 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.

nova
// 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
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

LanguageHow to call CSafety levelEffort
NOVAextern fn name(args) -> T + @link("lib")Unsafe at boundary, safe outside2 lines to declare
Python (ctypes)lib = ctypes.CDLL("lib.so") + lib.func(args)Unchecked — no type safetyLoad library + set arg/return types per function
Python (cffi)Parse C headers, ffi.cdef(header)Better — type-checked against headerMedium — header parsing
Go (cgo)// #include <lib.h> in Go file + C.func(args)Type-checked, but pointers can escapeMedium — Go/C boundary has overhead
Rust (bindgen)Generate bindings from C headers, mark calls unsafeWrapper required for safetyHigh — bindgen setup, lifetime management
Java (JNI)Write C glue code that calls Java, compile separatelyComplex — C glue can corrupt JVMVery 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.

nova
// 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.75
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

nova
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.

nova
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 0 = 0
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 pointer i * 8 bytes past base. ptr_read and ptr_write take no offset argument — they always act on the exact address passed in — so the offset must be baked into the pointer first via ptr_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 * 8 is 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:

nova
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)
777
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.

nova
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)
255
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.

nova
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)
123

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.

nova
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)
p has no address yet
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.

nova
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
hello
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:

nova
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
0
5
1
10

Line-by-line breakdown:

  • atomic_new(0) — Creates a new atomic integer initialized to 0. 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 call atomic_add at 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.

nova
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
1024
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 raw ptr_read/ptr_write, which require unsafe and have no bounds checking at all). This is why offheap operations do not need an unsafe block: 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.

nova
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))       // false
true
cache_entry
false

Line-by-line breakdown:

  • weak_create(obj) — returns a weak handle to obj. This does NOT increment obj'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, or null if it has been collected. "Upgrade" is the standard term (matching Rust's Weak::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 so weak_alive reports 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.

nova
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]))
11
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

nova
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
10
0
72
111

Line-by-line breakdown:

  • bytes(10) — Creates a byte buffer with 10 slots, all set to 0. 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 to 72. In ASCII encoding, 72 is 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. Returns 111 (ASCII 'o').

Converting between bytes and strings

nova
// 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

nova
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)
0
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:

nova
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:

nova
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
72

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)

nova
// 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.

DimensionsNameExampleShape
0Scalar42.0[]
1Vector[1.0, 2.0, 3.0][3]
2MatrixA 2×3 grid of numbers[2, 3]
33D TensorA color image: height × width × RGB channels[224, 224, 3]

Creating tensors

nova
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.

nova
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
1.0 0.0 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

nova
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.

nova
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)
5.0 5.0

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:

nova
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
[2, 2]

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:

nova
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.0 is 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.

nova
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
[1.0, 2.718, 7.389] [0.0, 1.0, 2.0]

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.

nova
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
[1.0, 2.0, 3.0, 4.0] 10.0

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.

nova
// 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
[1.1, 2.2, 3.3, 4.1, 5.2, 6.3]

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

nova
// 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

FunctionWhat 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

nova
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 call remote_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

nova
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 address 127.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:

  1. When you call remote_send(conn, value), NOVA serializes value to a JSON string
  2. It prefixes the JSON with its byte length (4 bytes, little-endian)
  3. It sends both over the TCP connection
  4. On the receiving end, remote_recv reads 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)

nova
// 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)

nova
// 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:

nova
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")
Processed 3 images across 3 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_items is deliberate, not 0..num_items - 1 — NOVA's .. is exclusive of its right bound, so 0..num_items is exactly num_items iterations, matching the num_items sends 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

PatternCodeWhen to use
Local (no concurrency)results = map(items, compute)Small lists, fast operations
Local concurrentresults = pmap(items, compute)CPU-bound work, single machine
Local green tasksspawn fn() send(ch, compute(item))I/O-bound, fine-grained control
Distributedremote_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/frameworkDistributed modelDifferent from local code?
NOVARemote channels — same send/recv APINo — only the connect call changes
Python (Ray)@ray.remote decorator + .remote() callsYes — requires annotations and different syntax
Go (gRPC)Protobuf schema + generated stubs + HTTP/2Yes — entirely different API from local functions
Java (Akka)Actor refs + message classes + serializationYes — significant infrastructure
Erlang/OTPPID-based messaging, same send APINo — 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.

nova
// 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
ping
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.

nova
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)
42

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:

  1. NOVA source — your .nova files
  2. LLVM IR — low-level intermediate representation. NOVA's compiler generates this.
  3. Clang -O2 — LLVM's optimizer turns IR into optimized machine code (constant folding, instruction selection, register allocation)
  4. 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
// 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
/* 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

nova
// 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

BenchmarkNOVA 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 function1.21–1.32×ABI overhead — future optimization
Sieve of Eratosthenes~1.0×List access speed
Green task spawn (10k tasks)~100ms total, ~10µs eachHeap-allocated fiber
Struct field with capital Float150× SLOWER than CDynamic dispatch — DON'T DO THIS

The fast patterns

nova
// 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 patternWhyFast fix
x: Float on struct fieldDynamic dispatch, heap pointer dereference per opUse x: float (lowercase) — 150× speedup
result = result + piece in loopO(n²) — copies entire string every iterationpush(parts, piece); join(parts, "") — O(n)
Sending large dicts/lists over channels in a tight loopDeep copy on every sendCompute locally, send only the small result
Recursion with depth >10kStack overflow (32KB fiber stack)Use a while loop with explicit stack list
map() then filter()Two intermediate lists allocatediter_map + iter_filter + iter_collect — zero intermediate lists
Sleep in a hot loop for pollingUnnecessary latency + wasted CPUUse channels — recv parks the task, zero CPU until data

String building — the O(n²) trap explained

nova
// 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

nova
// 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:

nova
// 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 primitiveCreation costMemory per unitWhen to use
NOVA green task~1µs~32KBDefault: concurrent connections, work items
OS thread~10µs~1MBOnly for C FFI that blocks, or true CPU isolation
Python coroutine~1µs~1KB but GIL prevents true parallelismI/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.

OperationNOVA (measured)C (clang -O2)Python 3 (typical, not benchmarked here)Notes
Integer loop~1x1x (baseline)~50x slowerNOVA generates identical machine code to C
Struct field math (lowercase)~1.04x1x~150x slowerNOVA within 4% of C
Struct field math (Capital)~150x slower!1x~150x slowerBoth equally slow — avoid Capital types
Float array operations~1.05x1x~100x slowerNOVA within 5% of C
String concat in loop1.2–2x1xvariesUse join() for loops
Dict lookup1.5–3xN/A~5–10x slowerOpen-addressing hash table
Green task spawn~38µs avg (10k tasks incl. 10k parked, in 382ms)N/AN/AAn 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, not x: Float
  • Use join() or buffer_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.

nova
// 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
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.

nova
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
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.

nova
// 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
3

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

nova
// 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.

nova
@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 booltrue 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.

nova
// 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
bash
nova run pure_test.nova
2 tests passed

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.

nova
@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-safefib 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:

nova
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.

nova
type Credentials
    username: string
    @redact
    password: string

print(Credentials("admin", "s3cret"))
// Credentials { username: admin, password: [REDACTED] }
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.

nova
@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.

nova
@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.

nova
@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())
payment_gateway 1 string, int

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.

nova
@inject
type UserService
    db: string
    logger: string

fn main()
    print(UserService__is_injectable())
    print(UserService__inject_deps())
    print(UserService__inject_name())
1 db:string, logger:string user_service

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 (UserServiceuser_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.

nova
@middleware("requestLogger")
fn logRequests(req) -> bool
    true

fn main()
    print(logRequests__middleware_name())
    print(logRequests__is_middleware())
requestLogger 1

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.

nova
@validate
type SignupForm
    email: string
    password: string
    age: int

fn main()
    print(SignupForm__has_validation())
    print(SignupForm__validate_fields())
    print(SignupForm__field_count())
1 email:string, password:string, age:int 3

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.

nova
@retry(5)
fn fetch_price(symbol: string) -> float
    99.5

fn main()
    print(fetch_price__max_retries())
    print(fetch_price__is_retryable())
5 1

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.

nova
@timeout(2000)
fn call_external_api(url: string) -> string
    "response"

fn main()
    print(call_external_api__timeout_ms())
    print(call_external_api__has_timeout())
2000 1

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.

nova
@singleton
type ConnectionPool
    max_conns: int

fn main()
    print(ConnectionPool__is_singleton())
    print(ConnectionPool__singleton_name())
1 connection_pool

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.

nova
@observable
type FormState
    email: string
    submitted: bool

fn main()
    print(FormState__is_observable())
    print(FormState__observable_fields())
1 email, submitted

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.

nova
@async
fn stream_updates(conn) -> bool
    true

fn main()
    print(stream_updates__is_async())
1

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.

nova
@cache(300)
fn get_homepage(req) -> string
    "homepage"

fn main()
    print(get_homepage__is_cached())
    print(get_homepage__cache_ttl())
1 300

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.

nova
@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())
user.created 1

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.

nova
@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.

nova
@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.

nova
@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.

nova
@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.

nova
@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)
[NOVA LOG] ENTER process_payment charging 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.

nova
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.

nova
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.

nova
const MAX_SIZE = 1024       // with =
const RETRY_LIMIT 3          // without = — parses identically

print(MAX_SIZE)               // 1024
print(RETRY_LIMIT)             // 3
1024
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.

nova
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())
true
[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".

nova
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))
alice
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.

nova
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.

nova
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.

nova
@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
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.

AnnotationTypeWhat it does
@testTier 1Test discovery
@memoTier 1Memoization
@redactTier 1Field masking
@builderTier 1Builder pattern
@comptimeTier 1Compile-time eval
@cdeclTier 1C ABI
@exportTier 1Library export
@deprecatedTier 1Deprecation warn
@logTier 1Entry logging
@get / @post / etc.Tier 1HTTP routing
@entityTier 2ORM metadata
@serviceTier 2Service registry
@validateTier 2Validation
@retryTier 2Retry policy
@timeoutTier 2Timeout policy
@cacheTier 2Cache policy
@eventTier 2Event handler
@injectTier 2Dependency-injection metadata
@middlewareTier 2Middleware registration metadata
@singletonTier 2Singleton marker
@asyncTier 2Async function marker
@observableTier 2Observable/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.

nova
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.

nova
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.

nova
print("runtime ABI: {abi_version()}")   // runtime ABI: 65536
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.

nova
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.

nova
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.

nova
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
enemy destroyed
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.

nova
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.

nova
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)
16777216.0
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):

nova
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.

nova
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))
[1,2,3]
[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:

nova
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
2.5
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.

nova
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.

nova
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
[9.0, 18.0, 27.0, 36.0]
[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.

nova
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.

nova
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.

nova
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.

nova
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

KeywordMeaning
fnDeclare a function
typeDeclare a struct
enumDefine a sum type (variant type)
traitDefine a set of required methods
letBind a name (alternative to bare assignment)
returnExplicit early return from a function
if / elseConditional expression or statement
whileLoop while a condition is true
for / inIterate over a list, dict, string, or range
loopInfinite loop (exit with break or return)
matchPattern match an expression
breakExit the nearest enclosing loop
continueJump to the next iteration of the nearest loop
spawnStart a new green task
sendPut a value on a channel
receiveBlock until a value arrives on a channel
selectBlock until any one of several channels has a value
channelCreate a new channel
importImport a module
extern fnDeclare a C function for FFI
unsafeBlock where safety checks are relaxed
tryUnwrap Ok or propagate Err (like Rust's ?)
true / falseBoolean literals
nullAbsence of a value
and / or / notLogical operators (NOT &&/||/!)
in / not inMembership test
asType cast
matchesRegex pattern test

Operators

OperatorMeaning
+ - * / %Arithmetic
== != < <= > >=Comparison
and or notLogical (short-circuits)
& | ^ << >>Bitwise
in not inMembership test
x => bodySingle-parameter closure
(a, b) => bodyMulti-parameter closure
a..bExclusive integer range (gives a,a+1,...,b-1 — b itself is NOT included)
+= -= *= /= %=Compound assignment
matchesRegex test: s matches "pattern"

Built-in functions — core

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

nova
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}
Hello, world!
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:

nova
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, try propagates the error and the function returns Err(...).
  • if len(line) == 0 or starts_with(line, "#") — Skips empty lines and comment lines.
  • eq = find(line, "=") — Finds the = position. Returns -1 if 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:

nova
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:

nova
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:

nova
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_workersspawn — Spawns N workers (.. is exclusive, so 0..num_workers is exactly num_workers iterations). Each runs a loop pulling from work_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 for loop: 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 on recv(work_ch) forever; the loop bound is 0..num_workers for the same reason as the spawn loop.
  • Final for loop: collect all len(jobs) results (one per job, order not guaranteed) — again 0..len(jobs), not len(jobs) - 1, or the last result is silently dropped.

Group-by / build a map from a list

nova
// 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

nova
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

nova
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:

nova
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 of api_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:

nova
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: Running has a progress %, Done has 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 true loop calls tick until 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:

nova
// 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() — returns true after 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 calling tcp_close(server) once shutdown begins.
  • tcp_close(server) — closing the listener socket is what unblocks a tcp_accept call that's currently parked waiting for the next connection; there's no separate timeout mechanism to wait on.
  • if conn < 0: breaktcp_accept returns 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 break fires on the next unblock, in-flight spawned 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.

nova
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")
Server running -- press Ctrl+C to stop
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.

nova
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")
Loaded config: max_conns = 100
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:

nova
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:

nova
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:

nova
// 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:

nova
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).

nova
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:

nova
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:

PatternWhen action firesUse case
Debounce (300ms)300ms after the LAST eventSearch-as-you-type, resize handlers
Throttle (1000ms)At most once per 1000ms, on the FIRST eventMetrics, API rate limiting, 60fps UI updates
No limitingOn every eventOnly 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:

nova
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:

nova
// 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:

nova
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:

nova
// 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:

nova
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 "").

nova
// 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:

nova
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:

nova
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.

nova
// 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

ProblemRoot causeFix
Struct math 150× slower than expectedCapital type on struct field — e.g. x: FloatChange to lowercase: x: float
&& or || causes syntax errorWrong boolean operatorsUse and, or, not
String { interpolates unexpectedlyAll NOVA strings interpolate by defaultUse \{ for a literal brace
7 / 2 gives 3 not 3.5Integer division truncatesUse 7.0 / 2.0 for float result
Float comparison with == fails intermittentlyIEEE 754 rounding — 0.1 + 0.2 ≠ 0.3Use abs(a - b) < 0.000001
String building in a loop is slows = s + piece is O(n²)push(parts, piece), then join(parts, "") at end
Stack overflow on deep recursion32KB fixed stack, ~3000-deep limitConvert to explicit loop with stack list
Dict lookup returns wrong typeDict values are typed dynamicallyCast explicitly: int(d["count"])
Range 0..4 stops before 4NOVA ranges are EXCLUSIVE of the right bound — 0..4 gives 0,1,2,3, never 4Use 0..len(list) to visit every index — NOT 0..len(list) - 1, which drops the last element
Closure captures wrong valueClosures capture by VALUE at creation timeCorrect 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

ModuleWhat it doesWhen to use
forgeFull HTTP server with routing, middleware, WebSocket, SSE, JSON helpers, query/body parsing, cookiesAny web application or REST API — the main Forge module
forge_dbDatabase connection pooling, query helpers, transaction supportWhen your web app needs to talk to SQLite or PostgreSQL
forge_authJWT creation/verification, CSRF protection, session managementUser authentication and authorization
forge_htmlHTML builder functions (h, p, div, ul, li, a, form, table) — generate HTML without template stringsBuild HTML pages programmatically, auto-escape user content
forge_otpOTP-style supervisors, agents (stateful processes), job queuesFault-tolerant process supervision (restart crashed processes)
forge_distDistributed Forge — run across multiple serversHorizontal scaling when one server is not enough
forge_mqMessage queue — asynchronous task processingBackground job processing (sending emails, processing uploads)
forge_compressResponse compression middleware (gzip)Reduce response sizes by 60-80%
forge_pgPostgreSQL database adapterWhen you need PostgreSQL instead of SQLite
forge_mysqlMySQL database adapter (pooled)When you need MySQL instead of SQLite/PostgreSQL
forge_ormAgnostic ORM over SQLite/PostgreSQL/MySQL — struct-driven CRUD, fluent query builder, migrations, transactions, N+1-safe relation loadingPersisting 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.

ModuleWhat it doesWhen to use
forge_cryptoSHA-256/512/384/MD5, HMAC, PBKDF2, HKDF, AES-CTR/GCM, ChaCha20-Poly1305, X25519 key exchange, Ed25519 signaturesAny time you need to hash, encrypt, sign, or derive keys
forge_x509X.509 certificate parsing and field extractionReading SSL/TLS certificates
forge_p256ECDSA P-256 signature verificationVerify P-256 signatures (common in web PKI and JWTs)
forge_rsaRSA PKCS#1 v1.5 and PSS signature verificationVerify RSA signatures (common in JWT tokens and certificates)
forge_tlsTLS 1.3 key schedule, record layer, handshake, Finished verificationBuilding blocks for TLS 1.3 connections
forge_chainX.509 certificate chain validation (PKI)Verify that a certificate is signed by a trusted CA

Data and math

ModuleWhat it doesWhen to use
bignumArbitrary-precision integers — numbers with hundreds of digitsCryptography, financial calculations, combinatorics
complexnumComplex number arithmetic (add, mul, abs, conjugate)Signal processing, physics, fractal rendering
rationalExact rational fractions (no floating-point rounding)Financial calculations, symbolic math
matrixxMatrix operations (multiply, transpose, determinant, inverse)Linear algebra, transformations, physics
prngSeedable pseudorandom number generator (xoshiro256**)Simulations, games, deterministic testing
bitsetCompact bit arrays (set/clear/test, union, intersection)Compact boolean arrays, permission flags, Bloom filters

Text and encoding

ModuleWhat it doesWhen to use
strxExtended string operations (word wrap, title case, levenshtein distance)When built-in string functions are not enough
basexBase32, Base58, Base64 encoding and decodingEncode binary data as text (email, URLs, cryptocurrency)
graphemexUnicode grapheme cluster operations — correctly handle emojis, accented lettersCorrect Unicode text handling beyond ASCII
csvxCSV parsing and generationReading and writing spreadsheet-compatible data files
urlxURL parsing, query string encoding/decoding, path manipulationConstruct or decompose URLs
deflatexDEFLATE compression and decompressiongzip/ZIP file handling

Utilities

ModuleWhat it doesWhen to use
corexCore utilities (deep equality, deep clone, type checks)General-purpose programming
collxCollection utilities (group_by, chunk, zip, interleave, frequencies)List/dict transformations beyond map/filter/reduce
setopsSet operations (union, intersection, difference) on listsMathematical set operations
getinNested data access (get_in, assoc_in, update_in) for deep dicts/listsComplex JSON structures: get_in(data, ["users", 0, "address", "city"])
uuidUUID v4 generationUnique IDs for database records, session tokens
proptestProperty-based testing — generate random inputs and check propertiesTesting 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

FunctionWhat it does
seq_fold(xs, init, f)Fold left with an explicit seed, like reduce
seq_reduce(xs, f) -> ResultFold with no seed — takes it from xs[0]; Err on an empty list instead of crashing
seq_find(xs, pred) -> ResultFirst 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
nova
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")
278
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

FunctionWhat 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
nova
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
[a, b, c]
[[1, 2], [3, 4], [5]]

import std/core/dict — dict combinators

FunctionWhat 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
nova
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
[alice, carol]
83

import std/core/sort — stable, non-mutating sorts

FunctionWhat 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
nova
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
[3, 7, 19, 42]
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
fmapfm_map(f, xs) · fm_map_indexed(f, xs) · fm_flat_map(f, xs)
ffilterff_filter(pred, xs) · ff_reject(pred, xs) · ff_partition(pred, xs) -> [kept, rejected]
freducefr_foldl(f, init, xs) · fr_foldr(f, init, xs) · fr_scanl(f, init, xs) (running accumulator, length+1)
fchainfch_pipe2/3(x, f, g[, h]) · fch_compose2(x, f, g) · fch_apply_all(x, f, g, h)
faccumulatefa_sum_by(f, xs) · fa_product_by(f, xs) · fa_max_by_value(f, xs) · fa_count_where(pred, xs)
fgroupbyfg_group_by(keyfn, xs) (global groups) · fg_partition_by(pred, xs) (consecutive runs) · fg_count_by(keyfn, xs)
fpredicatefpr_all/any/none/count(pred, xs) · fpr_find_index(pred, xs) -> int
fiteratefi_apply_n(f, n, x) · fi_iterate(f, x, n) -> [x, f(x), ...]
fsortbyfs_sort_by(keyfn, xs) · fs_min_by/fs_max_by(keyfn, xs)
fzipwithfz_zip_with(f, a, b) · fz_zip_with3(f, a, b, c) · fz_zip_with_index(f, xs)
ftakewhileftw_take_while(pred, xs) · ftw_drop_while(pred, xs) · ftw_span(pred, xs) -> [taken, dropped]
funcf_identity · f_compose(f, g) · f_pipe2(f, g) · f_const(k) · f_apply_n · f_memoize1(f) (returns a closure)
fcombinatorfc_flip(f, a, b) · fc_on(f, g, a, b) · fc_apply/fc_apply2 · fc_twice(f, x)
nova
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]
[3, 6]
[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_chunkitc_chunk(xs, size) · itc_chunk_pad(xs, size, pad) · itc_chunk_count · itc_evenly(xs, n)
it_flattenitf_flatten(xss) · itf_concat(a, b) · itf_concat_all(lists) · itf_repeat_concat(xs, n)
it_groupitg_group_consecutive(xs) · itg_rle(xs) · itg_rle_decode(pairs) · itg_run_lengths(xs)
it_interleaveiti_interleave(a, b) · iti_intersperse(xs, sep) · iti_roundrobin(lists) · iti_weave(a, b)
it_partitionitp_split_at(xs, i) · itp_partition_parity(xs) · itp_halve(xs) · itp_split_runs(xs, n)
it_productitpr_cartesian(a, b) · itpr_cartesian3 · itpr_count · itpr_pairs_upper(xs) (all i < j pairs)
it_rotateitr_rotate_left/right(xs, k) · itr_cycle_take(xs, n) · itr_reverse(xs)
it_take_dropitt_take/drop(xs, n) · itt_take_last/drop_last · itt_slice(xs, start, stop) · itt_step(xs, step)
it_uniqueitu_unique(xs) · itu_count_distinct · itu_duplicates(xs) · itu_is_unique(xs)
it_windowitw_window(xs, k) · itw_pairwise(xs) · itw_windows_count · itw_triples(xs)
it_zipitz_zip/zip3 · itz_unzip(pairs) · itz_zip_longest(a, b, fill) · itz_enumerate(xs)
it_accumulateita_sums(xs) (prefix sums) · ita_products · ita_maxima/minima · ita_diffs(xs)
itertoolsit_range · it_chain · it_zip · it_take/drop · it_windowed · it_chunked · it_enumerate · it_flatten · it_repeat (self-contained aggregator surface)
nova
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], [3, 4], [5]]
[[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

nova
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

nova
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

nova
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

nova
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

nova
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

nova
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
[1, 2, 3, 4, 5]
[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...ImportKey function
Build a web serverforgeforge.app() + forge.get/post + forge.serve
Hash a passwordforge_cryptopbkdf2_sha256(password, salt, 100000)
Parse a CSV filecsvxcsvx.parse_file(path)
Access deeply nested JSONgetingetin.get_in(data, ["a", "b", "c"])
Generate unique IDsuuiduuid.v4()
Arbitrary-precision mathbignumbignum.add(a, b), bignum.pow(base, exp)
Group a list by propertycollxcollx.group_by(xs, key_fn)
Property-based testingproptestproptest.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

NOVAPythonGo
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

NOVAPythonJavaScript
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

NOVAPython (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

NOVARustPython
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

NOVAPythonJava
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

NOVARustPython (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

CommandDescription
nova new <name>Create project skeleton (--api default, or --microservice/--frontend/--fullstack/--lib) — Chapter 1
nova initCreate nova.toml in the current directory — Chapter 1
nova setupPre-compile the runtime cache — one-time, ~38× faster builds — Chapter 1
nova cleanRemove .ll/.exe build artifacts — below
CommandDescription
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
CommandDescription
nova testRun 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
CommandDescription
nova replInteractive read-eval-print shell — Chapter 1
nova debug <file>Build with debug info and launch lldb — below
nova lsp / nova --lspStart the LSP server for IDE integration — Chapter 1
CommandDescription
nova get <package>[@ver]Add a dependency to nova.toml — below
nova installDownload all dependencies from nova.toml — below
CommandDescription
nova version / nova --versionShow installed version — below
nova self-testRun 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.

bash
nova build server.nova
ls
nova clean
ls
server.nova server.ll server.exe

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.

nova
fn add(a, b)
    a + b

result = add(3, "four")
bash
nova check bad.nova
bad.nova:4: type mismatch in add(a, b) — parameter 'b' is used as int (from a + b, inferred against the 3 passed on line 1's call), but "four" is a string

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.

bash
nova lint messy.nova
messy.nova:1: tab character found — NOVA expects spaces for indentation
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.

bash
nova fmt messy.nova
nova format messy.nova   # identical alias
Formatted messy.nova

DO: 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.

bash
nova debug crash.nova
(lldb) target create "crash"
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.

bash
nova get http_client@1.2.0
nova get json_schema
nova install
Added http_client@1.2.0 to nova.toml
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:

toml
[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.

bash
nova version
nova self-test
NOVA v0.1.0

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.

#FrameworkDomain
1ForgeWeb backend (HTTP, WebSocket, ORM, auth)
2ReactorGame engine (ECS, physics, rendering)
3CortexAI/ML (tensors, training, inference)
4MeshDistributed systems (CRDTs, Raft, remote spawn)
5PrismGUI — web frontend (browser DOM/WASM) + desktop (native)
6PulseData/analytics (dataframes, streaming, ETL)
7SentinelSecurity (crypto, post-quantum, HSM, Secret<T>)
8EdgeEmbedded/IoT (MCU, freestanding, drivers)
9OpsDevOps (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.

Normative reference

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

nova
// 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.

nova
// 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

FormExampleType
Integer42, -7, 1_000_000int (64-bit)
Hex integer0xFF, 0xCAFE_F00Dint
Float3.14, 1e10float (64-bit)
String"hello", "line\nbreak"string
Booltrue, falsebool
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 double
  • booltrue or false
  • string — immutable UTF-8 byte sequence
  • unit — the type of a no-value expression

Containers

  • list<T> — dynamic array, heap-allocated, reference-counted
  • dict<K, V> — open-addressing hash map, heap-allocated, reference-counted

User-defined (structs)

nova
type Name
    field1: T1
    field2: T2

Enums (sum types)

nova
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.

nova
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 the spawn machinery away to zero overhead.
  • Channels — every cross-process communication. A send may move the value. Under NOVA_TRACK8=1 this is checked statically at compile time.

4. Expressions

PrecedenceOperators
Highestf(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

nova
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

nova
fn name(params) -> return_type
    body

Parameters and return type are optional; both are inferred. Functions are first-class values.

Methods

nova
fn TypeName.method(self, args) -> R
    body

Traits

nova
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:

  1. Public function signatures (documentation + better errors)
  2. 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 _.

FormEffect
import mathBinds math.* qualified
import math as mBinds 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 typeC type
intint64_t
i32int32_t
floatdouble
stringconst char * (NUL-terminated)
ptrvoid *
boolint (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).

nova
@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.

nova
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
  1. Lexer — tokenizes UTF-8 source
  2. Parser — Pratt-style precedence parser builds an AST
  3. Type inferrer — Hindley-Milner + constraint solving + trait conformance
  4. IR builder — lowers AST to basic-block IR with named registers
  5. Optimizer — TCO, dead-block/instruction elimination, constant folding, escape analysis
  6. LLVM emitter — writes textual LLVM IR with TBAA metadata and DWARF debug info
  7. clang — links .ll with nova_runtime.c to 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.