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.
- No garbage collector — deterministic, low-latency native code.
- No reference counting in the language core — ownership is static.
- Zero cost for
i32,bool, enums without payloads — stack only. - Compile errors instead of runtime crashes for use-after-move, double-free, and dangling references.
- Escape analysis — Nyra proves local values at compile time and promotes them to the stack; sequential channels can become lock-free
LocalChannel. See Escape analysis.
Ownership UX
Nyra keeps Rust’s static ownership model but reduces day-to-day friction:
- Auto-borrow —
save(user)becomessave(&user)when the callee expects&User. - Auto-Copy — structs like
Point { x: i32 y: i32 }copy on assign without#[derive(Copy)]. - Inference — return types, generic call sites (
id(7)), andlet price = 3.14→f64. - Explicit control —
move user/clone userat calls; Swift-style errors with fix-it notes.
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)
}struct User {
name: string
age: i32
}
fn save(u: &User) {
print(u.name)
}
fn main() -> void {
let user: 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).
| Kind | Types | On assignment / pass | At scope end |
|---|---|---|---|
| Copy | i32, i64, u32, f64, bool, enum tags, ptr, fn pointers, all-Copy structs | Bitwise copy — both names valid | Stack discarded — no heap call |
| Move | string (heap C-string), structs with move fields, types with impl Drop | Ownership transfers — old name invalid | Auto 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'
}fn main() -> void {
let a: i32 = 10
let b = a // Copy — both usable
print(a)
print(b)
let s: string = "hello"
let t = s // Move — s is now invalid
print(t)
// print(s) // ERROR: use of moved value 's'
}Ownership rules
- One owner — each heap-owned value has exactly one binding responsible for cleanup.
- Move by default — passing a
stringto a function or assigning it moves ownership unless the binding ismutand the callee borrows. - Borrow, don’t steal —
&Tand&mut Ttemporarily share access without transferring ownership. - No active borrows on move — you cannot move a value while an immutable or mutable reference to it is live (NLL tracks this).
- Returned owned values — functions like
read_file,strcatreturn new heapstrings; the caller owns the result.
fn greet(name) {
return strcat("Hello, ", name)
}
fn main() {
let msg = greet("Nyra")
print(msg)
}fn greet(name: string) -> i32 {
return strcat("Hello, ", name)
}
fn main() -> void {
let msg = greet("Nyra")
print(msg)
}Automatic cleanup (Drop plan)
After type-checking, the ownership analyzer builds a DropPlan per function (ownership/drop.rs):
- Scan every
letbinding and infer whether it owns heap memory. - Track moves — if a value is moved, mark it so it is not dropped twice.
- At each scope exit (end of block, before
return, on branch merge), codegen emitsemit_auto_drops. - For
stringlocals still owned → callfree(ptr). - For structs with
impl Drop→ callDrop_TypeName_drop.
fn load_and_print() {
let data = read_file("config.txt") // owned heap string
print(data)
} // compiler inserts: free(data)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 automaticallyfn main() -> void {
let p = { id: 1, body: read_file("data.txt") }
print(p.id)
} // free(p.body) inserted automaticallyNested 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)
}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'
}fn main() -> void {
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__i32enum Option<T> { None, Some(T) }
let msg = Option.Some("hello") // monomorph → Option__string
let n = Option.Some(42) // literal → Option__i32Heap 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)
}import "stdlib/option.ny"
import "stdlib/box.ny"
struct UserRecord Send { id: i32, name: string, email: string }
fn lookup(id: i32, label: i32) -> i32 {
if id > 0 { return Option.Some(label) }
return Option.None
}
fn main() -> void {
let u: UserRecord = 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))
}import "stdlib/arc.ny"
struct GraphEdge Send {
from_id: i32
to_id: i32
shared_label: Arc<string>
}
fn share_label(name: string) -> i32 {
let root = Arc_from_string(name)
return Arc_clone_string(root)
}
fn main() -> void {
let shared = share_label("node-alpha")
let edge: GraphEdge = 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
| Event | What gets dropped |
|---|---|
End of fn / block | All owned bindings still in scope and not moved |
return | Owned locals not returned as the value |
| Move to function arg | Source binding — callee or copy owns cleanup |
defer expr | Deferred 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.rs → compute_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 main() -> void {
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)
}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
}// ERROR: cannot return reference to local
fn dangling(){
let s: string = "tmp"
return &s
}
// OK: return value transfers ownership instead
fn owned() -> i32 {
let s: string = "tmp"
return s
}- Explicit:
fn pick<'a>(x: &'a string) -> &'a string - Elision: one input reference → output shares that lifetime
- HRTB:
for<'a> fn(&'a string) -> &'a stringfor higher-order callbacks
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
}fn consume(s: string) {
print(s)
} // consume's drop plan frees s here
fn main() -> void {
let path: string = "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
}
}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
- Intentional leak via FFI — returning a raw
ptrto C without documenting ownership. - Manual
freeon live bindings — compiler warns; double-free if you free then auto-drop runs. - Cycles — no cycle collector; avoid reference cycles in raw pointers.
Double-free & use-after-free
Nyra blocks these at compile time:
| Bug | Compile-time check | Example error |
|---|---|---|
| Use after move | Move tracking in borrowck | Use of moved value 's' |
| Double free | Moved values skipped in DropPlan | Cannot happen if code compiles |
| Free then use | free on owned binding → warning + moved mark | Manual free warning |
Dangling & | Lifetime checker | cannot return reference to local |
| Data race (thread) | Send / Sync on spawn captures | captured value is not Send |
fn main() {
let s = "hello"
let t = s // move
print(s) // ERROR: use of moved value 's'
}fn main() -> void {
let s: string = "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
}allow_extended
fn cleanup() { print(1) }
fn main() -> void {
defer cleanup()
return
}Output: 1 (before function returns). LIFO demo: #ex-defer-lifo → body, 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).
| Need | Prefer | defer? |
|---|---|---|
Heap string at } | Auto-drop | No |
| FFI handle / file | impl Drop wrapper (RAII) | Only one-off defer extern_close(h) if you skip a struct |
| Log at scope exit | Write before return or in Drop | Optional 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 deferextern fn gzclose(f){ handle: ptr }
impl Drop for GzFile {
fn drop(self) {
unsafe { gzclose(self.handle) }
}
}
fn main() -> void {
let f = GzFile { handle: open_gz("log.txt.gz") }
print(read_all(f.handle))
} // gzclose via Drop — preferred over deferWhy 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 automaticallystruct Buffer { data: ptr, len: i32 }
impl Drop for Buffer {
fn drop(self) {
free(self.data)
}
}
fn main() -> void {
let b: Buffer = Buffer { data: allocate(), len: 1024 }
} // Drop_Buffer_drop called automaticallyStructs 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:
- Copy captures (
i32,bool) — copied into the thread struct. - Move captures (
string) — must beSend; marked moved in the parent. - Active
&/&mut— compile error.
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:
export fnreturningstring→ caller outside Nyra must callfree.extern fnreturningstring→ Nyra caller auto-owns and auto-drops.- Do not call
freeon live Nyra bindings — the compiler warns.
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
}struct Packet repr(C) align(8) {
kind: i32
port: i32
}
fn main() -> void {
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
| Error | Cause | Fix |
|---|---|---|
Use of moved value 'x' | Used after move | Borrow with &, use clone x / .clone(), or change callee to &T (auto-borrow applies) |
Cannot borrow as mutable | Two &mut or & + &mut | Shrink borrow scope; use NLL — end borrow before second use |
cannot return reference to local | Dangling ref | Return owned string or take &'a parameter |
cannot capture reference in closure | & in lambda capture | Capture owned copy or use Send move value |
free on live owned binding | Manual free + auto-drop | Remove 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 compilerextern fn read_file(path){
let text = read_file("README.md")
print(text)
} // free(text) inserted by compilerConcatenate 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 consumedextern fn strcat(a, b){
let a: string = "Hello, "
let b: string = "Nyra"
let msg = strcat(a, b) // a and b moved into strcat; new string returned
print(msg)
} // only msg freed — a,b already consumedSafe 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)
}fn bump(mut n: i32) {
n = n + 1
print(n)
}
fn main() -> void {
let mut count: i32 = 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)
}import "stdlib/option.ny"
fn main() -> void {
let x = Option.None
let y = x ?? 42 // i32 Copy — no heap
print(y)
}