Skip to content

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:

TierOperators
Pipe|>
Logical or||
Logical and&&
Equality==, !=
Comparison<, <=, >, >=
Inclusionin, !in
Unwrap??
Bitwise or|
Bitwise xor^
Bitwise and&
Shift<<, >>
Range.., ..=
Additive+, -
Multiplicative*, /, %
Power**
Pattern testis, !is
Castas
Unary!, -, ~
Indexingx[i]
Member accessx.field
Callf(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.

nym
func polynomial(x: int): int = x ** 3u + 2 * x ** 2u - x + 7

Exponentiation 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).

nym
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:

nym
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.

nym
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).

nym
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:

nym
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.

nym
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.

nym
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:

nym
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.

nym
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.

nym
func doubled(n: int): int = (10 |> x -> x * n)
nym
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.

nym
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.

nym
func apply(f: (int) -> int, x: int): int = f(x)
func demo(): int = apply($ + 1, 5)
nym
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):

nym
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)).

nym
func double(x: int): int = x * 2
func inc(x: int): int = x + 1
func demo(): int = 10 |> double |> inc

The 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:

nym
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).

nym
func f(n: int): boolean = n as float > 0.0
nym
enum 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.

nym
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.

nym
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 ?? d

break 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.

nym
func classify(n: int): string = {
  if (n < 0) { break@classify "negative" }
  if (n == 0) { break@classify "zero" }
  "positive"
}
nym
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.