Functions, lambdas & modules

Mix functions come in two shapes that look almost identical but mean different things:

  • function name(...) — a statement that registers a named function in the current scope.
  • function(...) / fn(...) — an expression that evaluates to a first-class function value (a lambda) you can store, pass, return, or stash in a map.

Both share one tail grammar (($params) ... end or ($params) = expr), one binding model (args by value, writes function-local), and one capture rule (closures see their defining scope for reads). Get those four facts straight and everything else follows.

Verified against mix 0.21.2. Every example below was run with mix -c and shows its real output. The binary is the oracle — if anything here disagrees with live behaviour, the binary wins.

Named functions — the statement form

function add($a, $b)
  return $a + $b
end
print(add(2, 3))
5

A named function is a statement: evaluating it registers add in scope and returns nil. The body contains newline- or ;-separated statements closed by end (there is no do; see control flow). Use return to hand a value back; a block body that never returns yields nil (more on that below).

fn alias and the = expr short form

fn is a Rust-style short alias for function — it works everywhere function does, and both spellings accept both body forms (block ... end or = expr, named or anonymous — all eight combinations parse). The = expr form is a single-expression body: no return, no end, the expression's value is the result.

fn square($x) = $x * $x
print(square(7))
49

This is the form to reach for in HOFs: map($xs, fn($x) = $x*2).

The body is one expression — which includes the value-producing conditionals (ternary ?:, if ... end expressions, ?? — see operators and control flow), so a conditional one-liner needs no block:

$sgn = fn($x) = $x > 0 ? "pos" : "neg"
print($sgn(3))
print($sgn(-3))
pos
neg

fn needs quoting as a map key. A keyword is accepted anywhere it is unambiguously a NAME — bare map keys ({label: 1, to: "x"}), field access and assignment ($m.to, $cfg.label = 2). The ONE exception is fn: it lexes to the same token as function, so {fn: 1} and $m.fn are parse errors — use a quoted key ({"fn": 1}) and index access ($m["fn"]).

Top-level functions hoist (0.63.0)

Every function/fn declared at the direct top level of a script is bound before the first statement runs, so a forward call works — a script can put its entry point first and its helpers last:

print(f())
function f()
  return 1
end
1

The hoist is top level only. A function nested inside a block (an if branch, a loop body) still binds when — and only when — its branch executes; hoisting those would make a conditional definition unconditional. Function bodies are not pre-scanned either: a nested definition inside a body binds when its statement runs, as before. Safe by construction: a Mix fn captures nothing from the enclosing scope, so an early binding cannot close over a not-yet-defined value.

The hoist applies to every invocation root: a script run directly, a required module's top level (the same file forward-calls identically either way), a sourced file, and a serve-mode on handler body — in a handler the binding happens on entry, so a top-level fn below an early return is still defined.

Redefinition: with two same-named top-level definitions, a call before both sees the last one (the hoist binds them in order); code between them still sees the earlier one, exactly as before 0.63.0 — the definition statement re-binds when reached. This includes prelude names: a script that defines its own fn lines(…) shadows the prelude's lines from the first statement, even at call sites above the definition.

Mutual recursion no longer needs both definitions to precede the first call — calls resolve at call-time, and hoisting has already bound every top-level name:

function is_even($n)
  if $n == 0 then return true end
  return is_odd($n - 1)
end
function is_odd($n)
  if $n == 0 then return false end
  return is_even($n - 1)
end
print(is_even(10))
print(is_odd(7))
true
true

Recursion is capped at 128 frames

Recursion works, but the evaluator caps nesting at 128 calls deep (127 nested recursive calls pass, 128 fail). Exceeding it is a clean, catchable runtime error — not a stack-overflow abort:

function f($n)
  if $n == 0 then return 0 end
  return f($n - 1)
end
print(f(100000))
Runtime error at line 3: recursion depth exceeded (limit 128)

For deep counts, use a loop or a HOF instead of recursion.

Default parameters

A parameter can carry a default expression, used when the caller omits that argument:

function greet($name = "world")
  return "hello " .. $name
end
print(greet())
print(greet("mix"))
hello world
hello mix

A missing argument with no default binds nil. Extra arguments are silently ignored (f(1, 2, 3) against function f($a) just binds $a = 1).

Since 0.29.0 that tolerance is a mode: mix --strict-arity script.mix (or an embedder's ArityMode::Strict) raises a catchable ARITY_MISMATCH structured error instead — before the function body runs — whenever a call's argument count falls outside min..=max (min = parameters without defaults). Strict mode also enforces every builtin's contract arity from the machine metadata, so run_stream(["true"], {}, 5)'s silently-ignored surplus argument becomes an error. (That example read run_stream(argv, {timeout: 5}) until 0.51.0, when run_stream gained an options map and began refusing timeout by name as OPTION_INVALID — in every mode, not just this one.) The compatible binding stays the default; exact arity would only ever become the language default at an announced major compatibility boundary, after explicit variadic syntax lands. mix lint flags statically provable mismatches in either mode.

A keyword can't be a function name. function step(...) and fn to(...) are parse errors (expected identifier, got Step) — and that includes non-obvious reserved words like step, to, label. Pick phase, say_step, etc. $-sigil variable and parameter names are unaffected — the sigil disambiguates, so $step = 1 and function f($to) both work.

You can't shadow a builtin. function abs($x) return 999 end parses fine, but calls still resolve to the builtin: abs(-5) is 5, never 999. A builtin always wins over a same-named Mix function — pick another name.

Lambdas — the expression form (first-class values)

Drop the name and function(...) becomes an expression that evaluates to a function value. Store it, call it, pass it around:

$sq = function($x) return $x * $x end
print($sq(9))
81

The terse form (fn($x) = expr) is a lambda too — anonymous, single-expression, no end:

$dbl = fn($x) = $x * 2
print($dbl(21))
42

A lambda is an ordinary Value: put it in a list or map, return it from another function, hand it to a builtin. That is the whole point — see modules and HOFs. Stringified, a function value shows its parameter count: "" .. $dbl is <function/1>, a two-param lambda is <function/2>.

Block bodies do not implicitly return

A function ... end block body does not return its last expression. If you want a value out of a block body, you must return it:

$f = function($x) $x * $x end
print("result: " .. ("" .. $f(5)))
result: nil

The = expr short form is the opposite — it returns the expression directly. So fn($x) = $x*$x gives 25, but function($x) $x*$x end gives nil. Reach for = expr whenever the body is one expression.

The binding model — by value, local writes

Three rules govern how data flows in and out of a function. They are the most common source of surprise for anyone arriving from bash/python, so verify them against your own code.

Arguments pass by value

Numbers, lists, and maps are copied in. Mutating a parameter — even push() on a list param — does not reach the caller:

function addit($p)
  push($p, 99)
  return length($p)
end
$l = [1, 2, 3]
print("inside len: " .. ("" .. addit($l)))
print("caller len: " .. ("" .. length($l)))
inside len: 4
caller len: 3

Because this silently discards work, Mix warns (to stderr, once, when the function is defined) about the provably-lost case — a push($p, …) statement whose result is thrown away where $p is a parameter the body never reads again:

mix: warning: in fat_snare() at line 2: push($drums, …) discards its result but
$drums is a by-value parameter never read again — the mutation is lost to the
caller (Mix lists pass by value). Return the list or use concat().

The warning is deliberately conservative (zero false positives): it stays silent the moment $p is genuinely used — return $p, length($p), $p[i], capturing $y = push($p, …), pop($p), or passing $p on — so addit above (which reads $p via length) does not warn. It only fires when a push into a parameter can have no observable effect at all.

Building a sequence across helpers (e.g. accumulating MIDI events for a .asc score) is where this bites hardest — a helper that pushes onto a list parameter silently drops every event onto the copy. Under by-value semantics, have the helper return its items and combine them at the call site with concat (concat($a, $b, …) joins lists into one new list, one level deep, O(total)):

function bar_events($b)
  return [$b * 10, $b * 10 + 1]   -- RETURN a list, never push a param
end
$track = []
for each $b in [0, 1, 2]
  $track = concat($track, bar_events($b))   -- combine at the caller
end
print("" .. $track)
[0, 1, 10, 11, 20, 21]

Writes inside a function are function-local

Any $x = ... assignment inside a function binds a function-local variable. It does not write through to an outer/global of the same name — and that includes $rc/$result: when those change after a send, it is the Bus runtime writing them natively, not a Mix assignment (see Bus messaging); a literal $rc = ... inside a function is as local as any other write.

$g = 1
function w()
  $g = 99
end
w()
print($g)
1

This includes the accumulator trap: a helper doing $count = $count + 1 leaves the caller's $count untouched. A function cannot tick a shared global counter.

Reads fall through (globals fallback)

A function may read an outer/global variable fine — lookups fall through to the global frame:

$g = 42
function r()
  return $g + 1
end
print(r())
43

The pass-in / return / reassign triad

Because writes are local and args are by value, the way to thread state through a function is the triad: (1) pass the value in (explicit read) → (2) return the new value (compute) → (3) reassign at the call site (write). There is no global keyword — this is the idiom by design.

function inc($n)
  return $n + 1
end
$count = 0
$count = inc($count)
$count = inc($count)
print($count)
2

Return a map to update several values at once, then destructure at the call site. Net result: pure, self-contained functions — a helper can't accidentally read or stomp a caller's same-named variable.

Closures — capture for reads

A lambda captures its defining scope for reads, so it carries state. Define a lambda where $base is in scope and it stays bound to $base even after the lambda escapes:

$base = 100
$add = function($x) return $x + $base end
print($add(5))
105

This is what makes a closure factory work — each call to adder produces a lambda closed over that call's own $n:

function adder($n)
  return function($x) return $x + $n end
end
$add10 = adder(10)
$add100 = adder(100)
print($add10(5))
print($add100(5))
15
105

A closure captures only the variables its body actually references, not the whole enclosing frame. So defining a key/predicate lambda inline inside a function that also holds a large value — a multi-thousand-element list, a whole generated score — costs nothing per call for the data it doesn't touch. That is what keeps sort_by/map/filter over big data O(n log n)/O(n) even when the lambda is written inside a heavy scope: sort_by($track, fn($e) = $e["t"]) closes over $e alone, never the surrounding tracks.

A closure can read captured state but not accumulate into it

The same local-write rule applies: a write to a captured/outer name binds a local and does not mutate the captured variable:

$c = 0
$bump = function() $c = $c + 1 end
$bump()
$bump()
print($c)
0

So a closure can read shared state but can't accumulate into it. To carry a counter forward, thread it through the triad ($n = $inc($n)):

$inc = fn($x) = $x + 1
$n = 0
$n = $inc($n)
$n = $inc($n)
$n = $inc($n)
print($n)
3

Binders shadow, they don't clobber

Names a function introduces — a for each $x / for $i = ... loop var, a catch $e, a parse ... with $a $b target — bind in the function's own frame, so they shadow a same-named caller/global rather than overwriting it:

$x = "caller"
function loops()
  for each $x in [1, 2, 3]
    $unused = $x
  end
  return "done"
end
print(loops())
print($x)
done
caller

Higher-order functions

The payoff of first-class lambdas is the HOF builtins — no hand-rolled for each ... push ... end loops. Pass a lambda (inline or stored) as the transform:

$out = map([1, 2, 3], function($x) return $x * $x end)
print("" .. $out)
[1, 4, 9]
$out = filter([1, 2, 3, 4, 5], fn($n) = $n > 2)
print("" .. $out)
[3, 4, 5]

reduce takes the list, an explicit init, then fn(acc, item) — mind that order:

$sum = reduce([1, 2, 3, 4], 0, function($a, $b) return $a + $b end)
print($sum)
10

The full HOF set (mix what <name> for each): map filter reduce sort_by any all count min_by max_by sum_by group_by unique_by. Each one is covered on the dedicated hof page.

Two HOF gotchas

(1) You can't pass a named function by bare name. A bareword is parsed as a string, not a function value:

function triple($x) return $x * 3 end
$out = map([1, 2, 3], triple)
print("" .. $out)
Runtime error at line 2: map: expected function, got string

Wrap it in a lambda — $f = function($x) return triple($x) end — or define it as a lambda var in the first place.

(2) A multi-statement lambda won't parse inline inside a HOF call. The open call-paren breaks statement separation:

$out = map([1, 2, 3], function($x)
  $y = $x * 2
  return $y + 1
end)
print("" .. $out)
Parse error at line 3:3: expected end of statement, got Return

Bind the lambda to a var first, then pass the var:

$f = function($x)
  $y = $x * 2
  return $y + 1
end
$out = map([1, 2, 3], $f)
print("" .. $out)
[3, 5, 7]

A single-expression lambda (function($x) return $x*$x end or fn($x) = ...) is fine inline — only multi-statement bodies need the var-bind. A stored-var lambda also reads cleanly with sort_by and friends:

$ports = [{ port: 80 }, { port: 22 }, { port: 443 }]
$by_port = function($r) return $r["port"] end
$sorted = sort_by($ports, $by_port)
print("" .. $sorted)
[{port: 22}, {port: 80}, {port: 443}]

Modules — a map of lambdas

Mix has no module keyword, but a map of lambdas is a namespace. Functions are values, so you can group them:

$math = { square: function($x) return $x * $x end, cube: function($x) return $x * $x * $x end }
print($math["square"](5))
print($math["cube"](3))
25
27

Dot-call works — member-first for function members (0.72.0; 0.27.0–0.71 dispatched name-first). $math.square(5) calls the map's own square whenever the member exists and holds a function — even when a free/prelude function of the same name also exists (before 0.72.0 the free name won, which made a module export named sum or lines silently unreachable via dot-call). When the map has no function-valued member of that name, the call is ordinary UFCS sugar ($list.map($f), $s.upper() — unchanged). Two residual collisions to know: a member named after a builtin is still unreachable via dot (the desugar happens at parse time), and serve-mode extension verbs still outrank members by design. $math["square"](5) calls the member unconditionally — the index form remains the collision-proof spelling:

$math = { square: function($x) return $x * $x end }
print($math.square(5))     -- 25 (member — no free 'square' exists)
print($math["square"](5))  -- 25 (index form: collision-proof)

Each lambda still closes over the defining scope for reads, so a module can share read-only state across its members:

$prefix = ">> "
$log = { line: function($s) return $prefix .. $s end }
print($log["line"]("hello"))
>> hello

See data structures for indexing details.

Grouping functions across files — require vs include vs source

For libraries, split functions into their own .mix file and pull them in. Mix has three file-loaders with different semantics (full comparison + module semantics: modules):

  • $m = require("lib.mix") (0.27.0+) — evaluates the file in an isolated scope and returns its exports as a map ($m.fn(...), $m.var). Cached once per file, cycle-safe, cannot clobber (or see) your scope. Use this for anything library-shaped.
  • include "lib.mix"script-relative path, load-once (re-include is a no-op), splices the file's fns + $vars INTO your scope. Use when you want the names unprefixed and own both files.
  • source "lib.mix"CWD-relative path, runs every time. Use this for re-running setup.

Given util.mix:

function lib_double($x)
  return $x * 2
end
print("[lib loaded]")

include runs the lib body once even if included twice:

include "util.mix"
include "util.mix"
print(lib_double(21))
[lib loaded]
42

source re-runs it each time:

source "util.mix"
source "util.mix"
print(lib_double(21))
[lib loaded]
[lib loaded]
42

include drops names into a flat namespace

include injects the lib's functions straight into the caller's scope — the namespace is flat. A name collision across two libs silently last-loaded-wins (no warning). Prefix your function names, or use the module idiom to scope them.

Note include is not expression-valued — you can't capture its result:

$m = include "lib.mix"
Parse error at line 1:6: unexpected token Include

So a "module library" assigns a well-known var name and the caller uses that after the include. Given mathlib.mix:

$mathlib = {
  add: function($a, $b) return $a + $b end,
  mul: function($a, $b) return $a * $b end
}

The caller:

include "mathlib.mix"
print($mathlib["add"](3, 4))
print($mathlib["mul"](3, 4))
7
12

Quick reference

FormKindBodyReturns
function f($a) ... endstatement (registers f)blocknil unless return
fn f($a) = exprstatement (registers f)one expressionthe expression
function($a) ... endexpression (a value)blocknil unless return
fn($a) = exprexpression (a value)one expressionthe expression

fnfunction in every row — either keyword takes either body form, named or anonymous.

  • Sigil everywhere: $x, params $a/$b. Bare x = 1 is misparsed as a shell command.
  • Concat is .. (never +/.): "hi " .. $name.
  • Top-level fns hoist (0.63.0) — forward calls work at the script's top level; a fn nested in a block still binds when its branch runs. Recursion caps at 128 frames (clean runtime error).
  • A keyword can't be a function name (function step(...) errors; $step = 1 is fine) and a builtin can't be shadowed (calls always resolve to the builtin).
  • Args by value; writes function-local; closures capture for reads only. Propagate state with the pass-in/return/reassign triad — there is no global.
  • Modules = map of lambdas, index-called ($mod["fn"](args)), not dot-called.
  • include = script-relative, load-once (libs); source = CWD, every-time.
  • Multi-statement lambdas must be var-bound before going into a HOF; named functions can't be passed by bare name.

See also

  • hof — the twelve HOF builtins in depth (map/filter/reduce/sort_by/…)
  • strings.. concat, interpolation, the 'raw' vs "${...}" split
  • control flowif/for/while, end, why there is no do
  • operators — ternary ?: and ??, the expressions a = expr body can hold
  • maps / lists — indexing, the data behind the module idiom
  • keywords — the reserved words that can't be function names
  • builtins index — everything callable out of the box
  • Bus messagingsend/on/emit keywords (where $rc/$result come from)
  • The mix repo · bus · cos
  • mix help — command overview · mix what map (and any builtin) — one-line signature + version