Functions & lambdas
fn add(a, b) = a + b # expression body
fn greet(name) = "Hello, {name}!"
fn factorial(n) { # block body
if n <= 1 { 1 } else { n * factorial(n - 1) }
}
double = fn(x) x * 2 # lambda
fetch = async fn(url) http.get(url) # async lambda
fn(params) body is the canonical anonymous form. The pipe form |x| x * 2 still parses but is deprecated: ecko check warns and ecko fmt rewrites it.
A fn(x) { ... } body is a block, except when the brace opens a map literal - no statement can begin with name :, so there is no ambiguity:
fn(x) { value: x } # returns a map
fn(x) ({ value: x }) # the same thing, said explicitly
fn(x) { y = x + 1
y * 2 } # a block, because `y = ...` is a statement
ecko fmt canonicalises the first form to the second.
Defaults and named arguments
fn box(w, h, fill = "-") = fill * (w * h)
box(2, 3) # positional
box(2, h: 3) # named
box(2, 3, fill: "#") # positional first, then named
Named arguments bind after positional binding. Passing a parameter twice, naming one that does not exist, or putting a positional argument after a named one is an error.
Typed parameters
fn inc(x: Int, by: Int = 1) = x + by
area = fn(w: Float, h: Float) w * h
print(inc(41)) # 42
print(area(2, 3)) # 6 - an Int is accepted where Float is declared
Write name: Type to declare what a parameter takes. It is optional, and the rules are the ones struct fields use: Int widens to Float, Float does not narrow to Int, List<T> checks each element, a record type compares by name.
T? means a T or null, for anything that may be absent:
fn port(p: Int? = null) = p ?? 8080
print(port(), port(9090)) # 8080 9090
Option<T> is the older spelling of the same thing. It still works, ecko check warns about it, and ecko fmt rewrites it to T?.
The check runs in two places, and they always agree. ecko check refuses a literal that can never fit - inc("5") stops the program before it starts. Any other value is checked when the function is entered, and a mismatch is a catchable error that names the parameter:
error: parameter `x` of `inc` expects Int, got string
Return types
-> Type after the parameters declares what a function returns, and it is checked with the same rules - on the value the body ends with and on every return:
fn label(n) -> String {
if n < 0 { return n }
"n={n}"
}
label(3) is "n=3". label(-1) throws:
error: `label` is declared to return String, but returned int
A function that falls off the end without a value returns null, so declare -> T? when that is a real outcome. The check runs before @ensures, so a postcondition can rely on the type. ecko check refuses a literal that can never fit, like fn f() -> Int = "x".
Arity is checked at the call site
'box' expects 2..3 argument(s), got 4
'box' is missing required argument 'h'
ecko check finds these before the program runs, including for built-ins. Passing null explicitly satisfies arity and does not trigger a default - an explicit null is a value you chose.
Built-ins reject extra arguments the same way your own functions do:
abs(-1, 99) # abs takes at most 1 argument, got 2
math.gcd(12, 18, "nonsense") # math.gcd takes at most 2 arguments, got 3
Some take optional trailing arguments - str.trim(s, chars?), sql.query(db, sql, params?) - and a few are variadic: fs.join, fmt.format, print. Each module page gives the signature.
A built-in's options are named arguments:
proc.run("ffmpeg", args, timeout_ms: 5000)
http.get(url, headers: { accept: "text/plain" }, timeout: 2000)
log.to_file("app.log", level: "warn", json: true)
Passing them as a map - proc.run("ffmpeg", args, { timeout_ms: 5000 }) - is an error that shows the named form, and so is an option the function does not have (timout_ms) or a value of the wrong type (timeout_ms: 1.5). A map that is data, like http.response's headers or a log line's fields, is still passed positionally.
Closures capture their defining scope
They observe later updates and can mutate an outer mut:
fn make_counter() {
mut n = 0
fn() {
n = n + 1
n
}
}
next = make_counter()
next() # 1
next() # 2
The captured scope outlives the defining call. Named functions can be declared inside another function, and plain, async and contracted nested functions all capture the same lexical scope.
In parallel contexts the capture is a snapshot instead - see Variables & mutability.
fn bindings are immutable
fn f() = 1
f = 2 # error
let f = fn() 2 # legal
return
Exits the enclosing function immediately. Often unnecessary, since a block evaluates to its last expression, but useful as a guard:
fn handle(x) {
unless valid(x) { return error_response() }
process(x)
}
Contracts and tools
A function is where contracts attach:
@requires(n > 0)
@ensures(result >= n)
fn grow(n) = n * 2
And where @tool makes a function available to a model.