15 Patterns

Compiler-level control flow and concurrency constructs.

Grammar: See grammar.ebnf § PATTERNS

15.1 Categories

CategoryPatternsPurpose
Block expressions{ } blocks, try { }, match expr { }Sequential expressions, error propagation, pattern matching
function_exprecurse, parallel, spawn, timeout, cache, with, for, catchConcurrency, recursion, resources, error recovery
function_valint, float, str, byteType 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:

  1. Evaluate all pre() conditions in order; panic on failure
  2. Execute function body
  3. Bind result to each post() lambda parameter
  4. Evaluate all post() conditions in order; panic on failure
  5. Return result

Scope constraints:

  • pre() may only reference function parameters and module-level bindings
  • post() may reference the result (via lambda parameter) plus everything visible to pre()

Type constraints:

  • pre() condition shall have type bool
  • post() shall be a lambda from result type to bool
  • It is a compile-time error to use post() on a function returning void

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, false
  • Option<T>: Some(_), None
  • Result<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.

ContextNon-ExhaustiveRationale
match expressionErrorMust handle all cases to return a value
let binding destructureErrorMust match to bind
Function clause patternsErrorAll 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)
ContextRequirement
match armAny pattern (refutable OK)
let bindingMust be irrefutable
Function parameterMust be irrefutable
for loop variableMust 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:

PatternMatches
[]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

  1. Evaluate condition
  2. If true: return base expression
  3. If false: evaluate step expression (which may contain self(...) 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 Suspend capability
  • 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

AspectGuarantee
Start orderTasks start in list order
Completion orderAny order (concurrent execution)
Result orderSame 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 n tasks 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:

  1. Incomplete tasks are marked for cancellation
  2. Tasks reach cancellation checkpoints and terminate
  3. Cancelled tasks return Err(CancellationError { reason: Timeout, task_id: n })
  4. 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

  1. Start executing op
  2. If op completes before after: return Ok(result)
  3. If after elapses first: cancel op, return Err(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:

  1. Operation is marked for cancellation
  2. At the next cancellation checkpoint, operation terminates
  3. Destructors run during unwinding
  4. 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,
)
ParameterTypeDescription
bodyNursery -> TLambda that spawns tasks
on_errorNurseryErrorModeError handling mode
timeoutDurationMaximum 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;
ModeBehavior
CancelRemainingOn first error, cancel pending tasks; running tasks continue
CollectAllWait for all tasks regardless of errors (no cancellation)
FailFastOn 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:

  1. Is marked for cancellation
  2. Continues executing until it reaches a cancellation checkpoint
  3. At the checkpoint, terminates with CancellationError
  4. Runs cleanup/destructors during termination

Cancellation checkpoints:

  • Suspension points (async calls, channel operations)
  • Loop iterations (start of each for or loop iteration)
  • 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:

  1. Stack unwinding occurs from the cancellation checkpoint
  2. Destructors run for all values in scope
  3. 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

  1. Compute key expression
  2. Check cache for existing unexpired entry
  3. If hit: return cached value (clone)
  4. 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

TTLBehavior
PositiveEntry expires after TTL from creation
ZeroNo caching (always recompute)
NegativeCompile error (E0992)

Concurrent Access

When multiple tasks request the same key simultaneously (stampede prevention):

  1. First request computes the value
  2. Other requests wait for computation
  3. 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:

Aspectcache(...)recurse(..., memo: true)
PersistenceTTL-based, may persist across callsCall-duration only
CapabilityRequires CachePure, no capability
ScopeShared across function callsPrivate to single recurse
Use caseAPI responses, config, expensive I/OPure recursive algorithms

Error Codes

CodeDescription
E0990Cache key must be Hashable
E0991cache requires Cache capability
E0992TTL 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

  1. Evaluate acquire: to obtain resource
  2. If acquire fails (returns Err or panics), stop—no cleanup needed
  3. If acquire succeeds, bind result to action: parameter
  4. Evaluate action: expression
  5. Always evaluate release: with the resource, regardless of how action: completed

The pattern returns the result of action:.

Release Guarantee

If acquire: succeeds, release: runs under all exit conditions:

Exit ConditionRelease Runs
Normal completionYes
Panic during actionYes
Error propagation (?)Yes
break in actionYes
continue in actionYes
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 @panic handler 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

CodeDescription
E0860with pattern missing required parameter (acquire:, action:, or release:)
E0861release 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:

StatementEffect
continueSkip element, add nothing
continue valueAdd value instead of yield expression
breakStop iteration, return results so far
break valueAdd 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.