15 Patterns
Compiler-level control flow and concurrency constructs.
Grammar: See grammar.ebnf § PATTERNS
15.1 Categories
| Category | Patterns | Purpose |
|---|---|---|
| Block expressions | { } blocks, try { }, match expr { } | Sequential expressions, error propagation, pattern matching |
function_exp | recurse, parallel, spawn, timeout, cache, with, for, catch | Concurrency, recursion, resources, error recovery |
function_val | int, float, str, byte | Type conversion |
NOTE Data transformation (map, filter, fold, find, collect) and resilience (retry, validate) are stdlib methods, not compiler patterns. See Built-in Functions.
15.2 Block Expressions
15.2.1 Blocks
A block { } is a sequence of ;-terminated statements. The last expression without ; is the block’s value. A block where every expression has ; is a void block.
{
let x = compute();
let y = transform(x);
x + y
}
Function-Level Contracts: pre() / post()
Contracts are declared on the function, between the return type and =:
@divide (a: int, b: int) -> int
pre(b != 0)
post(r -> r * b <= a)
= a div b;
// Multiple conditions
@transfer (from: Account, to: Account, amount: int) -> (Account, Account)
pre(amount > 0 | "amount must be positive")
pre(from.balance >= amount | "insufficient funds")
post((f, t) -> f.balance + t.balance == from.balance + to.balance)
= {
let new_from = Account { balance: from.balance - amount, ..from };
let new_to = Account { balance: to.balance + amount, ..to };
(new_from, new_to)
}
Semantics:
- Evaluate all
pre()conditions in order; panic on failure - Execute function body
- Bind result to each
post()lambda parameter - Evaluate all
post()conditions in order; panic on failure - Return result
Scope constraints:
pre()may only reference function parameters and module-level bindingspost()may reference the result (via lambda parameter) plus everything visible topre()
Type constraints:
pre()condition shall have typeboolpost()shall be a lambda from result type tobool- It is a compile-time error to use
post()on a function returningvoid
Custom messages: Use condition | "message" to provide a custom panic message. Without a message, the compiler embeds the condition’s source text.
15.2.2 try
Error-propagating sequence. Returns early on Err.
try {
let content = read_file(path);
let parsed = parse(content);
Ok(transform(parsed))
}
15.2.3 match
match status {
Pending -> "waiting",
Running(p) -> str(p) + "%",
x if x > 0 -> "positive",
_ -> "other",
}
Arms are comma-separated. Trailing commas are optional. Multi-expression arm bodies use blocks:
match request {
Get(url) -> {
let response = fetch(url: url);
Ok(response)
},
Post(url, body) -> {
let result = send(url: url, body: body);
Ok(result)
},
_ -> Err("unsupported"),
}
Match patterns include: literals, identifiers, wildcards (_), variant patterns, struct patterns, list patterns with rest (..), or-patterns (|), at-patterns (@), and range patterns.
Match shall be exhaustive.
At-Patterns
Grammar: See grammar.ebnf § at_pattern
An at-pattern binds the entire matched value to a name while simultaneously matching against an inner pattern.
at_pattern = identifier "@" match_pattern .
The identifier before @ binds the whole scrutinee value. The pattern after @ shall match for the arm to be selected. Both the identifier and any bindings in the inner pattern are in scope in the arm’s body.
match opt {
whole @ Some(inner) -> use_both(whole: whole, inner: inner),
None -> default_value(),
};
Here whole has type Option<T> (the full value) and inner has type T (the unwrapped payload). The arm matches only when the scrutinee is Some(_).
At-patterns compose with all other match patterns:
// With variant patterns
match status {
s @ Failed(_) -> log_and_report(status: s),
_ -> "ok",
};
// With struct patterns
match point {
p @ { x, y } -> transform(point: p, dx: x, dy: y),
};
// With nested at-patterns
match tree {
node @ Branch(left @ Leaf(_), _) -> prune(tree: node, leaf: left),
other -> other,
};
The type of the at-pattern binding is the type of the scrutinee at that nesting level, not the type of the inner pattern’s bindings.
At-patterns are refutable if their inner pattern is refutable, and irrefutable if their inner pattern is irrefutable.
Exhaustiveness Checking
A match expression is exhaustive if every possible value of the scrutinee type matches at least one pattern arm. The compiler uses pattern matrix decomposition to verify exhaustiveness.
For each type, the compiler knows its constructors:
bool:true,falseOption<T>:Some(_),NoneResult<T, E>:Ok(_),Err(_)- Sum types: all declared variants
- Integers: infinite (requires wildcard)
- Strings: infinite (requires wildcard)
Never variants: Variants containing Never are uninhabited and need not be matched:
type MaybeNever = Value(int) | Impossible(Never);
match maybe {
Value(v) -> v,
// Impossible case can be omitted — it can never occur
}
Matching Never explicitly is permitted but the arm is unreachable.
Non-exhaustiveness is a compile-time error. There is no partial match construct.
| Context | Non-Exhaustive | Rationale |
|---|---|---|
match expression | Error | Must handle all cases to return a value |
let binding destructure | Error | Must match to bind |
| Function clause patterns | Error | All clauses together shall be exhaustive |
Pattern Refutability
An irrefutable pattern always matches. A refutable pattern may fail to match.
Irrefutable patterns:
- Wildcard (
_) - Variable binding (
x) - Struct with all irrefutable fields (
Point { x, y }) - Tuple with all irrefutable elements (
(a, b))
Refutable patterns:
- Literals (
42,"hello") - Variants (
Some(x),None) - Ranges (
0..10) - Lists with length (
[a, b]) - Guards (
x if x > 0)
| Context | Requirement |
|---|---|
match arm | Any pattern (refutable OK) |
let binding | Must be irrefutable |
| Function parameter | Must be irrefutable |
for loop variable | Must be irrefutable |
Guards and Exhaustiveness
Guards are not considered for exhaustiveness checking. The compiler cannot statically verify guard conditions. A match with guards shall include a catch-all pattern:
// ERROR: guards require catch-all
match n {
x if x > 0 -> "positive",
x if x < 0 -> "negative",
// Error: patterns not exhaustive due to guards
}
// OK: catch-all ensures exhaustiveness
match n {
x if x > 0 -> "positive",
x if x < 0 -> "negative",
_ -> "zero",
}
Or-Pattern Exhaustiveness
Or-patterns contribute their combined coverage:
type Light = Red | Yellow | Green;
// Exhaustive via or-pattern
match light {
Red | Yellow -> "stop",
Green -> "go",
}
Bindings in or-patterns shall appear in all alternatives with the same type.
At-Pattern Exhaustiveness
At-patterns contribute the same coverage as their inner pattern:
match opt {
whole @ Some(x) -> use_both(whole: whole, inner: x),
None -> default_value(),
}
List Pattern Exhaustiveness
List patterns match by length:
| Pattern | Matches |
|---|---|
[] | Empty list only |
[x] | Exactly one element |
[x, y] | Exactly two elements |
[x, ..rest] | One or more elements |
[..rest] | Any list (including empty) |
To be exhaustive, patterns shall cover all lengths.
Range Pattern Exhaustiveness
Integer ranges cannot be exhaustive without a wildcard (infinite domain). The compiler warns about overlapping ranges.
Unreachable Patterns
The compiler warns about patterns that can never match due to earlier patterns covering all their cases.
15.3 Recursion (function_exp)
15.3.1 recurse
The recurse pattern evaluates recursive computations with optional memoization and parallelism.
recurse(
condition: bool_expr,
base: expr,
step: expr_with_self,
memo: bool = false,
parallel: bool = false,
)
Evaluation
- Evaluate
condition - If true: return
baseexpression - If false: evaluate
stepexpression (which may containself(...)calls)
@factorial (n: int) -> int = recurse(
condition: n <= 1,
base: 1,
step: n * self(n - 1),
);
Self Keyword
self(...) within step represents a recursive invocation:
@fibonacci (n: int) -> int = recurse(
condition: n <= 1,
base: n,
step: self(n - 1) + self(n - 2),
);
Arguments to self(...) shall match the enclosing function’s parameter arity.
Self Scoping
Within a recurse expression:
self(without parentheses) — trait method receiver (if applicable)self(...)(with arguments) — recursive call
These coexist when recurse appears in a trait method:
impl TreeOps: Tree {
@depth (self) -> int = recurse(
condition: self.is_leaf(), // Receiver
base: 1,
step: 1 + max(left: self(self.left()), right: self(self.right())), // Recursive calls
);
}
It is a compile-time error to use self(...) outside of a recurse step expression.
Memoization
With memo: true, results are cached for the duration of the top-level call:
@fib (n: int) -> int = recurse(
condition: n <= 1,
base: n,
step: self(n - 1) + self(n - 2),
memo: true, // O(n) instead of O(2^n)
);
Memo requirements:
- All parameters shall be
Hashable + Eq - Return type shall be
Clone
The cache is created at top-level entry, shared across recursive calls, and discarded when the top-level call returns.
Parallel Recursion
With parallel: true, independent self(...) calls execute concurrently:
@parallel_fib (n: int) -> int uses Suspend = recurse(
condition: n <= 1,
base: n,
step: self(n - 1) + self(n - 2),
parallel: true,
);
Parallel requirements:
- Requires
uses Suspendcapability - Captured values shall be
Sendable - Return type shall be
Sendable
When memo: true and parallel: true are combined, the memo cache is thread-safe. If multiple tasks request the same key simultaneously, one computes while others wait.
Tail Call Optimization
When self(...) is in tail position, the compiler optimizes to a loop with O(1) stack space:
@sum_to (n: int, acc: int = 0) -> int = recurse(
condition: n == 0,
base: acc,
step: self(n - 1, acc + n), // Tail position: compiled to loop
);
Stack Limits
Non-tail recursive calls are limited to a depth of 1000. Exceeding this limit causes a panic. Tail-optimized recursion bypasses this limit.
15.4 Concurrency
Concurrency patterns create tasks. See Concurrency Model for task definitions, async context semantics, and capture rules.
15.4.1 parallel
Execute tasks, wait for all to settle. Creates one task per list element.
parallel(
tasks: [() -> T uses Suspend],
max_concurrent: Option<int> = None,
timeout: Option<Duration> = None,
) -> [Result<T, E>]
Returns [Result<T, E>]. Never fails; errors captured in results.
Execution Order Guarantees
| Aspect | Guarantee |
|---|---|
| Start order | Tasks start in list order |
| Completion order | Any order (concurrent execution) |
| Result order | Same as task list order |
let results = parallel(tasks: [slow, fast, medium]);
// results[0] = result of slow (first task)
// results[1] = result of fast (second task)
// results[2] = result of medium (third task)
// Even if fast completed first
Concurrency Limits
When max_concurrent is Some(n):
- At most
ntasks run simultaneously - Tasks are queued in list order
- When one completes, the next queued task starts
When max_concurrent is None (default), all tasks may run simultaneously.
Timeout Behavior
When timeout expires:
- Incomplete tasks are marked for cancellation
- Tasks reach cancellation checkpoints and terminate
- Cancelled tasks return
Err(CancellationError { reason: Timeout, task_id: n }) - Completed results are preserved
Tasks can cooperatively check for cancellation using is_cancelled().
Resource Exhaustion
If the runtime cannot allocate resources for a task:
- The task returns
Err(CancellationError { reason: ResourceExhausted, task_id: n }) - Other tasks continue executing
- The pattern does NOT panic
Error Handling
Errors do not stop other tasks. All tasks run to completion (equivalent to CollectAll behavior).
For early termination on error, use nursery with on_error: FailFast.
Empty Task List
parallel(tasks: []) returns [] immediately.
See nursery for cancellation semantics and CancellationError type.
15.4.2 spawn
Fire-and-forget task execution. Creates one task per list element.
spawn(
tasks: [() -> T uses Suspend],
max_concurrent: Option<int> = None,
)
Returns void immediately. Task results and errors are discarded.
Fire-and-Forget Semantics
Tasks start and the pattern returns without waiting:
spawn(tasks: [send_email(u) for u in users]);
// Returns void immediately
// Emails sent in background
Errors in spawned tasks are silently discarded. To log errors, handle them within the task:
spawn(tasks: [
() -> match risky_operation() {
Ok(_) -> log(msg: "success"),
Err(e) -> log(msg: `failed: {e}`),
},
])
Task Lifetime
Spawned tasks:
- Run independently of the spawning scope
- May outlive the spawning function (true fire-and-forget)
- Complete naturally, are cancelled on program exit, or terminate on panic
NOTE spawn is the ONLY concurrency pattern that allows tasks to escape their spawning scope. Unlike parallel and nursery, which guarantee all tasks complete before the pattern returns, spawn tasks are managed by the runtime. For structured concurrency with guaranteed completion, use nursery.
@setup () -> void uses Suspend = {
spawn(tasks: [background_monitor()]);
// Function returns, but monitor continues
}
Concurrency Control
When max_concurrent is Some(n), at most n tasks run simultaneously. When None (default), all tasks may start simultaneously.
Resource Exhaustion
If the runtime cannot allocate resources for a task:
- The task is dropped
- No error is surfaced (fire-and-forget semantics)
- Other tasks continue
15.4.3 timeout
Bounded execution time for an operation.
timeout(
op: expression,
after: Duration,
) -> Result<T, CancellationError>
Basic Behavior
- Start executing
op - If
opcompletes beforeafter: returnOk(result) - If
afterelapses first: cancelop, returnErr(CancellationError { reason: Timeout, task_id: 0 })
let result = timeout(op: fetch(url), after: 5s);
// result: Result<Response, CancellationError>
Cancellation
When timeout expires, the operation is cooperatively cancelled using the same cancellation model as nursery:
- Operation is marked for cancellation
- At the next cancellation checkpoint, operation terminates
- Destructors run during unwinding
Err(CancellationError { reason: Timeout, task_id: 0 })is returned
Cancellation checkpoints:
- Suspending function calls (functions with
uses Suspend) - Loop iterations
- Pattern entry (
run,try,match, etc.)
CPU-bound operations without checkpoints cannot be cancelled until they reach one.
Nested Timeout
Inner timeouts can be shorter than outer:
timeout(
op: {
let a = timeout(op: step1(), after: 2s)?;
let b = timeout(op: step2(), after: 2s)?;
(a, b)
},
after: 5s, // Overall timeout
)
15.4.4 nursery
Structured concurrency with guaranteed task completion. Creates tasks via n.spawn().
nursery(
body: n -> for item in items do n.spawn(task: () -> process(item)),
on_error: CollectAll,
timeout: 30s,
)
| Parameter | Type | Description |
|---|---|---|
body | Nursery -> T | Lambda that spawns tasks |
on_error | NurseryErrorMode | Error handling mode |
timeout | Duration | Maximum time (optional) |
Returns [Result<T, E>]. All spawned tasks complete before nursery exits.
The Nursery type provides a single method:
type Nursery = {
@spawn<T> (self, task: () -> T uses Suspend) -> void;
}
Error modes:
type NurseryErrorMode = CancelRemaining | CollectAll | FailFast;
| Mode | Behavior |
|---|---|
CancelRemaining | On first error, cancel pending tasks; running tasks continue |
CollectAll | Wait for all tasks regardless of errors (no cancellation) |
FailFast | On first error, cancel all tasks immediately |
Guarantees:
- No orphan tasks — all spawned tasks complete or cancel
- Error propagation — task failures captured in results
- Scoped concurrency — tasks cannot escape nursery scope
Cancellation Model
Ori uses cooperative cancellation. A cancelled task:
- Is marked for cancellation
- Continues executing until it reaches a cancellation checkpoint
- At the checkpoint, terminates with
CancellationError - Runs cleanup/destructors during termination
Cancellation checkpoints:
- Suspension points (async calls, channel operations)
- Loop iterations (start of each
fororloopiteration) - Pattern entry (
run,try,match,parallel,nursery)
Cancellation Types
type CancellationError = {
reason: CancellationReason,
task_id: int,
}
type CancellationReason =
| Timeout
| SiblingFailed
| NurseryExited
| ExplicitCancel
| ResourceExhausted;
Cancellation API
The is_cancelled() built-in function returns bool, available in async contexts:
@long_task () -> Result<Data, Error> uses Suspend = {
for item in items do {
if is_cancelled() then break Err(CancellationError { ... });
process(item)
};
Ok(result)
}
The for loop automatically checks cancellation at each iteration when inside an async context.
Cleanup Guarantees
When a task is cancelled:
- Stack unwinding occurs from the cancellation checkpoint
- Destructors run for all values in scope
- Cleanup is guaranteed to complete before task terminates
15.5 Resource Management (function_exp)
15.5.1 cache
Memoization with TTL-based expiration. Requires Cache capability.
cache(
key: expression,
op: expression,
ttl: Duration,
)
Semantics
- Compute
keyexpression - Check cache for existing unexpired entry
- If hit: return cached value (clone)
- If miss: evaluate
op, store result, return it
@fetch_user (id: int) -> User uses Cache =
cache(
key: `user-{id}`,
op: db.query(id: id),
ttl: 5m,
);
The cache pattern returns the same type as op.
Key Requirements
Keys shall implement Hashable and Eq:
cache(key: "string-key", op: ..., ttl: 1m); // OK: str is Hashable + Eq
cache(key: 42, op: ..., ttl: 1m); // OK: int is Hashable + Eq
cache(key: (user_id, "profile"), op: ..., ttl: 1m); // OK: tuple of hashables
Value Requirements
Cached values shall implement Clone. The cache returns a clone of the stored value.
TTL Behavior
| TTL | Behavior |
|---|---|
| Positive | Entry expires after TTL from creation |
| Zero | No caching (always recompute) |
| Negative | Compile error (E0992) |
Concurrent Access
When multiple tasks request the same key simultaneously (stampede prevention):
- First request computes the value
- Other requests wait for computation
- All receive the same result
If op fails during stampede, waiting requests also receive the error. Failed results are NOT cached.
Error Handling
If op returns Err or panics, the result is NOT cached. To cache error results, wrap in a non-error type:
cache(
key: url,
op: match fetch(url) { r -> r }, // Cache the Result itself
ttl: 5m,
)
Invalidation
Time-based expiration is automatic. Manual invalidation uses Cache capability methods:
@invalidate_user (id: int) -> void uses Cache =
Cache.invalidate(key: `user-{id}`);
@clear_all_cache () -> void uses Cache =
Cache.clear();
Cache vs Memoization
The cache pattern and recurse(..., memo: true) serve different purposes:
| Aspect | cache(...) | recurse(..., memo: true) |
|---|---|---|
| Persistence | TTL-based, may persist across calls | Call-duration only |
| Capability | Requires Cache | Pure, no capability |
| Scope | Shared across function calls | Private to single recurse |
| Use case | API responses, config, expensive I/O | Pure recursive algorithms |
Error Codes
| Code | Description |
|---|---|
| E0990 | Cache key must be Hashable |
| E0991 | cache requires Cache capability |
| E0992 | TTL must be non-negative |
15.5.2 with
Resource management with guaranteed cleanup.
with(
acquire: open_file(path),
action: f -> read_all(f),
release: f -> close(f),
)
NOTE The property is named action: because use is a reserved keyword.
Semantics
- Evaluate
acquire:to obtain resource - If
acquirefails (returnsError panics), stop—no cleanup needed - If
acquiresucceeds, bind result toaction:parameter - Evaluate
action:expression - Always evaluate
release:with the resource, regardless of howaction:completed
The pattern returns the result of action:.
Release Guarantee
If acquire: succeeds, release: runs under all exit conditions:
| Exit Condition | Release Runs |
|---|---|
| Normal completion | Yes |
| Panic during action | Yes |
Error propagation (?) | Yes |
break in action | Yes |
continue in action | Yes |
with(
acquire: open_file(path),
action: f -> {
if bad_condition then panic("abort");
if err_condition then Err("failed")?;
read_all(f)
},
release: f -> close(f), // Always called
)
Type Constraints
The release: expression shall return void:
// OK: release returns void
with(acquire: lock(), action: l -> work(), release: l -> l.unlock())
// Error E0861: release must return void
with(acquire: lock(), action: l -> work(), release: l -> l.count())
Result Types
When acquire: returns Result<R, E>:
@open_file (path: str) -> Result<File, IoError>;
// Using `?` for fallible acquire:
@read_config (path: str) -> Result<Config, IoError> =
with(
acquire: open_file(path)?, // Propagates Err, no cleanup needed
action: f -> parse(f.read_all()),
release: f -> f.close(),
);
When action: may fail:
@process_file (path: str) -> Result<Data, Error> uses FileSystem =
with(
acquire: open_file(path)?,
action: f -> {
let content = f.read_all()?; // May propagate Err
parse(content)? // May propagate Err
},
release: f -> f.close(), // Still runs on any Err
);
Double Fault Abort
If release: panics while already unwinding (e.g., after action: panicked), the program aborts immediately:
- The
@panichandler is NOT called - Both panic messages are shown
- Exit code is non-zero
This prevents cascading failures during cleanup.
Suspending Context
In a suspending context (uses Suspend), both action: and release: may suspend:
@with_connection (url: str) -> Data uses Suspend =
with(
acquire: connect(url),
action: conn -> fetch_data(conn), // May suspend
release: conn -> conn.close(), // May suspend
);
Error Codes
| Code | Description |
|---|---|
| E0860 | with pattern missing required parameter (acquire:, action:, or release:) |
| E0861 | release must return void |
15.6 Error Recovery (function_exp)
15.6.1 catch
Captures panics and converts them to Result<T, str>.
catch(expr: may_panic());
If the expression evaluates successfully, returns Ok(value). If the expression panics, returns Err(message) where message is the panic message string.
See Errors and Panics § Catching Panics.
15.7 for Pattern
for(over: items, match: Some(x) -> x, default: 0);
for(over: items, map: parse, match: Ok(v) -> v, default: fallback);
Returns first match or default.
15.8 For Loop Desugaring
The for loop desugars to use the Iterable and Iterator traits:
// This:
for x in items do
process(x: x);
// Desugars to:
{
let iter = items.iter();
loop {
match iter.next() {
(Some(x), next_iter) -> {
process(x: x);
iter = next_iter;
continue
},
(None, _) -> break,
}
}
}
15.9 For-Yield Comprehensions
The for...yield expression builds collections from iteration.
15.9.1 Basic Syntax
for x in items yield expression;
Desugars to:
items.iter().map(transform: x -> x * 2).collect();
15.9.2 Type Inference
The result type is inferred from context:
let numbers: [int] = for x in items yield x.id; // [int]
let set: Set<str> = for x in items yield x.name; // Set<str>
Without context, defaults to list:
let result = for x in 0..5 yield x * 2; // [int]
15.9.3 Filtering
A single if clause filters elements. Use && for multiple conditions:
for x in items if x > 0 yield x;
for x in items if x > 0 && x < 100 yield x;
Desugars to:
items.iter().filter(predicate: x -> x > 0).map(transform: x -> x).collect();
15.9.4 Nested Comprehensions
Multiple for clauses produce a flat result:
for x in xs for y in ys yield (x, y);
Desugars to:
xs.iter().flat_map(transform: x -> ys.iter().map(transform: y -> (x, y))).collect();
Each clause can have its own filter:
for x in xs if x > 0 for y in ys if y > 0 yield x * y;
15.9.5 Break and Continue
In yield context, break and continue control collection building:
| Statement | Effect |
|---|---|
continue | Skip element, add nothing |
continue value | Add value instead of yield expression |
break | Stop iteration, return results so far |
break value | Add value and stop |
for x in items yield
if skip(x) then continue,
if done(x) then break x,
transform(x),
15.9.6 Map Collection
Maps implement Collect<(K, V)>. Yielding 2-tuples collects into a map:
let by_id: {int: User} = for user in users yield (user.id, user);
If duplicate keys are yielded, later values overwrite earlier ones.
15.9.7 Empty Results
Empty source or all elements filtered produces an empty collection:
for x in [] yield x * 2; // []
for x in [1, 2, 3] if x > 10 yield x; // []
See Types § Iterator Traits for trait definitions.