How to write the everyday patterns — closures, chained pipelines, and builders — the Zerg way, with the small core it already gives you. Part of the Language Reference. Also in 繁體中文.
Zerg's anonymous function fn(…) -> R { … } is the closure — first-class, capturing by copy (see
Functions & Closures). There is deliberately no terser
|x| lambda: a little verbosity nudges you toward Zerg's procedural-first style rather than deep
functional chains. Three ways to keep closures readable, in order of preference:
- Name the function — first-class functions pass by name, so a named
fnis reusable, testable, and clutter-free at the call site. - Write a
forloop — often the clearest, and the most procedural-first. - Inline
fnthat fits its slot — for a one-off, an untyped parameter takes its type from the function type the closure is checked against, and so does an omitted result type (Type System). Where there is no such position —f := fn (x) { … }— there is nowhere to take either from: the parameter is a named error, and an absent-> typemeans nil, never an inference.
Method calls chain (a.f().g()), so map / filter / fold compose. Prefer named functions over
inline closures:
fn double(x: int) -> int { return x *% 2 }
fn positive(x: int) -> bool { return x > 0 }
result := xs.map(double).filter(positive).fold(0, add)
[not yet] The adapters themselves do not exist.
xs.map(double)reports E9056 NotImplemented: the list methodmap— this compiler haslenandappend, andfilterandfoldanswer the same way, so the chain above has nothing to chain. Method calls do chain, and the ones alisthas are the two named in that message; until the adapters land, the loop below is not merely the procedural-first alternative but the only spelling.
Or, procedural-first, just loop — the idiom Control Flow endorses (append into another collection):
mut out: list[int] = []
for x in xs {
continue if not positive(x)
out.append(double(x))
}
The type is written on the left because the empty list has none of its own — mut out := [] is E3034 the
binding out gives the empty list [], which has no type of its own. That matters more here than it
usually would: with the adapters unbuilt this loop is the only spelling, so it had better be one that
builds.
When an inline function is genuinely one-off, the parameter type its position supplies keeps it short:
ys := xs.map(fn(x) -> int { return x *% 2 }) # x: int taken from xs; -> int written
[not yet] What is left unbuilt in this line is
map, marked above as E9056. The untyped parameter is not:xtakes its type from the function type the closure is checked against, so the line could be writtenxs.map(fn(x) { return x *% 2 })the day the adapter exists.
Named arguments and default parameters are Zerg's builder. Most fluent Builder().x().y().build()
ceremony exists only to make inputs optional — which Functions & Closures already gives you
in one call:
fn connect(host: str, port: int = 443, tls: bool = true, timeout: int = 30) -> Conn { … }
c := connect("example.com") # all defaults
c := connect("example.com", port: 8080, tls: false) # override only what you name
For plain data, construction is a call with named fields, which does the same:
cfg := Config(host: "example.com", port: 8080)
When you genuinely need a staged / fluent builder (e.g. a query builder), copy-by-value makes a
fluent-immutable builder fall out — each step reads this, modifies a copy, and returns it, so the chain
never shares mutable state:
q := new_query().where("age > 18").order("name").limit(10)
# fn where(clause: str) -> Query {
# mut q := this # a local copy of the receiver
# q.filters.append(clause)
# return q
# }
To mutate the builder in place instead, use a mut fn method on a mut binding (inside a block or with).
Destructuring binds directly at a :=: a tuple (a, b) := e and a struct P{x, y} := e both unpack in one
step — the everyday way a multiple return or a small record is consumed. The catalogue of what a pattern may
be is Control Flow; what matters here is that a target is not a pattern. A match
asks whether a value has a shape and a destructuring binding is told it, so nothing in a target tests:
every leaf either mints a name (:=) or names storage that already exists (=), and a leaf that would
test — a literal — is refused where it is written.
The two shapes nest, and they nest into each other: ((a, b), c) := e and
Pair{a: P{x, y}, n} := e. A _ reads a position and drops it. mut and const sit before the whole
target, since that is where GRAMMAR#binding puts them, so they apply to every name it
mints.
A struct target asks the same three questions a struct pattern does, because it is the same production
(GRAMMAR#struct-pat) in another position: the type it names must be the value's, every
field it names must exist, and without a .. it names them all.
[not yet]
L(Yes(v))andL(0)are both E9076 NotImplemented: a sub-pattern inside a variant payload, beginning at…. A RESERVED WORD in a payload position is a different rule and keeps its own:L(this)is E2013thisis a reserved word and cannot name a pattern binding.Binding the whole matched value beside its parts is built —
pattern as name, which tests nothing of its own and names what the pattern under it matched. GRAMMAR attaches it to asub-pattern, which is the SIBLING of a range arm rather than one of its forms, so1..=9 as nis derived by nothing.Two more shapes an arm can be written with are refused by name. Matching absence literally is E9068 NotImplemented:
nilas amatchpattern; and shadowing an arm's own binding inside that arm is E9070 NotImplemented:vre-binds a name amatcharm's pattern already binds in this block — the arm's binding is spliced in as text, so the new one would never be read.
Two conveniences that other languages reach for are left out to stay small and procedural-first:
- UFCS (
x.f(y)≡f(x, y)) would let free functions chain like methods, but adds a name-resolution rule. - A pipe operator
|>reads well for pipelines but is a new operator and leans functional.
Reach for a named function and a for loop first; they cover these cases without new syntax.