Skip to content

Functions and control flow

A word analysis might need to handle empty input, distinguish a missing label from a present one, or reuse a calculation for several groups. Conditions, patterns and functions express those choices. Bindings are immutable, and functions receive outer data through explicit arguments.

Conditions and value matches below run in the word example. The word and CSV examples use ConstructedProgram to read and prepare named functions as well. Functions receive their data through explicit arguments.

The expression spelling if condition then value elseif condition then value else value endif uses the same conditional rules as the block form below. Both comprehension spellings are available inside function bodies and branches; see comprehensions.

Zirium accepts a complete function program as its query argument. From a Zirium checkout, run:

Terminal window
zirium 'fn twice(n: int) -> int { n * 2 } twice(3)' examples/cli/function-report.mlir

The result is 6. The argument 3 supplies n; the final expression in the function body supplies its result. This calculation does not inspect the MLIR input. The Zirium guide shows functions that consume native operations. Zincite also supports named functions in programs supplied with --program or --program-file; its query guide uses MiniZinc source selections and values.

The word executable can run the same pure calculation directly:

Terminal window
cargo run --locked --example words -- 'fn twice(n: int) -> int { n * 2 } twice(3)'

CSV also accepts typed parameters and named functions through the constructed program API, with repeated --param name=value inputs. See the orders report.

A sequence<opt T> parameter accepts a sequence of present T values when the element declarations are compatible. This also applies to host-native types, such as Zincite’s Node and Zirium’s Operation, and to worklist inputs such as queue<opt T>(values). Each member must still pass runtime admission. An empty sequence retains its element declaration, so an empty sequence of a different native kind is rejected.

A conditional has a required final else. Each branch is a block with optional local bindings and a final value:

if length(words) == 0 {
"no words"
} else {
let first = words[1];
"first: " ++ first
}

This returns "first: ash". Only the selected block executes. Its names stay inside that block. An initializer sees the outer scope and earlier bindings; it cannot refer to its own new name. Duplicate names and shadowing fail.

Use else if for another condition. Every written branch receives scope, type and purity checks, even when its condition is false. Runtime errors in an unselected branch, such as a missing array position, remain unexecuted.

A value match evaluates its subject once, then tries arms in written order:

match (length(words)) {
0 => "empty",
1 => "one word",
n if n < 10 => "a few words",
_ => "many words"
}

The result is "a few words". A name pattern binds the subject for that arm’s guard and result. A false guard continues to the next arm. The final arm must be an unguarded _ wildcard.

A pattern mismatch tries the next arm. A guard error does not. Guards require known Booleans; unknown native truth and non-Boolean values fail. All arms receive preflight checks, and only the selected result executes.

Patterns can select fields, rename them and ignore unused data:

match ({"word": "apple", "count": 2}) {
{word: spelling, count: n} if n > 1 => spelling,
_ => "no repeated word"
}

This returns "apple". {word} is shorthand for {word: word}. A final .. permits additional stored record fields without binding them. Without .., the record’s stored labels must match the pattern exactly.

Tuple patterns require a tuple of exactly the given length. (name, _) binds the first value of a pair and ignores the second. It does not match an array. A one-element tuple is written (value,); (value) groups an expression. Literal patterns require the same kind, so an integer pattern does not match a float with the same numeric magnitude.

List patterns match positional arrays or exposed native lists. [a, b] requires exactly two elements; [a, b, ..] requires at least two and discards the rest. [] requires an empty list, and [..] accepts any positional list. The remainder has no name or copied value. Lists do not match tuples, sets or keyed arrays.

Lets, typed parameters, predicate arguments, comprehension binders and value matches use the same patterns. For example:

[[a, b] for [a, b, ..] in [[1, 3, 99], [0, 2]] if a > 0]
[[1, 3, 99], [0, 2]] | filter([a, b, ..] => length(_) >= 2 and a + b > 3)

These return [[1, 3]] and [[1, 3, 99]]. A predicate’s _ reads its whole subject even when the pattern binds parts. Nested predicates rebind _; written outer names remain lexical captures. A wildcard inside a pattern discards only that component. A short list is a located error in a binding, parameter, predicate or ordinary generator. An explicit case generator skips a mismatched candidate, and a value-match arm tries the next pattern.

To select matching list shapes explicitly, write:

[a + b for case [a, b, ..] in [[1, 3], [9], [2, 4, 99]]]

This returns [4, 6]. The short list is skipped before the guard, later sources or head execute. The MiniZinc form uses | case pattern in source. Both forms skip only structural, literal and presence mismatches; guard errors, Unknown truth, inspection failures and budget exhaustion still fail the request. See select matching values for a record/optional example and the strict binding rules.

Local let bindings can use these patterns too:

if true {
let {word: spelling, ..} = {"word": "ash", "count": 1};
spelling
} else {
"unreachable"
}

The result is "ash". A failed binding pattern is an error, and none of its names is installed. All new names become available together after a successful match.

Use absent and present(pattern) to inspect an optional value:

match (lookup({"word": "ash"}, "label")) {
absent => "unlabelled",
present(label) => label,
_ => "unlabelled"
}

This returns "unlabelled". present views an existing present value; it does not introduce another wrapper. The final wildcard is required even when the other arms cover the expected cases.

A missing record field and a stored absent field remain different shapes. A record pattern requires its named fields to be stored. To treat a missing field as optional data, use lookup before matching it.

A function names its parameters and result type:

fn selected_count(xs: sequence<string>, prefix: string) -> int {
length([w for w in xs if starts_with(w, prefix)])
}

In a host that supports functions, selected_count(words, "a") returns 3 for the word model. Arguments are positional and evaluated left-to-right. Every actual argument must meet its complete declared type before parameter patterns bind. The returned value must meet the result type.

A call returning a constructed collection accepts the same common pipeline stages as a literal or saved collection:

fn rows() -> sequence<int> { [3, 1, 2] }
rows() | map(x => x + 1) | windows(2)

The result is [[4, 2], [2, 3]]. Forward calls inside function bodies use the same suffix grammar.

Type spelling Contract
int, float, bool, string The corresponding scalar kind
value Any constructed value
opt T A present T or absence
sequence<T> A positional sequence whose elements satisfy T
tuple<T, U> Two fixed positions with their own types
record {word: string, count: int} Declared record fields
set<T> A scalar or nominal set; set<value> accepts arbitrary constructed values
dict<K, V> A dictionary whose keys satisfy K and values satisfy V
keyed<T> Keyed cells satisfying T
queue<T> A computational FIFO with elements satisfying T
identity_set<T> A computational set of one native kind
A registered name A native or nominal type resolved by the host

Optional record fields may be omitted; extra labels fail the declared contract. Integers do not implicitly satisfy a float contract. Native declarations retain the host’s ownership and identity checks.

A host using the pattern declaration reader can destructure a typed parameter:

fn repeated({word: spelling, count: n}: record {word: string, count: int}) -> string {
if n > 1 { spelling } else { "" }
}

Functions have no default arguments, closures, imports, overloads or function values. Names register before bodies, allowing mutual recursion. Functions cannot edit or emit, and domain operations called by a function must be pure. External param inputs to a program are a separate mechanism with a smaller set of types; see program parameters.

A function can call itself or another function in the same prepared request:

fn count_down(n: int, total: int) -> int {
if n <= 0 { total } else { count_down(n - 1, total + 1) }
}

count_down(4, 0) returns 4. The recursive call is the selected final result, so compatible result contracts allow it to reuse the current activation. An expression such as 1 + count_down(n - 1, 0) has a pending addition and needs another active function call.

Tail-call reuse does not remove work limits. Each call spends work, and an endless recursion eventually fails. Expression nesting and native callback depth have their own limits. See diagnostics and limits.

The common expression reader and evaluator support folds, scans, state loops and worklist collections. Hosts supply native type names, identity and primitive operations; typed builders can construct the same shared representation.

Form Behavior
Fold Apply a named pure step to each item and return the final state; empty input returns the initial state
Scan Retain each post-step state; exclude the initial state and return an empty sequence on empty input
State loop Repeatedly compute a complete next state or break with a result
FIFO queue Pop the front or append items while preserving existing aliases
Native identity set Insert a native identity or test membership

A loop introduces immutable state names. Initializers run in the outer scope; every body path ends with break value or continue (next, ...). Transfers inside a block can end with a semicolon. For example, this expression returns 15:

loop (n = 5, total = 0) {
if n == 0 { break total; }
else { continue (n - 1, total + n); }
}

Fold and scan take a named pure step, including destructured parameters:

fn add((count, total): tuple<int, int>, item: int) -> tuple<int, int> {
(count + 1, total + item)
}
fold([2, 3, 5], (0, 0), add)

The fold returns (3, 10). Replacing fold with scan returns [(1, 2), (2, 5), (3, 10)]. The step position accepts a function name; functions are not stored as ordinary values.

queue(items) infers an element contract from admitted input or context; queue<Operation>([]) supplies one for empty input when the host exposes Operation. pop_front(q) returns absent or present((item, remaining)). push_back_all(q, items) appends in order. identity_set<Operation>() creates an empty native identity set; item in seen tests membership, and insert(seen, item) inserts idempotently. Bare names such as queue and loop keep their ordinary binding meaning when they are not used in these forms.

Fold and scan require a step with state and item parameters. The state contract is fixed; every returned state is checked. A loop likewise computes all next state values against the old state before replacing any of them. Failed checks cannot leave partially updated state. Neither repetition nor an empty input bypasses preflight or admission.

Queues and identity sets retain native ownership. They are computational values, with no scalar serialization or identity-set iteration. The implementation reference covers folds and scans, state loops and native collections.