Memory & ownership

How Nyra manages memory at compile time — no garbage collector, no manual free in normal code, and no leaks when you follow the rules the compiler enforces. For developer-friendly ownership (auto-borrow, auto-Copy, inference) see Ownership UX.

Philosophy: safety without a GC

Nyra uses compile-time ownership inspired by Rust, with Go-like syntax. Every value has a clear owner. When ownership ends, the compiler inserts cleanup code — you never call free on ordinary strings or owned return values.

Ownership UX

Nyra keeps Rust’s static ownership model but reduces day-to-day friction:

Full guide: Ownership UX · Conformance: CONF-INF-*, CONF-COERCE-*, CONF-DIAG-*, CONF-COPY-*, CONF-F64-*.

struct User {
    name: string
    age: i32
}

fn save(u: &User) {
    print(u.name)
}

fn main() {
    let user = User { name: "Ada", age: 30 }
    save(user)
    print(user.name)
}

Copy vs Move types

The compiler classifies every type as Copy or Move (ownership/kind.rs).

KindTypesOn assignment / passAt scope end
Copyi32, i64, u32, f64, bool, enum tags, ptr, fn pointers, all-Copy structsBitwise copy — both names validStack discarded — no heap call
Movestring (heap C-string), structs with move fields, types with impl DropOwnership transfers — old name invalidAuto free or custom Drop_*_drop
fn main() {
    let a = 10
    let b = a          // Copy — both usable
    print(a)
    print(b)

    let s = "hello"
    let t = s            // Move — s is now invalid
    print(t)
    // print(s)         // ERROR: use of moved value 's'
}

Ownership rules

  1. One owner — each heap-owned value has exactly one binding responsible for cleanup.
  2. Move by default — passing a string to a function or assigning it moves ownership unless the binding is mut and the callee borrows.
  3. Borrow, don’t steal&T and &mut T temporarily share access without transferring ownership.
  4. No active borrows on move — you cannot move a value while an immutable or mutable reference to it is live (NLL tracks this).
  5. Returned owned values — functions like read_file, strcat return new heap strings; the caller owns the result.
fn greet(name) {
    return strcat("Hello, ", name)
}

fn main() {
    let msg = greet("Nyra")
    print(msg)
}

Automatic cleanup (Drop plan)

After type-checking, the ownership analyzer builds a DropPlan per function (ownership/drop.rs):

  1. Scan every let binding and infer whether it owns heap memory.
  2. Track moves — if a value is moved, mark it so it is not dropped twice.
  3. At each scope exit (end of block, before return, on branch merge), codegen emits emit_auto_drops.
  4. For string locals still owned → call free(ptr).
  5. For structs with impl Drop → call Drop_TypeName_drop.
fn load_and_print() {
    let data = read_file("config.txt")   // owned heap string
    print(data)
}                                             // compiler inserts: free(data)

You do not write free(data) in normal Nyra code. The compiler guarantees cleanup on every path that still owns the binding.

Composite struct cleanup

Structs with heap-owned fields (e.g. string) but no custom impl Drop get automatic field-wise cleanup — the compiler emits free for each owned field at scope end:

fn main() {
    let p = { id: 1, body: read_file("data.txt") }
    print(p.id)
}                         // free(p.body) inserted automatically

Nested structs without custom Drop are dropped recursively. Custom impl Drop still takes full control (no double field-free).

Owned extern fn returns

Any extern fn foo(...) -> string in your program is automatically treated as returning heap-owned memory — no need to register symbols in a compiler whitelist:

extern fn my_api_load(){
    let s = my_api_load()   // auto-owned, auto-dropped
    print(s)
}

Partial field moves

Moving a single field out of a struct invalidates the parent binding (Rust-style), preventing use-after-partial-move bugs:

fn main() {
    let p = { a: 1, body: "hello" }
    let body = p.body        // move field — p is now invalid
    print(body)
    // print(p.a)           // ERROR: use of moved value 'p'
}

Generic Option / Result

enum Option<T> { None, Some(T) }
let msg = Option.Some("hello")   // monomorph → Option__string
let n = Option.Some(42)                          // literal → Option__i32

Heap payloads in generic enums (e.g. Option<string>) get automatic payload cleanup at scope end.

Import from stdlib: import "stdlib/option.ny". Typed annotations and match patterns rewrite to monomorphized names (Option__string) at compile time.

Monolith-friendly structs + strings

Large single-file apps can combine multi-field structs, partial field moves, generic Option<string>, and Box<T> without manual Drop for string fields:

import "stdlib/option.ny"
import "stdlib/box.ny"

struct UserRecord Send { id: i32, name: string, email: string }

fn lookup(id, label) {
    if id > 0 { return Option.Some(label) }
    return Option.None
}

fn main() {
    let u = UserRecord { id: 1, name: "Ada", email: "ada@nyra.dev" }
    let id = u.id
    let name = u.name
    let opt = Option.Some(name)
    match opt {
        Option.Some(n) => print(n),
        Option.None => print("none"),
    }
    let boxed = Box<string> { value: "cache" }
    print(id)
    print(boxed.value)
}

See examples/monolith_struct_smoke.ny for a full compile-ready sample.

Shared ownership with Arc<T>

Reference-counted sharing for graphs and cross-scope labels. Import stdlib/arc.ny:

import "stdlib/arc.ny"

struct GraphEdge Send {
    from_id: i32
    to_id: i32
    shared_label: Arc<string>
}

fn share_label(name) {
    let root = Arc_from_string(name)
    return Arc_clone_string(root)
}

fn main() {
    let shared = share_label("node-alpha")
    let edge = GraphEdge { from_id: 1, to_id: 2, shared_label: shared }
    print(Arc_get_string(edge.shared_label))
}

Arc<T> is Send + Sync; Arc_clone_string / Arc_clone_i32 increment refcount; last Drop frees the payload (arc_dec_i32 / arc_dec_string). See examples/graph_arc_smoke.ny.

When auto-drop runs

EventWhat gets dropped
End of fn / blockAll owned bindings still in scope and not moved
returnOwned locals not returned as the value
Move to function argSource binding — callee or copy owns cleanup
defer exprDeferred expressions run LIFO before scope exit

Non-lexical borrows (NLL)

Unlike lexical-only systems, Nyra ends borrows at the last use of the reference, not at the closing brace. This is Non-Lexical Lifetimes (ownership/nll.rscompute_last_uses).

fn main() {
    let mut s = "hello"
    let r = &s              // immutable borrow starts
    print(r)                 // last use of r — borrow ENDS here
    s = "world"                // OK — no active borrow
}
fn broken() {
    let mut s = "hello"
    let r = &mut s          // mutable borrow
    print(s)                 // ERROR: cannot use s while mutably borrowed
    print(r)
}

See also: Nyra Borrowing for a guided lesson.

Lifetimes

References carry lifetimes so the compiler can reject dangling references (returning &local after the local is destroyed).

// ERROR: cannot return reference to local
fn dangling(){
    let s = "tmp"
    return &s
}

// OK: return value transfers ownership instead
fn owned() {
    let s = "tmp"
    return s
}

How Nyra prevents memory leaks

A memory leak is heap memory that is never freed. Nyra prevents leaks in normal code through:

1. Drop plan covers every owned binding

If you write let s = read_file(...) and never move s, the compiler always emits free at scope end — even if you forget the variable exists.

2. Moves transfer cleanup responsibility

fn consume(s) {
    print(s)
}                           // consume's drop plan frees s here

fn main() {
    let path = "data.txt"
    let content = read_file(path)
    consume(content)        // ownership moved to consume — main won't double-free
}

3. No silent heap allocation without ownership

Functions that return heap memory (strcat, read_file, sys_recv, …) are registered in OWNED_EXTERN_RETURNS. The caller must bind the result — orphaning is impossible because unused expressions in let still get a binding or the value is moved/consumed.

4. Closure & spawn heap env freed at scope end

Escaping closures that malloc capture envs register heap_owned metadata — freed when the let binding ends. Stack closures need no heap free.

5. Custom Drop for resource types

struct FileHandle { fd: i32 }

impl Drop for FileHandle {
    fn drop(self) {
        nyra_close(self.fd)    // always runs when FileHandle goes out of scope
    }
}

What Nyra does not prevent automatically

Double-free & use-after-free

Nyra blocks these at compile time:

BugCompile-time checkExample error
Use after moveMove tracking in borrowckUse of moved value 's'
Double freeMoved values skipped in DropPlanCannot happen if code compiles
Free then usefree on owned binding → warning + moved markManual free warning
Dangling &Lifetime checkercannot return reference to local
Data race (thread)Send / Sync on spawn capturescaptured value is not Send
fn main() {
    let s = "hello"
    let t = s                // move
    print(s)                 // ERROR: use of moved value 's'
}

defer vs Drop (Extended)

Name: defer expr
Explanation: Schedules a call at scope exit (LIFO). Prefer impl Drop for resources — do not defer free on owned strings.
Example: methods gallery · nyra run examples/builtins/defer/defer_return.ny

allow_extended
fn cleanup() { print(1) }
fn main() {
    defer cleanup()
    return
}

Output: 1 (before function returns). LIFO demo: #ex-defer-lifobody, a, b.

defer schedules a call expression at scope exit (LIFO). It lives in the Extended tier. For most code, auto-drop and impl Drop replace defer — do not use defer free on owned strings (double-free risk).

NeedPreferdefer?
Heap string at }Auto-dropNo
FFI handle / fileimpl Drop wrapper (RAII)Only one-off defer extern_close(h) if you skip a struct
Log at scope exitWrite before return or in DropOptional defer log_done()
extern fn gzclose(f){ handle: ptr }

impl Drop for GzFile {
    fn drop(self) {
        unsafe { gzclose(self.handle) }
    }
}

fn main() {
    let f = GzFile { handle: open_gz("log.txt.gz") }
    print(read_all(f.handle))
}                            // gzclose via Drop — preferred over defer

Why Extended, not Core: Drop covers cleanup semantics Core needs; defer overlaps and is mainly for niche FFI/side-effect hooks. Core-only CI uses nyra check --deny-extended with auto-drop only. See nyra-skill.md § defer vs Drop.

Custom Drop for structs

struct Buffer { data: ptr, len: i32 }

impl Drop for Buffer {
    fn drop(self) {
        free(self.data)
    }
}

fn main() {
    let b = Buffer { data: allocate(), len: 1024 }
}                            // Drop_Buffer_drop called automatically

Structs with impl Drop are always Move types — they cannot be silently copied.

spawn, closures & Send/Sync

spawn

spawn { ... } packs captured variables into a struct, heap-copies it, and starts a pthread. Rules:

Closures

Capturing lambdas follow the same rules — no reference captures; move types transfer ownership. See Closures.

FFI boundary

Inside Nyra: auto-drop. At the C boundary:

Full ABI: FFI & ABI.

Type layout (size_of / align_of)

Measure how many bytes a type occupies (and its alignment). Useful for FFI buffers, repr(C) structs, and understanding integer widths. Multiply by 8 for bits. Intrinsics need no import; helpers live in stdlib/mem/layout.ny.

struct Packet repr(C) align(8) {
    kind: i32
    port: i32
}

fn main() {
    print(size_of<i32>())         // 4 bytes = 32 bits
    print(size_of<i64>())         // 8 bytes = 64 bits
    print(size_of<Packet>())      // 8
    print(align_of<Packet>())     // 8
}

Output

4
8
8
8

More examples: stdlib → size_of / align_of.

Compiler pipeline

Source (.ny)
  → Lexer → Parser
  → Macro expand (??, ?., struct ctors, move/clone desugar)
  → Monomorph (+ generic call-site inference)
  → Auto-borrow coercion
  → Const fold → Typecheck
  → Ownership analysis → DropPlan (+ auto-Copy inference)
  → Borrow check (NLL moves/borrows, callee-aware diagnostics)
  → Lifetime check
  → Send / Sync check
  → LLVM codegen (auto-drops, spawn, closures, f64/double)
  → opt → clang + Nyra runtime → binary

Inspect without codegen: nyra check . · Structured errors: nyra diag . --json · Ownership snapshot: nyra inspect name --at main.ny:42 · Ownership inspect guide

Common errors & fixes

ErrorCauseFix
Use of moved value 'x'Used after moveBorrow with &, use clone x / .clone(), or change callee to &T (auto-borrow applies)
Cannot borrow as mutableTwo &mut or & + &mutShrink borrow scope; use NLL — end borrow before second use
cannot return reference to localDangling refReturn owned string or take &'a parameter
cannot capture reference in closure& in lambda captureCapture owned copy or use Send move value
free on live owned bindingManual free + auto-dropRemove manual free; let auto-drop handle it

More: Diagnostics

Examples cookbook

Read file — zero manual free

extern fn read_file(path){
    let text = read_file("README.md")
    print(text)
}                            // free(text) inserted by compiler

Concatenate strings — both operands moved unless borrowed

extern fn strcat(a, b){
    let a = "Hello, "
    let b = "Nyra"
    let msg = strcat(a, b)   // a and b moved into strcat; new string returned
    print(msg)
}                                 // only msg freed — a,b already consumed

Safe mutation with &mut

fn bump(mut n) {
    n = n + 1
    print(n)
}

fn main() {
    let mut count = 0
    bump(count)               // i32 is Copy — count still valid
    print(count)
}

Option ?? — no leak on either branch

import "stdlib/option.ny"

fn main() {
    let x = Option.None
    let y = x ?? 42           // i32 Copy — no heap
    print(y)
}