A statically typed language and C++20 compiler that owns the path from source to a
native binary: lexing, parsing, module resolution, type checking, reachable generic
monomorphization, LLVM IR generation, optional optimization, object emission, and host
linking on macOS and Linux.
Noria is a small, statically typed language and compiler. Noria has a stdlib containing dynamic arrays, linked lists, BSTs,
open-addressed hash maps/sets, and heaps.
Scalars:i32, bool, f64, void
Aggregates:[T], str, struct
ADTs:Sequence, Dictionary, Set with compile-time impl tags
import std::sequence::{Sequence, sequence_len, sequence_push};
helper sum(values: Sequence<i32>) {
let i: i32;
let total: i32;
while i < sequence_len(values) {
total = total + values[i];
i = i + 1;
}
return total;
}
fn main() {
let values: Sequence<i32>;
sequence_push(values, 10);
sequence_push(values, 32);
return sum(values);
}
From source to a native binary
The compiler follows a facade pattern with checkpoints used for testing: StopAfter::Tokens, Ast, Typed,
and Ir. C++ tests drive
the same pipeline without writing files.
host clangnative link · macOS and Linux, x86-64 and ARM64executable
Ownership, no garbage collector
Strings, arrays, structs with heap allocated fields, and the stdlib containers are managed.
Each one is freed exactly once. There is no garbage collector.
clone · borrow · move · drop
let b = a; // deep clone; a and b free independently
sequence_push(s, x); // borrow; mutation is visible to the caller
return owned; // move the local to the caller
return param; // clone so the caller still has a valid value
holder.nested = []; // free the old value, then store
let numbers: Sequence<i32>; // empty default is still owned
print("xy" + "z"); // concat allocates; the parts are freed
s[i] // string element: clone
a[i] // array element: borrow
Locals
freed when the block ends or you reassign
Fields and indexes
the struct or array still owns what is stored there
Defaults and concatenation
you own what you just created, including empty containers
Temporariesprint, len, and indexing free the temporary after use
Generated code tracks whether each value is owned. Arguments are borrowed, so a
callee can mutate a container without taking it. Returning an owned local moves it;
returning a parameter clones. Overwriting a field or index frees what was there
first. Each scope has a flag containsPtr that is used to free resources on scope exit.
codegen · drop on scope exit
scopes_ stack
function · demo()containsPtr: false
k · i32
block · { … }containsPtr: true
buf · [i32] · owned
block exit
free owned locals, then pop the block
while body · { … }containsPtr: false
i · i32
block exit
early return, no drop IR emitted
return
free remaining owned locals, inner → outer
emitted IR · pseudocode
; block exit, only if scope.containsPtr
owned ← load bool from %buf.owned
if not owned: goto next_local
ptr ← load ptr from %buf.slot
call @free(ptr) ; [i32] heap buffer
; same owned-bit gate for other managed locals:
call @__noria.rt.drop_str(str_ptr)
call @sequence_drop$s.i32(handle) ; one monomorphized specialization
next_local:
; scalar-only scope (containsPtr = false)
; → no instructions inserted
Memory model & structs
Structs are stack-allocated value aggregates, with members accessed through compile-time
known offsets.
Array data lives on the heap; the variable itself is a stack pointer to that buffer.
Empty arrays and array concatenation are owned.
Function arguments borrow that pointer, so kth_largest(nums, k) sees the
same buffer as the caller. let copy = nums is different: that's a deep
clone, and the two arrays drop independently.
memory · kth_largest(nums, k)
stack frame
heap
ki322
ii324
nums[i32]ptr
→
malloc · nums bufferlen: i64 = 6 @ offset 0[ 3, 2, 1, 5, 6, 4 ]borrowed at the call; caller still owns it
heapSequence<i32,arr>ptr
→
Sequence<i32,arr> backing store
stack grows ↑ · scalars live in-frame · pointers alias heap
Passing nums borrows the pointer into the callee frame, both frames alias
the same heap buffer. Assignment clones. Only scalars live entirely on the stack.
Compiler performance
Phase timings over 19,600 in-process compilations (196 inputs × 100
rounds, through LLVM IR) surfaced a hot path in monormphization and resolving stdlib imports. Through implementing frontier-only specialization and caching of frequently used AST components benchmark latency was reduced from
27.69s → 7.05s
19,600compilations
27.69→7.05saggregate through IR
74.5%lower latency
3.93×throughput
27.69sbaseline, before reusable AST-component caching
Two LFU caches: 64 parsed stdlib modules, 256 specializations.
Frequency buckets with deterministic oldest-in-min-bucket eviction.
Selective admission
Function specializations below a 1 KiB AST weight and structs below eight fields are cheaper to rebuild than keep.
Generics & monomorphization
Generic functions and structs infer type arguments at call sites, check
implementation-tag constraints, then monomorphize only the reachable specializations
before LLVM codegen. There is no type erasure and no vtable: each distinct
T / I pair becomes a concrete body with a deterministic
mangled name such as id$s.i32.
monomorphize · src/monomorphize/Monomorphize.cpp
1
Unify
infer T, I · check constraints
→
2
Sort
mangled name · location
→
3
Dedup
clone templates once
→
4
Frontier
check new bodies only
→
5
Enqueue
reachable callees
→
6
Rewrite
strip templates · LLVM
Abstract Data Types vs Data Structures
Many programming languages give users access to a wide variety of data structures, without having to define their interfaces.
This can leave users confused about which data structures to use for which applications. For instance, in C++, vector, deque, and list are all
sequences, but nothing about the name or high level code indicates that they are different from set.
Noria names the abstract data type first, then picks the
structure as a compile-time tag, the user chooses the interface before
the implementation. Leave the tag off and you still get a real default:
Sequence<T> is arr, Dictionary and
Set are hashmap. Changing a tag changes representation and
complexity, not the API. Monomorphization erases the tag: there is no vtable and no
runtime representation branch.
stdlib · ADT + impl tag
Sequence<T, I>
push · pop · get · set · insert · remove · len
impl arrdynamic array · get O(1)
impl listdoubly linked · get O(n)
Dictionary<K, V, I>
insert · get · contains · remove · len
impl hashmapopen addressing · O(1) avg
impl bstordered tree · O(h)
Set<T, I>
insert · contains · remove · len
impl hashmaphash table set · O(1) avg
impl bstBST set · O(h)
Same ADT, different structure
let seq: Sequence<i32>; // empty, impl arr
let map: Dictionary<i32, i32>; // empty, impl hashmap
let tree: Dictionary<i32, i32, bst> = dictionary_new(0, 0);
Those representations are not compiler magic. Array growth, circular-sentinel lists,
hashmap probing, BST links, and heapify live in stdlib/ as Noria.
Heap algorithms are written against the Sequence interface, so a list-backed heap is
still correct, and visibly slower, rather than special-cased.
stdlib/sequence.noria · one API, two bodies
fn sequence_new<T, I>(sample: T) -> Sequence<T, I> impl arr {
let cap: i32 = 4;
let header: __rt_ptr = alloc_bytes(16);
let data: __rt_ptr = alloc_bytes(cap * size_of());
let init0: i32 = store_i32_at(header, 0, 0);
let init1: i32 = store_i32_at(header, 1, cap);
let init2: i32 = store_ptr_at(header, 1, data);
return Sequence<T, I> { handle: header };
}
fn sequence_new<T, I>(sample: T) -> Sequence<T, I> impl list {
let sentinel: __rt_ptr = alloc_bytes(20);
let self0: i32 = store_ptr_at(sentinel, 0, sentinel);
let self1: i32 = store_ptr_at(sentinel, 1, sentinel);
return Sequence<T, I> { handle: sentinel };
}
Case folding on names
Identifiers and keywords are folded to lowercase in the lexer so that an inconsequential
capitalization difference is not a compile error. Count and count
are the same binding; sequence_len and Sequence_Len are the
same function. String literal contents keep their original casing, case still matters
for data, not for names.
The decision is about what a name means. Accidental PascalCase after a camelCase
declaration, or mixing a type-style capital with a later lowercase use, should not be a
distinct symbol.
Same binding, different capitalization
helper mean(values: Sequence<f64>) -> f64 {
let Count: i32 = sequence_len(values);
if count == 0 {
return 0.0;
}
let i: i32;
let Total: f64;
while i < count {
total = total + values[i];
i = i + 1;
}
return total / (count as f64);
}
The lexer emits lowercase lexemes; type checking and codegen never see the original
casing. Tests cover mixed-case source against the normalized token stream.
Headers that describe the function
fn, util, helper, and recfn are the
same declaration as far as the compiler is concerned, they lex as one keyword and
produce one AST node. The spellings exist so a header can state how the function is
meant to be used without forcing a reader through the body.
Best practice in Noria source:
fn for ordinary entry points and public operations;
util for small, reusable operations (square, min, a cast wrapper);
helper for logic that exists to serve one caller (merge, a partition step);
recfn for functions whose interesting structure is recursion.
The toolchain does not check that a recfn actually recurses, the annotation
is a reading convention, not a second type system.
helper vs recfn · intent in the header
helper merge(values: Sequence<i32>, lo: i32, mid: i32, hi: i32) -> void {
let i: i32 = lo;
let j: i32 = mid;
let k: i32 = 0;
let tmp: Sequence<i32> = sequence_new(0);
while i < mid && j < hi {
if values[i] <= values[j] {
sequence_push(tmp, values[i]);
i = i + 1;
} else {
sequence_push(tmp, values[j]);
j = j + 1;
}
k = k + 1;
}
return;
}
recfn sort_range(values: Sequence<i32>, lo: i32, hi: i32) -> void {
if hi - lo <= 1 {
return;
}
let mid: i32 = lo + (hi - lo) / 2;
sort_range(values, lo, mid);
sort_range(values, mid, hi);
merge(values, lo, mid, hi);
return;
}
Return types when they help the reader
A trailing -> Type is optional so headers are not forced to repeat what
the body already determines. Best practice is the opposite of “always omit”: keep an
explicit annotation on functions that other code calls often, so a reader can take the
result type from the header. Omit it on internal helpers whose only job is to return
whatever the body computes.
When omitted, the typechecker unifies every return into one result type,
including across mutually recursive functions, generics, structs, and arrays. Bare
return; infers void.
Variable declarations accept name-first or type-first forms so you can write the order you are thinking
in. value: i32 or i32: value are equivalent. The let keyword can be omitted from some declarations for shorthand.
A bare identifier (no let keyword) on the left of = is always assignment. Default initialization is real initialization, omitting the initializer does not give a garbage value.
Name-first, type-first, defaults, assignment
let x: i32 = 42;
x: i32 = 1;
i32: y = 2;
let n: i32; // 0
let text: str; // ""
let values: Sequence<i32>; // empty, impl arr
x = x + y;
End-to-end examples
Passing programs under examples/basic/ exercise imports, stdlib ADTs, generics,
and codegen, not just parser smoke tests. Several are adapted from common interview problems
to stress the full pipeline.
examples/basic/leetcode_kth_largest.noria
import std::heap::{heappop, heappush};
import std::sequence::{Sequence, sequence_len, sequence_new};
fn kth_largest(nums: [i32], k: i32) -> i32 {
let heap: Sequence<i32, arr> = sequence_new(0);
let i: i32 = 0;
while i < len(nums) {
if sequence_len(heap) < k {
heappush(heap, nums[i]);
} else {
let smallest: i32 = heappop(heap);
if nums[i] > smallest {
heappush(heap, nums[i]);
} else {
heappush(heap, smallest);
}
}
i = i + 1;
}
return heappop(heap);
}
Tests & tooling
The language corpus is a contract, including the programs that must fail:
277 accepted programs and 170 rejected ones,
148 semantic, 22 lexer/parser, 447
language programs in total.
447corpus programs
277accepted
170rejected
16CTest checks
Native behavior
linked exit status and stdout on selected programs
Emitted IR
bounds checks, drops, mangled specializations, dedup
Diagnostics
located semantic and syntax failures
Runtime traps
status 70 plus diagnostic text
Optimizer regressions
ownership and container programs re-run at -O2
Ownership
leak corpus across container tags; Linux leak checks on generated programs
Container models
300 deterministic ops vs Python oracles
Install layout
PATH invocation and stdlib discovery after cmake --install
NORIA_REQUIRE_LLVM_TOOLS=1 ctest --test-dir build
GitHub Actions builds on macOS and Ubuntu. Both jobs run the suite, sanitizers, and
leak checks. Linux also leak-checks generated programs while macOS sanitizers catch
use-after-free.
Parser and type checker errors include source line and column information:
noria: error: 2:10: typecheck: unknown local variable 'missing'
Why I built this
A compiler is where software and hardware meet. Building Noria meant tracing that path myself and
not treating any stage as a black box.
End-to-end ownership.
I learn best when I can follow a whole system from input to output. Noria
sharpened that habit: every bug belongs to a stage, and every stage has tests.
Hard problems, measured fixes.
Generic monomorphization needed a fixed-point specialization pass; ownership needed
clone/borrow/move/drop in codegen without a GC; compiler latency needed profiling
19,600 runs before the LFU cache was worth adding, all of that taught me to validate
assumptions with data, not vibes.
Better instincts in production work.
Debugging across layers: is this a type error, a codegen bug, or a toolchain
issue? These skills transfer directly into shipping production code at scale.