Expressions
Nymph is expression-oriented: if, match, state loops, for, and blocks all produce a value, not just literals and operator chains. The only things that are not expressions are a bare let binding and the handful of top-level declarations.
Operators and precedence
From lowest to highest binding strength:
| Tier | Operators |
|---|---|
| Pipe | |> |
| Logical or | || |
| Logical and | && |
| Equality | ==, != |
| Comparison | <, <=, >, >= |
| Inclusion | in, !in |
| Unwrap | ?? |
| Bitwise or | | |
| Bitwise xor | ^ |
| Bitwise and | & |
| Shift | <<, >> |
| Range | .., ..= |
| Additive | +, - |
| Multiplicative | *, /, % |
| Power | ** |
| Pattern test | is, !is |
| Cast | as |
| Unary | !, -, ~ |
| Indexing | x[i] |
| Member access | x.field |
| Call | f(x) |
Most binary and unary operators are backed by an interface from the stdlib's ambient operator prelude, so user types can overload them by implementing the matching interface — see Operators for the full list and how dispatch works. ==/!= are the one exception: they always compare by native identity/structural equality and never dispatch anywhere, even for a type that implements Equals.
func polynomial(x: int): int = x ** 3u + 2 * x ** 2u - x + 7Exponentiation is right-associative: a ** b ** c means a ** (b ** c). Its accepted built-in operand and result types are the exact exponentiation matrix; other combinations require a user Power implementation.
Function calls
A call is a callee expression followed by parenthesized, comma-separated arguments. Arguments to a plain function are matched to parameters by position — argument names are not accepted there (named arguments are for struct and enum construction instead, which is parsed the same way but resolved by field name).
func add(a: int, b: int): int = a + b
func demo(): int = add(1, 2)A ... prefix on a call argument spreads an iterable into the call. It's how you pass a list to a spread parameter:
func sum(...xs: #[int]): int = {
xs.iter().fold(0, (total, x) -> total + x)
}
func demo(): int = sum(...#[1, 2, 3])No explicit generic arguments at a call site
A generic function's type parameters are always inferred from the argument types — there is no f<int>(x) turbofish-style syntax for pinning them explicitly at the call.
Method calls
receiver.method(args…) resolves method against the receiver's type: an inherent method, an interface method available through a bound, or one materialized through an impl … for block.
struct Counter(n: int) {
func peek(): int = this.n
}
func demo(c: Counter): int = c.peek()Indexing
x[i] reads an element out of a list, tuple, or map. Lists natively accept uint indices. The standard library extends them with Index<Key = int>, where a negative index counts backward from the end, so xs[-1] is the last element. Other key types can be supported with additional Index implementations. A tuple index must be a literal integer (its element types can differ per position, so the checker needs a constant to know which one you get back).
func first_of(xs: #[int]): int = xs[0]
func last_of(xs: #[int]): int = xs[-1]
func swap_sum(t: #(int, int)): int = t[1] + t[0]
func lookup(scores: #{int: int}, level: int): int = scores[level]Collections are persistent. Use replaced to produce a list with a new value at an index while preserving the original list:
func set(xs: #[int], i: uint, v: int): #[int] = xs.replaced(i, v)Ranges
a.., a..b, a..=b, ..b, and ..=b build range values. The spelling a..= is invalid: inclusive ranges require an upper bound. See Ranges for the full syntax and Iteration for using one as a for source.
func sum_inclusive(n: int): int = {
(1..=n).iter().fold(0, (total, i) -> total + i)
}if and match as expressions
Both branches of an if/else — and every arm of a match — must agree on their type when the result is used as a value, since the whole construct itself has a type. See Pattern matching for everything match can destructure.
func clamp(x: int, lo: int, hi: int): int =
if (x < lo) { lo }
else if (x > hi) { hi }
else { x }
func vowel_index(c: char): int = match (c) {
'a' -> 1,
'e' -> 2,
'i' -> 3,
'o' -> 4,
'u' -> 5,
_ -> 0,
}An if with no else, used where no value is needed, is fine in statement position:
func demo(n: int): int = {
let x = if (n > 0) { n } else { 0 }
x
}Blocks
{ stmt; stmt; expr } runs each statement in order and evaluates to its last expression (or void if the block ends in a statement, not an expression). Blocks are how func bodies, conditionals, loops, and match arm bodies all get more than one step.
func average_scaled(a: int, b: int, scale: int): float = {
let sum = a + b
let scaled = sum * scale
scaled / 2
}Closures
A closure is an anonymous function value: params -> body. A single untyped parameter can skip the parentheses; anything else — zero params, multiple params, or a typed param — needs them.
func doubled(n: int): int = (10 |> x -> x * n)func apply_twice(f: (int) -> int, x: int): int = f(f(x))
func demo(): int = apply_twice((x: int) -> x * 2, 3)A closure captures immutable values from its enclosing scope. A closure built inside a method body keeps reading the original receiver's fields even after the method returns.
func demo(): int = {
let x = 1
let bump = () -> x + 1
bump()
}break exits the nearest enclosing explicit block, loop body, or callable. A callable body block and its callable are one target, while an expression-bodied closure is itself a target. Control inside a closure therefore cannot break from the function that created it. Compiler-generated helpers used to evaluate expression-valued control flow are transparent and do not retarget a source break.
Anonymous closure parameters
A closure short enough that naming its parameters is just noise can skip the header entirely and refer to its arguments positionally: $0 is the first, $1 the second, and so on, with a bare $ as a shorthand for $0. An expression that mentions any $N implicitly becomes a closure — no -> needed.
func apply(f: (int) -> int, x: int): int = f(x)
func demo(): int = apply($ + 1, 5)func combine(f: (int, int) -> int): int = f(7, 2)
func demo(): int = combine($0 - $1)They read especially well as the argument to a transforming method — o.map($ + 1) is exactly o.map((x: int) -> x + 1):
func inc(o: Option<int>): Option<int> = o.map($ + 1)
func evens(o: Option<int>): Option<int> = o.filter($ % 2 == 0)Which enclosing expression becomes the closure's body — the boundary — is chosen by the types, not by punctuation: it's the smallest enclosing expression for which the resulting closure type-checks in its position. That's why $ % 2 == 0 above becomes the whole predicate (x) -> x % 2 == 0 rather than ((x) -> x % 2) == 0: only the wider reading is a (int) -> boolean, which is what filter wants. The search runs at each spot a closure is expected — a call argument, a let initializer, a break operand, a constructor field — and can't cross out past that spot, so a $ always resolves to the nearest such boundary.
Pipe
a |> f calls f with a as its sole argument — f can be any single-argument callable: a named function or a closure. Chained pipes are left-associative, so x |> f |> g is g(f(x)).
func double(x: int): int = x * 2
func inc(x: int): int = x + 1
func demo(): int = 10 |> double |> incThe right-hand side can also be a closure — including a parenthesized anonymous-parameter one, handy for a one-off step that doesn't deserve a name:
func demo(): int = 10 |> ($ * 2) |> ($ + 1)as and is
value as Type casts value to Type. Between the built-in scalar types (int, uint, float, char) it runs Nymph's own defined conversion (see the Cast semantics built into each pair) and always produces the canonical boxed representation of the destination type, including identity and widening casts. Numeric-to-char casts truncate floats toward zero, then require a Unicode scalar value (0..=0x10FFFF, excluding 0xD800..=0xDFFF). Invalid literal casts are compile-time errors; invalid dynamic values fail deterministically at runtime. A cast evaluates its source exactly once. For a user type, it dispatches to an implementation of the ambient Into<Other> interface. value is Pattern / value !is Pattern tests value against a single pattern without a full match — it accepts the same pattern shapes a match arm does, just without a guard (guards are match-arm syntax, not part of a pattern).
func f(n: int): boolean = n as float > 0.0enum Shape { Circle(radius: int), Square(side: int) }
func is_big_circle(s: Shape): boolean = s is Circle(radius = 20)in and !in
item in collection / item !in collection dispatch to the ambient Contains<Item> interface's contains/not_contains methods — note the receiver is the collection, the right-hand operand, not the left-hand item.
struct Bag(n: int)
impl Contains<Item = int> for Bag {
func contains(item: int): boolean = item == this.n
}
func has(b: Bag, x: int): boolean = x in b
func lacks(b: Bag, x: int): boolean = x !in b?? (Unwrap)
a ?? fallback dispatches to the ambient Unwrap<Output> interface's unwrap method, called eagerly as a.unwrap(fallback). Nymph has no null/undefined-style optional representation, so unlike a nullish-coalescing operator in other languages, this is always a plain, unconditional call — nothing here short-circuits at the language level; whatever short-circuiting behavior exists is up to unwrap's own body.
struct MaybeInt(present: boolean, value: int)
impl Unwrap<Output = int> for MaybeInt {
func unwrap(default: int): int = if (this.present) { this.value } else { default }
}
func get(m: MaybeInt, d: int): int = m ?? dbreak and continue
Both are expressions typed never — the type of an expression that never produces a value because control leaves right there — so they can appear anywhere a value of any type is expected, including as an operand.
func classify(n: int): string = {
if (n < 0) { break@classify "negative" }
if (n == 0) { break@classify "zero" }
"positive"
}func first_positive(xs: #[int]): int = {
let found = for@search (i in 0u..xs.length()) {
if (xs[i as int] > 0) { break@search i }
}
match (found) {
Some(value) -> value as int,
None -> -1,
}
}Every explicit { ... } block is a break target. An unlabeled break completes the innermost explicit block, loop body, or callable; a loop body and its block are the same target. The break values and the target's final expression must have one type. A bare break explicitly completes the target with void, and cannot be mixed with a value or final expression of another type.
Label a loop as loop@outer (...) or for@outer (...), a block as outer@{ ... }, or a closure as outer@(params) -> body, then target it with break@outer value. A named function's callable label is its name. continue targets loops only. A callable is a hard lexical boundary: neither form can target a construct outside the current callable. if itself is not a target, though its explicit branch blocks are.
Postfix ? uses exactly the same targets: unlabelled value? propagates None or Error to the innermost target, while value?@label completes a labeled block, loop, or callable. See Error handling for its Option and Result type rules.
Bare and valued breaks may not be mixed in one loop, and all valued breaks must agree on T. A bare break makes either kind of loop void. A for with valued breaks produces Option<T> because it can exhaust naturally; a state loop with valued breaks produces T because it cannot exhaust. This is determined by a lexical scan of the whole loop body, including unreachable branches; unlabeled breaks in nested explicit blocks and all breaks in nested callables are excluded. A labeled break from a nested block can still target the loop.
return is not a keyword; it is available as an ordinary identifier.