The Ore Programming Language
Ore is a statically-typed, compiled systems language with algebraic effects and automatic memory management via Perceus reference counting.
Design Principles
- Explicit memory. Three pointer types:
[*]T(heap, RC-managed),[]T(slice view),*T(raw pointer). No hidden allocations. - Two operators, clear roles.
:=binds and rebinds names.<-mutates memory at a location. Never confused. - Everything is
name :: thing. Functions, data types, effects, constants — all defined with::. - Public by default. Items are public unless marked
pvt. - Effects are explicit. Side effects tracked in function signatures via effect rows.
- No null.
?Tfor optional values,!Tfor error unions. - One data keyword.
datareplaces struct and enum — everything is a sum type. - UFCS.
x.f()isf(x). No methods, no traits — just functions. - Operators are intrinsics.
+,-,*, etc. are compiler built-ins. - Compile-time safety. Argus (escape analysis + borrow checking) prevents dangling references at compile time. No runtime checks, no lifetime annotations.
Syntax Overview
Ore uses indentation with optional curly braces (layout rule):
greet :: fn(name: []const u8)
print(name)
greet :: fn(name: []const u8) { print(name) }
A Quick Example
Shape :: data
Circle .{ f64 }
Rect .{ f64, f64 }
area :: fn(s: Shape) -> f64
match s
Shape::Circle.{ r } { 3.14 * r * r }
Shape::Rect.{ w, h } { w * h }
main :: fn() -> !i32
0
Binding and Mutation
// :: is compile-time constant (rodata)
PI :: 3.14
// := binds a name to a value (ref cell)
x := 42
x := x + 1 // rebinds — updates the same cell
// <- mutates memory at a location
buf := alloc(u8, 10)
buf[0] <- 99 // writes to heap memory
// : T = is a typed binding
count : i32 = 0
Memory Model
| Type | What it is | Managed by |
|---|---|---|
[*]T | Heap pointer | Perceus (RC) |
[]T | Slice (view) | Argus (scope) |
*T | Raw pointer | Argus (scope) |
[N; T] | Fixed array | Stack |
Perceus handles when to free heap memory. Argus ensures views don’t outlive their source. Both are compile-time — zero runtime overhead.
Multidimensional fixed arrays are written by nesting the element type: [rows; [cols; T]].
matrix : [2; [3; i32]] = .{
.{ 1, 2, 3 },
.{ 4, 5, 6 }
}
Effects
Console :: effect
fn print(msg: []const u8)
fn read_line() -> []u8
greet :: fn(name: []const u8) -> <Console> ()
Console.print("Hello, ")
Console.print(name)
Effects replace exceptions, IO, and mutable state with typed, composable handlers.
Literals & Primitive Types
Integers
Integer literals default to i32. Underscores can be used as visual separators.
42
0
9999999
1_000_000
Alternate bases:
0xff // hexadecimal (case-insensitive: 0XAB, 0xDeAdBeEf)
0b1010 // binary (0B1100)
0o77 // octal (0O55)
Integer Types
| Type | Size | Range |
|---|---|---|
i8 | 8-bit | signed |
i16 | 16-bit | signed |
i32 | 32-bit | signed |
i64 | 64-bit | signed |
isize | pointer | signed |
u8 | 8-bit | unsigned |
u16 | 16-bit | unsigned |
u32 | 32-bit | unsigned |
u64 | 64-bit | unsigned |
usize | pointer | unsigned |
Floats
Float literals default to f32. Underscores allowed.
3.14159
0.5
100.0
1_000.5
Scientific notation:
1e10 // 10000000000.0
1.5e10 // 15000000000.0
2.5e-3 // 0.0025
3.0e+7 // 30000000.0
Float Types
| Type | Size |
|---|---|
f32 | 32-bit |
f64 | 64-bit |
Strings
Double-quoted. The type is []const u8 — an immutable slice of UTF-8 bytes.
"hello"
"" // empty
"hello world" // spaces
Escape sequences:
| Escape | Meaning |
|---|---|
\n | newline |
\t | tab |
\r | carriage return |
\" | double quote |
\\ | backslash |
\0 | null byte |
\' | single quote |
Triple-quoted strings for raw content:
"""triple quoted"""
Booleans
true
false
The type is bool.
Bytes
Single-quoted character literals produce a u8 value:
'a' // 97
'z'
'0'
' ' // space
Escape sequences work in byte literals too:
'\n' // newline byte
'\t' // tab byte
'\\' // backslash byte
'\'' // single quote byte
'\0' // null byte
Variables & Bindings
Let Bindings
Variables are introduced with let. They are immutable by default.
let x = 5
let name = "Alice"
Optional type annotation:
let x: i64 = 5
Bindings can use any expression:
let x = 5 + 3
let y = x * 2
let result = compute(42)
let s = "hello".to_upper()
Var Bindings
Use var for mutable state. A var binding creates a mutable ref cell:
var count = 0
count = count + 1
count += 1
Optional type annotation:
var total: i64 = 0
Destructuring
Positional products:
let .{ a, b } = get_pair()
let .{ a, .{ b, c } } = get_nested()
let .{ _, b } = get_pair() // wildcard discards first element
Named products and data values:
let Point.{ .x = x, .y = y } = get_point()
let Point.{ .x = px, .y = py } = get_point() // rename fields
let Config.{ .name = name } = get_config()
Constants
Top-level compile-time values with required type annotations:
const MAX_SIZE: i64 = 1024
const PI: f64 = 3.14159
const GREETING: String = "hello"
const YES: bool = true
const BYTE_A: u8 = 'a'
Constants support all literal forms:
const HEX: i64 = 0xFF
const BIN: i64 = 0b1010
const OCT: i64 = 0o77
const SCI: f64 = 1.5e10
const TRIPLE: String = """triple quoted"""
Visibility and attributes:
pvt const SECRET: i64 = 42
#[deprecated]
const OLD_VALUE: i64 = 0
Functions
Basic Functions
Functions are declared with fn. The body is indented on the next line:
fn add(x: i64, y: i64) -> i64
x + y
No return type means the function returns void:
fn greet(name: String)
print(name)
Short functions can use braces for a single-line body:
fn double(x: i64) -> i64 { x * 2 }
fn apply(x: i64) -> i64 { compute(x) }
Generics
Lowercase type parameters are inferred from usage — no explicit <T> declaration needed on functions:
fn identity(x: t) -> t
x
Multiple type parameters work the same way:
fn pair(left: t, right: u) -> .{ .left: t, .right: u }
.{ .left = left, .right = right }
Effects
Functions that perform side effects declare them via effect rows in the return type:
fn read_file(path: String) -> <io> String
// ...
fn complex(x: i64) -> <io, state> String
// ...
Polymorphic effects use a tail variable:
fn map(xs: List<a>, f: fn(a) -> e b) -> e List<b>
// ...
See Effects & Handlers for the full effect system.
Function Type Parameters
Functions can accept other functions as parameters using fn(Args) -> Ret type syntax:
fn apply(f: fn(i64) -> i64, x: i64) -> i64
f(x)
Visibility and Attributes
Functions are public by default. Use pvt for private:
pvt fn helper() -> i64
42
Attributes precede the function:
#[inline]
fn fast(x: i64) -> i64 { x * 2 }
Everything Combined
#[some_attr]
pvt fn kitchen_sink(x: t, y: u) -> <io, state> Result<t, u> where t: Display + Clone, u: Default
// ...
Lambdas
Anonymous functions use pipe syntax:
let f = || x + 1
let add = |x, y| x + y
let constant = || 42
Lambdas are commonly used with higher-order functions:
items.map(|x| x * 2)
items.filter(|x| x > 0)
items.fold(0, |acc, x| acc + x)
Closure Capture
Closures capture variables from their enclosing scope by copy. The closure gets its own copy of the value at the point of creation:
var x = 10
let f = || x + 1 // f captures a copy of x (10)
x = 99 // x is now 99, but f's copy is still 10
f() // returns 11, not 100
This means closures are self-contained — they don’t hold references to the original variable. Mutating the variable after creating the closure has no effect on the closure’s captured values, and vice versa.
Handler operations are different. Handler ops capture var bindings by reference, because they need live access to shared mutable state:
var cell = 0
with handler<Counter>
ctl get()
resume(cell) // reads the live value of cell
ctl set(x)
cell = x // mutates the original cell
resume(())
This is safe because handlers are scoped constructs — they always run to end of scope and cannot escape. The compiler’s scope coloring analysis verifies this.
| Construct | Capture mode | Can escape? |
|---|---|---|
Closures (|| ...) | Copy (value) | Yes — safe, owns its data |
Handler ops (ctl ...) | By reference | No — scoped to with block |
Arity-Based Dispatch
Multiple functions can share a name if they have different parameter counts:
fn add(a: i32, b: i32) -> i32 { a + b }
fn add(a: i32, b: i32, c: i32) -> i32 { a + b + c }
add(1, 2) // calls add/2
add(1, 2, 3) // calls add/3
The compiler resolves which variant to call by counting arguments. When passed as a value, the expected type disambiguates. See Arity & Config Structs for the full details.
Calling Functions
Standard call syntax:
add(5, 3)
f(g(x))
f(x + y, y * 2)
Method calls on values:
x.len()
x.concat(y)
x.trim().len() // chaining
Path-qualified calls:
Foo::new()
std::io::File::open("test.txt")
Arity-Based Dispatch
Ore supports multiple functions with the same name but different parameter counts (arities). The compiler resolves which function to call based on the number of arguments at the call site.
Basic Arity Dispatch
fn add(a: i32, b: i32) -> i32
a + b
fn add(a: i32, b: i32, c: i32) -> i32
a + b + c
fn main() -> !i32
add(1, 2) // calls add/2 → 3
add(1, 2, 3) // calls add/3 → 6
add(1, 2) + add(1, 2, 3) // 9
Each arity variant is a completely separate function with its own DefId, type signature, and compiled C name. add/2 compiles to ore_add_2, add/3 compiles to ore_add_3.
Rules
- Same name + same arity = compile error (duplicate definition)
- Same name + different arity = allowed (arity dispatch)
- Non-function definitions (structs, consts, variants) cannot share names
Higher-Order Functions
When passing a function as a value, the compiler infers which arity variant to use from the expected type:
fn apply(f: fn(i32, i32) -> i32, x: i32, y: i32) -> i32
f(x, y)
fn add(a: i32, b: i32) -> i32 { a + b }
fn add(a: i32, b: i32, c: i32) -> i32 { a + b + c }
fn main() -> !i32
apply(add, 20, 22) // apply expects fn(i32, i32) -> i32
// only add/2 matches → resolved
If there is only one function with a given name, no disambiguation is needed regardless of context:
fn double(x: i32) -> i32 { x * 2 }
items.map(double) // only one `double` → no ambiguity
If the compiler cannot determine which arity to use (rare — requires a fully generic parameter with no type constraints), it emits an error:
error: ambiguous function reference: multiple arities available, use in a call to disambiguate
Config Data (Alternative to Optional Parameters)
Ore uses fixed arity. When a function needs several related options, group them into a single data value instead of adding optional parameters.
ServerConfig :: data .{ .port: i32, .host: []const u8, .debug: i32 }
fn start_server(config: ServerConfig) -> i32
config.port
Aggregate Arguments
For nominal config data, construct the argument explicitly with a headed aggregate:
start_server(ServerConfig.{ .port = 3000, .host = "localhost", .debug = 0 })
If the parameter itself is structural, the expected type can drive an unheaded aggregate:
fn start_server(config: .{ .port: i32, .debug: i32 }) -> i32
config.port
start_server(.{ .port = 9000, .debug = 1 })
Name Mangling
All non-extern functions include arity in their C name: ore_{name}_{arity}. This ensures distinct C symbols even for same-name functions:
| Ore | C |
|---|---|
fn add(a, b) | ore_add_2 |
fn add(a, b, c) | ore_add_3 |
fn main() | ore_main_0 |
extern fn malloc(n) | malloc (unchanged) |
Extern functions keep their original C names for FFI compatibility.
Operators
Arithmetic
| Operator | Meaning | Example |
|---|---|---|
+ | addition | x + y |
- | subtraction | x - y |
* | multiplication | x * y |
/ | division | x / y |
% | modulo | x % y |
** | exponentiation | x ** y |
Comparison
| Operator | Meaning | Example |
|---|---|---|
== | equal | x == y |
!= | not equal | x != y |
< | less than | x < y |
<= | less than or equal | x <= y |
> | greater than | x > y |
>= | greater than or equal | x >= y |
Logical
| Operator | Meaning | Example |
|---|---|---|
and | logical AND (short-circuit) | x and y |
or | logical OR (short-circuit) | x or y |
not | logical NOT | not x |
and and or are keywords, not symbols. They short-circuit: the right operand is only evaluated if needed.
Bitwise
| Operator | Meaning | Example |
|---|---|---|
& | bitwise AND | x & y |
| | bitwise OR | x | y |
^ | bitwise XOR | x ^ y |
~ | bitwise NOT | ~x |
<< | left shift | x << y |
>> | right shift | x >> y |
Other
| Operator | Meaning | Example |
|---|---|---|
<> | append | xs <> ys |
Unary
| Operator | Meaning | Example |
|---|---|---|
- | negation | -x |
not | logical NOT | not x |
~ | bitwise NOT | ~x |
& | address-of (linear) | &x |
* | dereference (linear) | *p |
Precedence
From highest to lowest binding power:
| Precedence | Operators | Associativity |
|---|---|---|
| 25 | . () ? | left (postfix) |
| 23 | - not ~ & * (unary) | prefix |
| 21 | ** | right |
| 19 | * / % | left |
| 17 | + - <> | left |
| 15 | << >> | left |
| 13 | & | left |
| 11 | ^ | left |
| 9 | | | left |
| 7 | == != < <= > >= | left |
| 5 | and | left |
| 3 | or | left |
| 1 | = += -= *= /= %= | right |
Parentheses override precedence:
x + y * z // y * z first
(x + y) * z // x + y first
-a + b ** 2 // (-a) + (b ** 2)
Compound Assignment
Available to var only
| Operator | Desugars to |
|---|---|
x += y | x = x + y |
x -= y | x = x - y |
x *= y | x = x * y |
x /= y | x = x / y |
x %= y | x = x % y |
Try Operator
The postfix ? operator propagates errors from error union values:
fn read_config(path: String) -> !Config
let contents = read_file(path)? // returns error early if read fails
let config = parse(contents)?
config
Types
Data Types
All user-defined types use the data keyword. data is a sum type — a single-variant data acts like a struct, a multi-variant data acts like an enum. The compiler optimizes single-variant types (no tag needed).
Single-Variant (Struct)
Single-variant data is constructed with a headed aggregate literal:
Point :: data .{ .x: i32, .y: i32 }
p := Point.{ .x = 1, .y = 2 }
p.x // 1
p.y // 2
Multi-Variant (Enum)
Indented variants:
Shape :: data
Square .{ f32 }
Circle .{ f32 }
area :: fn(s: Shape) -> f32
match s
Shape::Square.{ side } => side * side
Shape::Circle.{ r } => 3.14 * r * r
Simple Enums (No Fields)
Pipe syntax for variants without fields:
Color :: data
Red
Green
Blue
Or indented:
Color :: data
Red
Green
Blue
Variant Field Shapes
Each variant independently chooses its field shape:
// No fields
Unit :: data
// Positional product fields
Pair :: data .{ i32, i32 }
// Named fields
Person :: data .{ .name: []const u8, .age: i32 }
// Mixed per variant
Expr :: data
Lit .{ i32 }
Add .{ .lhs: i32, .rhs: i32 }
Nil
Explicit Variant Name
For single-variant types, you can write the variant name explicitly. If the variant name matches the type name, it’s treated as a single-variant type:
Person :: data
Person .{ .name: []const u8, .age: i32 }
// Equivalent to:
Person :: data .{ .name: []const u8, .age: i32 }
UFCS (Uniform Function Call Syntax)
Any free function whose first parameter matches a type can be called with dot syntax:
Person :: data .{ .name: i32, .age: i32 }
greet :: fn(p: Person) -> i32
p.age + 1
p := Person.{ .name = 10, .age = 30 }
p.greet() // calls greet(p)
This works on any type, including primitives:
double :: fn(x: i32) -> i32
x * 2
5.double() // 10
Type Aliases
type Id = i32
type Callback = fn(i32) -> i32
Aliases are transparent — the compiler treats them as the underlying type.
Error Sets
data FileError = error
FileNotFound
ReadFailed
Error sets define named error codes used with error unions (!T).
Error Unions
A function that can fail returns !T (error union):
fn read_file(path: []const u8) -> !i32
let fd = open(path.ptr, 0)
return error.FileNotFound if fd < 0
// ...
size
The caller uses try to propagate errors:
let size = try read_file("test.txt")
Or catch to handle them:
let size = read_file("test.txt") catch e
0 // default on error
Optionals
?T is an optional type — either a value of type T or nil:
fn find(items: []i32, target: i32) -> ?i32
// ...
return nil // not found
Use orelse to provide a default:
let x = find(items, 42) orelse -1
Primitive Types
See Literals & Primitive Types for the full list of built-in types:
i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, bool, isize, usize.
Pointer Types
See Pointers for *T, *const T, [*]T, and [*]const T.
Slice Types
See Slices & Strings for []T and []const T.
Products
Ore has one structural product surface with two shapes: positional products and named products. They are distinct types and do not unify with each other.
Positional Products
Positional products use .{ ... } with numeric field access.
t := .{ 10, 20, true }
t.0
t.1
t.2
Type annotation: .{ i32, i32, bool }.
Parentheses are only for grouping. Unit remains ().
(42) // grouping
.{ 42 } // 1-element positional product
() // unit
Named Structural Products
Named products are structural values with labeled fields.
point := .{ .x = 10, .y = 20 }
point.x
point.y
Their type syntax uses the same field set with : instead of =:
.{ .x: i32, .y: i32 }
Type Annotation
alloc :: fn(n: i32) -> .{ .ptr: [*]u8, .len: usize }
.{ .ptr = ore_alloc(n), .len = n as usize }
Field Access
Named products are accessed only by field name.
buf := alloc(100)
buf.ptr
buf.len
As Function Parameters
area :: fn(rect: .{ .w: i32, .h: i32 }) -> i32
rect.w * rect.h
area(.{ .w = 10, .h = 5 })
Key Rules
Field names are part of identity
.{ .x: i32, .y: i32 } and .{ .a: i32, .b: i32 } are different types.
Named and positional products are distinct
.{ .x: i32, .y: i32 } does not unify with .{ i32, i32 }.
Field access matches the product family
Positional products use .0, .1, and so on. Named products use .field.
.{ ... } is structural by default
Without an explicit head, .{ ... } builds a structural product. Use a head when you want a nominal data value or an array.
Point.{ .x = 10, .y = 20 }
[3; i32].{ 1, 2, 3 }
Comparison with data
| Feature | Positional Product | Named Product | data |
|---|---|---|---|
| Identity | Structural by position | Structural by field names | Nominal |
| Access | .0, .1 | .name | .name |
| Type syntax | .{ A, B } | .{ .x: A, .y: B } | Name |
| Literal syntax | .{ 1, 2 } | .{ .x = 1, .y = 2 } | Point.{ .x = 1, .y = 2 } |
Use named structural products for lightweight labeled data. Use data when the type’s name should matter across the program.
Library Convention
Functions that allocate often return a named product containing the owner and its size:
read_file :: fn(path: []const u8) -> .{ .data: [*]u8, .size: i32 }
file := read_file("input.txt")
view := file.data[0..file.size as usize]
Control Flow
If
Inline Conditional
if x > 0 then "positive" else "non-positive"
if condition then expr else expr — both branches required for inline form.
Multi-Arm Conditional
if followed by a block introduces guard-style arms with =>:
fn classify(x: i64) -> String
if
x > 0 => "positive"
x < 0 => "negative"
else => "zero"
Each arm is condition => body. Use else => for the default case.
Arms can have multi-statement bodies:
fn process(x: i64) -> i64
if
x > 0 =>
let doubled = x * 2
doubled + 1
else => 0
Postfix Conditional
expr if condition — executes expr only if condition is true:
return error.FileNotFound if fd < 0
break if i >= 10
This is the primary guard/early-exit pattern.
Match
Pattern matching with => arms:
fn describe(s: Shape) -> i32
match s
Shape::Square.{ side } => side * side
Shape::Circle.{ r } => r * r
Literal Patterns
match x
0 => "zero"
1 => "one"
_ => "other"
Data Type Patterns
match result
Shape::Square.{ s } => s
Shape::Circle.{ r } => r
Or Patterns
Multiple patterns separated by |:
match x
1 | 2 | 3 => "small"
4 | 5 | 6 => "medium"
_ => "large"
Guards
Add conditions with if after the pattern:
match opt
Some.{ x } if x > 0 => "positive"
Some.{ x } if x < 0 => "negative"
Some.{ _ } => "zero"
_ => "none"
Multi-Statement Arms
match x
0 =>
let result = compute()
result + 1
_ => x
For
for is the only loop construct. All forms support break to exit and return to exit the enclosing function.
Infinite Loop
Bare for loops forever. Use break or return to exit.
for
event := poll()
break if event == quit
handle(event)
Condition (While-Style)
for cond loops while the condition is true.
mut i := 10
for i > 0
i <- i - 1
// i is 0 here
mut sum := 0
mut i := 0
for i < 10
sum <- sum + i
i <- i + 1
// sum is 45
Numeric Range
for i in start..end iterates from start up to (but not including) end. The loop variable i is bound to the current value.
mut sum := 0
for i in 0..10
sum <- sum + i
// sum is 45
Collection Iteration
for x in coll iterates over slices, buffers, and fixed arrays. The loop variable x is bound to each element.
for c in text
process(c)
Collection with Index
for x, i in coll — element first, index second. x is the element type, i is usize.
for c, i in text
if is_lower(c) then
buf[i] <- rotate(c, shift)
else
buf[i] <- c
Nested fixed arrays compose naturally with loops and indexing.
matrix : [2; [3; i32]] = .{
.{ 1, 2, 3 },
.{ 4, 5, 6 }
}
sum : i32 = 0
for row in matrix
for cell in row
sum <- sum + cell
matrix[1][2] <- sum
Collection with Filter
for x in coll where cond — only visits elements where the condition is true. The condition can reference the loop variable.
for c in text where is_lower(c)
idx := (c as i32) - 97
counts[idx] <- counts[idx] + 1
Early Return from Loop
return exits the enclosing function, not just the loop:
find :: fn(buf: []u8, target: u8) -> i32
for x, i in buf
return i as i32 if x == target
-1
Return
return exits the function early with a value:
fn clamp(x: i32, lo: i32, hi: i32) -> i32
return lo if x < lo
return hi if x > hi
x
In error union functions, return error.X wraps the error:
fn read_file(path: []const u8) -> !i32
let fd = open(path.ptr, 0)
return error.FileNotFound if fd < 0
// ...
Effects & Handlers
Ore uses algebraic effects for side effects, resource management, and control flow. The system is modeled after Koka and has three core constructs:
effect— declares an effect and its operationshandler— defines an implementation of an effectwith— installs a handler for a scope
Declaring Effects
An effect declares a set of operations that code can perform:
effect Console
fn print(msg: String)
fn read_line() -> String
Operation Kinds
There are three kinds of effect operations:
| Kind | Meaning | Continuation |
|---|---|---|
fn | Tail-resumptive: always resumes exactly once | No continuation captured (cheap) |
ctl | Control: may resume zero or many times | Captures continuation |
final | Final: never resumes | No continuation (like exceptions) |
effect Async<a>
fn spawn(task: fn() -> a) -> a // always resumes
ctl yield() -> a // may suspend/resume
ctl abort(reason: String) -> Never // may not resume
effect Error
final throw(msg: String) -> Never // never resumes
fn is the cheapest — use it when the handler always returns a value and continues. ctl captures a continuation, which the handler can call to resume. final is an optimization hint: the compiler knows no continuation is needed.
Scoped Effects
A scoped effect creates a fresh scope token each time it is handled, preventing handlers from being used outside their scope:
scoped effect heap<s::S>
ctl new_ref(init: a) -> ref<s, a>
The s::S annotation means s is a scope kind parameter — it’s not a regular type, but a token that ties references to their heap.
Named Effects
A named effect is an effect instance that can be passed as a first-class value. Named effects declare their containing scoped effect with in:
named effect ref<s::S, a::V> in heap<s>
fn get() -> a
fn set(value: a)
This says ref is an instance of an effect that lives inside a heap<s> scope. You can have multiple ref instances within the same heap.
Kind Annotations
Type parameters can have kind annotations using :::
| Kind | Meaning |
|---|---|
::V | Value type (default) |
::S | Scope token |
::E | Effect row |
::H | Heap/handler |
scoped effect heap<s::S> // s is a scope token
named effect ref<s::S, a::V> // s is scope, a is a value type
Marker Effects
Effects with no operations:
effect Pure
Generics, Bounds, and Where Clauses
effect Transform<a: Clone>
fn transform(value: a) -> a
effect Convert<a, b> where a: Clone, b: From<a>
fn convert(value: a) -> b
Visibility and Attributes
pvt effect InternalEffect
fn internal_op() -> i64
#[some_attr]
effect Documented
fn describe() -> String
Everything Combined
#[some_attr]
pvt effect FullEffect<a, b: Default> where a: Clone
fn required(value: a) -> b
ctl fail(msg: String) -> Never
final panic(msg: String) -> Never
Effect Rows
Effects appear in function signatures as effect rows — a list of effects the function may perform. Effect rows appear before the return type:
fn read_file(path: String) -> <io> String
// performs the io effect
fn complex(x: i64) -> <io, state> String
// performs io and state effects
Open Rows (Polymorphic Effects)
A row variable e makes effects polymorphic — the function can perform whatever additional effects the caller has:
fn map(xs: List<a>, f: fn(a) -> e b) -> e List<b>
// ...
Here e is an effect variable — map performs whatever effects f performs. A bare e before the return type is shorthand for an open effect row.
Explicit row syntax with a tail:
fn heap(action: forall<s> fn() -> <heap<s>, div|e> a) -> <div|e> a
// action performs heap<s> + div + whatever e is
The |e separates named effects from the row tail variable.
Forall
The forall quantifier introduces scoped type/effect variables, commonly used with scoped effects:
fn heap(action: forall<s> fn() -> <heap<s>, div|e> a) -> <div|e> a
The forall<s> ensures s is fresh for each call — references created inside can’t escape.
Handlers
A handler expression defines how to handle an effect’s operations.
Basic Handler
fn state_handler(init: i64)
var state = init
handler State
fn get() { state }
fn set(s) { state = s }
Operation parameters are just names — types come from the effect definition.
Named Handler
For named effects, use named handler:
fn with_ref(init: a, action: fn(ref<s, a>) -> e b) -> e b
var state = init
with r <- named handler
fn get() { state }
fn set(x) { state = x }
action(r)
The r binding captures the handler instance as a first-class value.
Return Clause
Process the final value of the handled computation:
handler State
fn get() { state }
fn set(s) { state = s }
return(val) { val }
Initially and Finally
Resource management without special destructor hooks:
fn file_handler(path: String)
let fd = Os::open(path)
handler File
initially { print("opening") }
finally { Os::close(fd) }
fn read(buf, len) { Os::read(fd, buf, len) }
fn write(data) { Os::write(fd, data) }
initially runs when the handler is installed. finally runs when the handled scope exits (whether normally or via a final operation).
Resume
In ctl handlers, resume(value) continues the captured continuation with a value:
fn example() -> i64
with handler Ask
ctl ask() { resume(42) }
Ask::ask() + 1 // ask() resumes with 42, so this returns 43
Not calling resume short-circuits — the handler’s value becomes the result of the entire with block:
fn safe_div(x: i64, y: i64) -> i64
with handler Fail
ctl fail(msg) { -1 } // no resume → with block returns -1
if y == 0 { Fail::fail("division by zero") }
x / y
resume can be called multiple times for effects like non-determinism:
fn example() -> List<i64>
with handler Choose
ctl choose()
let a = resume(true)
let b = resume(false)
a ++ b
let x = if Choose::choose() { 1 } else { 2 }
[x]
fn operations auto-resume (no explicit resume needed). final operations never resume.
With Expressions
with installs a handler for a block of code.
Rest of Scope
with installs a handler that covers the rest of the enclosing scope. It does not return a value — it establishes a scope.
fn example() -> i64
with state(0)
State::set(10)
State::get()
Handler Binding
Use <- to bind a named handler instance:
fn main()
with heap
let r1 = new_ref(41)
let r2 = dynamic_ref(true)
let v2 = if r2.get() { 1 } else { 0 }
println(r1.get() + v2)
Named handlers are bound with:
with r <- named handler
fn get() { state }
fn set(x) { state = x }
Stacking Handlers
Multiple with statements stack flat — each covers the rest of the scope:
fn example() -> i64
with diagnostic_stderr()
with state_handler(0)
State::set(42)
State::get()
Single-Operation Shorthand
For handlers with a single operation, skip the handler keyword — specify the operation kind and name directly:
fn example() -> i64
with ctl fail(msg)
-1
safe_div(10, 0)
fn example2() -> i64
with ctl ask()
resume(42)
Ask::ask() + 1
fn example3() -> i64
with fn get()
42
State::get()
This is equivalent to with handler Effect: op(params) { body } but more concise.
Override
override with re-handles an effect that is already in scope, replacing its handler.
The key difference from regular with: a regular handler is not in scope for its own body. If you call the same effect operation inside the handler, it escapes to an outer handler. override with makes the handler visible to itself, enabling recursion.
Delegating to the Outer Handler
fn wrap_ref(action: fn() -> e a) -> e a
override with fn get() -> a
get() // calls the SAME handler (recursive)
action()
Recursive Handlers
This is the primary use case. If your handler’s logic needs to call its own operation (e.g., open resolving a symlink by calling open again), override is required:
// Regular 'with': the inner open() escapes to a DIFFERENT handler above
with fn open(path)
if is_symlink(path) { open(target) } // looks for outer handler!
// 'override with': the inner open() calls back into THIS handler
override with fn open(path)
if is_symlink(path) { open(target) } // recursive — same handler
Without override, the default behavior prevents accidental infinite loops and allows layering (e.g., a file-logger that calls println, handled by a different console-logger above).
Mask
mask<effect> { body } temporarily hides an effect from the inner scope, so operations in the body skip the nearest handler and go to an outer one:
fn skip_inner(action: fn() -> e a) -> e a
let x = mask<ref<s, a>> { get() } // get() skips the inner ref handler
action()
This is the inverse of override. Where override makes a handler visible to itself, mask makes a handler invisible to the inner scope. This is useful when you need to reach past a local handler to access an outer one:
fn example()
with handler Logger
fn log(msg) { print("[outer] " ++ msg) }
with handler Logger
fn log(msg) { print("[inner] " ++ msg) }
log("hello") // goes to inner handler
mask<Logger> { log("hello") } // skips inner, goes to outer handler
Full Example: Heap
This example shows scoped effects, named effects, handlers, override, and mask working together:
scoped effect heap<s::S>
ctl new_ref(init: a) -> ref<s, a>
named effect ref<s::S, a::V> in heap<s>
fn get() -> a
fn set(value: a)
fn with_ref(init: a, action: fn(ref<s, a>) -> e b) -> e b
var state = init
with r <- named handler
fn get() { state }
fn set(x) { state = x }
action(r)
fn heap(action: forall<s> fn() -> <heap<s>, div|e> a) -> <div|e> a
with ctl new_ref(init: a)
with_ref(init, |v| resume(v))
action()
fn main()
with heap
let r1 = new_ref(41)
let r2 = dynamic_ref(true)
let v2 = if r2.get() { 1 } else { 0 }
println(r1.get() + v2)
Slices
A slice is a view into a contiguous sequence of elements: a pointer plus a length. Slices don’t own memory — they’re lightweight references to data that lives elsewhere (a string literal, an array, a heap allocation).
Slice Types
[]u8 // mutable slice of bytes
[]const u8 // immutable slice of bytes (read-only)
[]i32 // mutable slice of 32-bit integers
[]const i32 // immutable slice of integers
[]T is a mutable slice — you can read and write elements. []const T is an immutable slice — you can only read. A []T coerces to []const T (giving up write permission), but not the reverse.
String Literals
String literals are []const u8 — an immutable slice of UTF-8 bytes:
let greeting = "hello" // type: []const u8
greeting.len // 5
greeting[0] // 104 (ASCII 'h')
There’s no special String type for literals. A string literal is just bytes with a known length.
Built-in Fields
Slices have two built-in fields:
let s = "hello world"
s.len // usize — number of elements
s.ptr // *const u8 — raw pointer to the first element
.len returns the number of elements as usize. .ptr returns a raw pointer to the underlying data — use this for FFI interop.
Indexing
Use [i] to access individual elements:
let s = "hello"
s[0] // 104 ('h')
s[4] // 111 ('o')
s[s.len - 1] // 111 ('o') — last element
Indexing on []const T returns the element value. You cannot assign through a const slice:
let s = "hello"
s[0] = 65 // ERROR: cannot write through const pointer/slice
Subslicing (Range Syntax)
Use [start..end] to create a subslice:
let s = "hello world"
let hello = s[0..5] // []const u8, "hello"
let world = s[6..11] // []const u8, "world"
hello.len // 5
world[0] // 119 ('w')
The subslice shares the underlying data — no copy is made. The new slice is { ptr + start, end - start }.
Passing Slices to Functions
Slices are value types (pointer + length, two machine words). Pass them by value:
fn byte_count(s: []const u8) -> i32
s.len as i32
byte_count("hello") // 5
byte_count("hello world") // 11
Use []const T for read-only parameters. Use []T when the function needs to modify elements.
FFI Interop
For C functions that take a pointer and length separately, extract .ptr and .len:
// write(2) expects a raw pointer and byte count
write(1, s.ptr, s.len)
The .ptr field on []const T returns *const T. On []T it returns *T.
Creating Slices from Pointers
When working with C APIs that return raw pointers, use range syntax to create a slice:
extern fn strlen(s: *const u8) -> usize
// argv[0] is a *u8 (C string, null-terminated)
let name_ptr = argv[0]
let name = name_ptr[0..strlen(name_ptr)] // []const u8
This is the explicit boundary between “raw pointer world” (no length, no safety) and “slice world” (known length, safe indexing).
Coercion Rules
[]Tcoerces to[]const T(safe: giving up write permission)[]const Tdoes NOT coerce to[]T(would allow writing read-only data)*Tdoes NOT implicitly become[]T(must use range syntax with explicit length)
Many-Item Pointers ([*]T)
[*]T is a many-item pointer — a Perceus-managed (reference counted) heap allocation. It’s the ONE managed pointer type. Created by ore_alloc:
fn alloc(count: i32) -> [*]a
ore_alloc(count * @sizeof(a)) as [*]a
Creating Slices from [*]T
Use range syntax to create a slice view of managed memory:
let buf: [*]u8 = alloc(100)
let view: []u8 = buf[0..50] // slice view into buf
The slice borrows from the [*]T — Argus (the escape checker) ensures the slice doesn’t outlive the buffer.
Ownership Model
[*]T → owns memory (Perceus RC, heap allocated)
[]T → views memory (ptr + len, no ownership, 2 words)
*T → raw pointer (no ownership, no length, 1 word)
[*]T is the owner. []T is a view. You can create views from owners, but views cannot escape the owner’s scope.
Library Convention
Functions that allocate often return a named product with the owner and size:
read_file :: fn(path: []const u8) -> !.{ .buf: [*]u8, .size: i32 }
// ...
.{ .buf = buf, .size = size }
file := try read_file("input.txt")
view := file.buf[0..file.size as usize]
The caller creates views as needed. The [*]T is dropped when the last reference dies.
Arrays vs Slices
Array [N]T | Slice []T | |
|---|---|---|
| Size | Fixed, compile-time | Runtime length |
| Memory | Inline (stack) | Fat pointer (ptr + len) |
| Ownership | Owns data | Borrows data |
| Syntax | [i32; 5] | []i32 |
Arrays have a fixed size known at compile time. Slices have a runtime-known length and don’t own the underlying memory.
Summary
| Operation | Example | Result |
|---|---|---|
| Length | s.len | usize |
| Raw pointer | s.ptr | *T or *const T |
| Index | s[i] | Element T |
| Subslice | s[a..b] | []T or []const T |
| String literal | "hello" | []const u8 |
Ore Ownership & Memory Model
Ore’s memory and ownership system is simple, predictable, and orthogonal. Memory and operations are defined across four independent layers. Together, they dictate how names, memory, and mutation behave.
The Four Layers of Ore Memory:
- Binding — How a name is tied to a value (
::vs:=). - Constness — Whether data can be changed (
const). - Representation — Where it lives (Stack, Heap, Rodata).
- Addressability — How you access it (Direct, Slice, Pointer).
1. Bindings: :: vs :=
:: — compile-time constant: Creates a name bound at compile time. Usually stored in .rodata or .data. Immutable and shared across calls.
:= — runtime binding: Binds a name to a value at runtime. The value may reside on stack or heap depending on type. Always rebindable; := does not mutate existing memory.
digits :: [10; u8].{0,1,2,3,4,5,6,7,8,9} // compile-time, shared
x := [3; u8].{10,20,30} // stack-allocated, local
matrix := [2; [3; u8]].{
.{1,2,3},
.{4,5,6}
} // nested fixed array, still stack-allocated
Nested fixed arrays stay in the fixed-array family. [2; [3; T]] is still a
stack or rodata value, depending on whether it is introduced with := or ::.
Note: In Ore, a variable name is a fixed “slot.” Rebinding with
:=changes what’s in the slot; it does not create a new slot with the same name (no shadowing).
2. Memory Representation
| Syntax | Segment | Owner | Lifetime | Notes |
|---|---|---|---|---|
:: [N; T] | .rodata | Binary | Global | Immutable, baked into binary |
:= [N; T] | Stack | Scope | Local | Cleared on function return |
:= alloc(T, n) | Heap | Perceus | Managed | Automatic RC; freed when ref drops |
[]T | Slice/View | Argus | Scope-bound | Points into an allocation; no ownership |
*T | Escapee/Raw | Argus | Scope-confined | Untracked, cannot escape scope |
- Perceus: Automatic Memory Manager / Reference Counting.
- Argus: Compile-time Safety / Escape Checker.
3. Ownership & Lifetime
3.1 Allocation-level ownership
- Stack
[N; T]— scope-owned; freed automatically. - Heap
[*]T/Vec[T]— Perceus-managed; reference-counted. - Rodata
:: [N; T]— immutable; lifetime tied to the binary.
3.2 Views & slices
[]T— mutable view; cannot escape unless backing storage is Rodata or heap.[]const T— immutable view.
Slices do not increment RC. Mutating through a slice affects all aliases of the underlying allocation. A sub-slice (s2 := s1[1..3]) inherits the scope of the original allocation, not the intermediate slice — this prevents “scope laundering” through chains of sub-slices.
3.3 Who can leave the function?
- Owners (
[*]T,Vec[T]) — can escape any scope; returning one moves ownership to the caller. - Views (
[]T,*T) — confined to the scope of backing storage. Argus enforces this at compile time.
4. Operations: := vs <-
4.1 Binding and Rebinding (:=)
:= creates or updates a ref cell. Rebinding an existing name updates the same cell (Koka-style x.set(value)).
When rebinding a heap allocation ([*]T):
- FBIP (Functional But In Place): Reuses memory if RC=1.
- COW (Copy on Write): If RC>1, a copy is made to preserve value semantics.
4.2 Memory mutation (<-)
Mutates addressable locations (elements, fields, dereferenced pointers). Operates directly on memory; never changes ownership or RC.
Mutation is not allowed on:
- Bare bindings:
x <- 20— compile error. Use:=to rebind. []const Tslices:view[0] <- 42— compile error.:: [N; T]values: Rodata is immutable — compile error.
5. Cheat Sheet
| Syntax | Type | Memory | Owner | <- | := | Escapes? |
|---|---|---|---|---|---|---|
x :: [N; T] | Constant | Rodata | Binary | No | No | Yes |
x := [N; T] | Local buffer | Stack | Scope | Yes | Yes | No |
x := [*]T | Tracked ptr | Heap | Perceus | Yes | Yes (FBIP) | Yes |
x := Vec[T] | Growable | Heap | Perceus | Yes | Yes | Yes |
x : []T | Mutable view | View | Argus | Yes | No | If Rodata/Heap |
x : *T | Escapee | Raw | Argus | Yes | No | No |
6. Summary
::is compile-time;:=is runtime binding.:=updates an existing cell (no shadowing).- FBIP/COW ensures value semantics for heap rebindings.
<-mutates memory at a location; RC is irrelevant here.- Argus enforces safety at compile-time; no runtime checks or lifetime annotations required.
Pointers & Linear Types
Modules & Imports
Modules
File-Based Modules
Declare a module that maps to a file:
mod utils
This looks for utils.ore in the same directory.
Inline Modules
Define a module’s contents inline:
mod math
fn add(x: i64, y: i64) -> i64
x + y
Inline modules can contain any items (structs, functions, nested modules, etc.):
pvt mod helpers
struct Helper
fn assist() -> i64 { 42 }
Visibility and Attributes
pvt mod internal
#[cfg(test)]
mod tests
fn test_add() -> bool { true }
Imports
Simple Path
Import a module:
use std::io
Specific Item
use std::io::File
Glob Import
Import everything from a module:
use std::io::*
Aliases
Rename on import:
use std::io as sio
use std::io::File as F
Tree Imports
Import multiple items from the same path:
use std::io::{File, BufReader}
Nested trees:
use std::{io::{File, BufReader}, collections::HashMap}
Aliases inside trees:
use std::io::{File, Write as W}
Special Path Segments
| Segment | Meaning |
|---|---|
super | Parent module |
ignot | Package/crate root |
use super::something
use super::super::deep
use ignot::parser::ast::Item
Private Imports
pvt use std::io::File
Intrinsics
Intrinsics are compiler-builtin operations prefixed with @. They bypass normal name resolution and map directly to low-level operations.
Syntax
@name(args)
Examples:
@add(x, y)
@nop()
@fma(a, b, c)
Purpose
Intrinsics exist for operations that cannot be expressed in Ore itself:
- Arithmetic primitives —
@add<T>,@sub<T>,@mul<T>, etc. (used to implement operator traits) - Memory operations —
@alloc,@free,@load<T>,@store<T>,@copy - System calls —
@write,@read,@open,@close,@exit - Type operations —
@sizeof<T>,@as<To>(casting) - Process control —
@panic,@exit,@arg_count,@arg
Regular Ore code rarely uses intrinsics directly. They are primarily used in the standard library to implement traits and effects.
Keyword Reference
| Keyword | Usage |
|---|---|
and | Logical AND (short-circuit): x and y |
as | Type cast: x as f64 |
break | Exit enclosing for loop: break if i >= 10 |
catch | Inline error handling: expr catch err { fallback } |
const | Compile-time constant: const N: i64 = 42 |
ctl | Control effect operation (captures continuation) |
data | Declare a data type (sum type): Point :: data .{ .x: i32, .y: i32 } |
effect | Declare an algebraic effect |
else | Default arm: else => ..., or inline: if x then a else b |
error | Error set modifier: data E = error ..., or error value: error.NotFound |
false | Boolean literal |
final | Final effect operation (never resumes) |
fn | Declare a function, or a tail-resumptive effect operation |
forall | Universal quantifier for scoped type variables: forall<s> fn() -> ... |
handler | Define an effect handler |
if | Conditional: if x > 0 then ..., guard-style: if { cond => ... }, postfix: break if x |
ignot | Package/crate root in paths |
in | Named effect containment: named effect ref<s, a> in heap<s> |
let | Bind an immutable value to a name |
for | Loop: for, for cond, for i in 0..n, for x in coll, for x, i in coll, for x in coll where f(x) |
mask | Hide an effect from the current scope |
match | Pattern matching: match x { pat => body } |
mod | Declare a module |
named | Named effect modifier: named effect ref<s, a> |
nil | Absence of value (for ?T optional types) |
not | Logical NOT: not x |
or | Logical OR (short-circuit): x or y |
orelse | Optional default: expr orelse fallback |
override | Re-handle an effect in scope |
pvt | Mark an item as private (public by default) |
resume | Resume continuation in ctl handler: resume(value) |
return | Return a value from a function early: return error.X if cond |
scoped | Scoped effect modifier: scoped effect heap<s::S> |
super | Parent module in paths |
then | Inline if branch: if cond then expr else expr |
true | Boolean literal |
try | Error propagation: try expr |
type | Declare a type alias: type Id = i32 |
unsafe | Unsafe block |
use | Import items from a module |
var | Mutable binding: var x = 5 |
with | Install an effect handler |
Reserved Symbols
| Symbol | Usage |
|---|---|
@ | Intrinsic prefix: @add(x, y), @sizeof(T) |
! | Error union type prefix: !T |
? | Optional type prefix: ?T |
=> | Arm separator in match/if: pattern => body |
#[...] | Attribute annotation |
|...| | Lambda parameters: |x| x + 1 |
| | Pipe separator in data variants: data Color = Red | Green |
:: | Path separator or kind annotation: Foo::bar, s::S |
.. | Spread in literals, range in patterns |
-> | Return type separator |
<- | Handler binding: with r <- named handler |
_ | Wildcard pattern |