Functions
Functions are declared with fn, take typed parameters, and state what they return. Parameters may carry defaults, be passed by name, or be variadic.
Basic Functions
Section titled “Basic Functions”fn add(a: integer, b: integer) -> integer return a + bend
fn average(a: float, b: float) -> float return (a + b) / 2.0end
fn greet(name: string) -> nil print("Hello, " .. name)endDefault Parameters
Section titled “Default Parameters”class Player name: string = "" health: integer = 0 score: integer = 0
fn init(name: string, health: integer, score: integer) self.name = name self.health = health self.score = score endend
fn createPlayer(name: string, health: integer = 100, score: integer = 0) -> Player return Player(name, health, score)end
local a: Player = createPlayer("Arthur") -- health=100, score=0local b: Player = createPlayer("Arthur", 50) -- health=50
println(a.health) -- 100println(b.health) -- 50, score=0Named Parameters
Section titled “Named Parameters”fn setupGame(width: integer, height: integer, title: string, fullscreen: boolean = false) -> nil -- ...end
setupGame(width: 1920, height: 1080, title: "My Game", fullscreen: true)Multiple Return Values
Section titled “Multiple Return Values”fn minMax(items: table<integer>) -> (integer, integer) local min: integer = items[1] local max: integer = items[1] for val: integer in items do if val < min then min = val end if val > max then max = val end end return min, maxend
local min: integer, max: integer = minMax({3, 1, 7, 2, 9})Variadic Functions
Section titled “Variadic Functions”fn sum(...values: integer) -> integer local total: integer = 0
for v: integer in values do total = total + v end
return totalend
print(sum(1, 2, 3, 4, 5)) -- 15Generic Functions
Section titled “Generic Functions”fn filter<T>(items: table<T>, predicate: fn(T) -> boolean) -> table<T> local result: table<T> = {}
for item: T in items do if predicate(item) then result[#result + 1] = item end end
return resultend
local nums: table<integer> = {1, 2, 3, 4, 5, 6}local evens: table<integer> = filter<integer>(nums, x => x % 2 == 0)Inside the body a type parameter is rigid: T stands for whatever the caller picked, so it matches only itself. Widening into any is free, but narrowing to a concrete type is a downcast and goes through the checked as — the same escape any uses:
fn onlyInts<T>(items: table<T>) -> table<integer> local result: table<integer> = {}
for item: T in items do local n: integer = item -- rejected: a `T` is not an `integer` local n: integer? = item as integer -- checked at runtime, may be nil
if n != nil then result[#result + 1] = n! end end
return resultendTwo type parameters are independent for the same reason: nothing proves a T is a U. That holds across functions too — a T declared by the function you are calling is not the T declared by the one you are writing, however alike they read.
Classes, interfaces and enums take type parameters the same way — see Generic Classes, Generic Interfaces and Generic Enums.
Bounds
Section titled “Bounds”A bare <T> says only “some type the caller picks”, so there is nothing a body can do with a T but move it around. A bound says what the parameter has:
interface Named fn name() -> stringend
fn describe<T: Named>(x: T) -> string return "<" .. x.name() .. ">" -- `Named` promises `name`endThe bound is checked in both directions, which is the whole point of writing one:
- Inside the body, a
Thas exactly the members its bound declares —x.name()above resolves againstNamed, and a typo or a method the interface does not declare is a compile error. - At every call site, the type filling
Thas to satisfy the bound.describe(5)is rejected becauseintegerhas noname.
Without a bound, neither half is possible, so reading a member off an unconstrained parameter is refused outright:
fn show<T>(x: T) -> string return x.name() -- ERROR: no member `name` on the unbounded type parameter `T`endA bound names an interface the type must implement or a class it must be, with its own type arguments where it has them. Operator interfaces are the ones that pay off most, because they are what makes arithmetic on a type parameter possible at all:
fn total<T: OpAdd<T, T>>(a: T, b: T) -> T return a + b -- `OpAdd<T, R>` puts the result lastend
fn larger<T: OpCompare<T>>(a: T, b: T) -> T if a > b then return a end return bendClasses, interfaces and enums take bounds on their own parameters too, and a method’s bound nests inside its class’s:
interface Named fn name() -> stringend
class Person implements Named local who: string fn init(who: string) self.who = who end fn name() -> string return self.who endend
class Registry<T: Named> items: table<T> = {}
fn names() -> string return String.join(", ", Iter.map(self.items, i => i.name())) endend
local people: Registry<Person> = Registry<Person>() -- ok: `Person` is `Named`local wrong: Registry<integer> = Registry<integer>() -- ERROR: `integer` does not satisfy `Named`Two rules worth knowing. A parameter may be handed to another bounded parameter when its own bound implies the target’s — all the caller knows about a T is its bound, so that is what has to be enough. And a bound is still rigid: <T: Named> does not make a T interchangeable with Named, it makes a T usable as one.
Piping with when(...):
Section titled “Piping with when(...):”The when(...) keyword starts a colon-based pipeline (“Saule style”). It wraps a value, and every subsequent :func(args) calls func with the upstream value threaded in as the first argument:
fn pipe(s: string) -> string return s .. "world"end
local result: string = when("Hello, "):pipe()-- equivalent to: pipe("Hello, ")println(result) -- Hello, worldEach stage feeds its result into the next, so a chain reads top-to-bottom even though every step is an ordinary free-function call:
fn filter<T>(items: table<T>, keep: fn(T) -> boolean) -> table<T> return Iter.filter(items, keep)end
fn map<T>(items: table<T>, f: fn(T) -> T) -> table<T> return Iter.map(items, f)end
fn length_of<T>(items: table<T>) -> integer return #itemsend
local size: integer = when({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) :filter<integer>(x => x % 2 == 0) :map(x => x * x) :length_of()
println(size) -- 5That’s exactly the same as length_of(map(filter<integer>({...}, x => x % 2 == 0), x => x * x)) — just easier to read.
Static type checking along the chain
Section titled “Static type checking along the chain”The type of the upstream value must match the first parameter of the next stage, otherwise the typechecker rejects the chain at compile time:
fn to_str(n: integer) -> string return tostring(n)end
fn square(n: integer) -> integer return n * nend
local err = when(5):to_str():square() -- ERROR: stage `square` expects `integer`, got `string`Rules:
- The chain needs at least one
:stage()afterwhen(...)— a barewhen(x)is a parse error so the keyword’s purpose stays unambiguous. - Stage targets are resolved as free functions in scope (locals, module variables, top-level
fn). Class methods and lambdas aren’t pipeable today. - The piped value always becomes argument
#1; declared defaults and the variadic tail still apply to the remaining parameters as usual.