Functions let you give a reusable piece of logic a name so you can call it from multiple places without copying code. This page covers: declaring functions, default argument values, reference parameters (so a function can modify the caller's variable), early return, const parameters, and type-based dispatch.
Declaring Functions
The keyword fn starts a function definition. List parameters as name: type separated by commas. -> type declares what the function gives back. The last expression in the body is returned automatically — no return needed there. Default values let callers skip optional arguments.
fn greet(name: text, greeting: text = "Hello") -> text {
greeting + ", " + name + "!"
}
Default arguments that involve other arguments
Loft defaults must be literal values, not expressions referencing other parameters. When one argument's default should be derived from another, use a sentinel value and compute the actual default inside the function body. The convention is to use -1 (or 0) as "not provided" for integer parameters.
fn substring(t: text, start: integer = 0, end: integer = -1) -> text {
actual_end = if end < 0 { len(t) } else { end };
t[start..actual_end]
}
Named Arguments
When a function has several parameters with defaults, you can skip middle ones by naming the arguments you want to provide. Positional arguments come first; once you use a name, all remaining arguments must also be named.
fn connect(host: text, port: integer = 8080, tls: boolean = true) -> text {
"{host}:{port}:{tls}"
}
Reference Parameters and Defaults
A function with no -> type is called for its side effects, not its result. Adding & before a parameter type makes it a reference: the function receives a direct link to the callers variable and can read or write it. Without &, Loft copies the value in, so changes inside the function stay local. Default values (written = value' after the type) are substituted when the caller omits that argument.
fn add(a: & integer, b: integer, c: integer = 0) {
a += b + c;
}
Early Return
Use return value; to exit a function before reaching the end. This is handy for guard clauses: handle the special cases first, then write the normal path without extra nesting.
fn classify(n: integer) -> text {
if n < 0 {
return "negative";
}
if n == 0 {
return "zero";
}
"positive"
}
Const and Reference Parameters
const on a parameter tells the compiler "this function must never change this value." The compiler enforces it — any assignment to a const parameter is a compile error. & is the opposite promise: "this function will modify this value." Declaring & without actually writing to the parameter is also a compile error. These rules make it easy to read a function signature and know what it does to its inputs.
fn scale(a: integer, factor: integer) -> integer {
a * factor
}
Type-Based Dispatch
You can define two functions with the same name as long as their parameter types differ. Loft picks the right one at compile time based on the type of the argument you pass.
fn describe_int(v: integer) -> text {
"integer:{v}"
}
fn describe_text(v: text) -> text {
"text:{v}"
}
Function References
fn <name> creates a reference to a named function that you can store or pass around. The compiler checks that the name exists and is a function — a typo is a compile error. The result has type fn(param_types) -> return_type and can be: - stored in a variable, - called directly with f(args), and - passed as a parameter to another function.
fn double_it(x: integer) -> integer {
x * 2
}
fn negate_it(x: integer) -> integer {
- x
}
fn apply_fn(f: fn(integer) -> integer, x: integer) -> integer {
f(x)
}
Lambda Expressions
A lambda is an anonymous (unnamed) function written right where you need it. Use the full form when you want to be explicit about types: fn(param: type) -> return_type { body } Use the short form (pipe-bar syntax) when the call site already knows the types: |param| { body } Both forms work anywhere a function reference is expected — especially with map, filter, and reduce. The short form does not allow type annotations; use the full fn(…) form when you need explicit types. Lambdas that need outer variables can capture them: see the Closures page.
fn main() {
Passing both arguments explicitly overrides the default.
assert(greet("World", "Hi") == "Hi, World!", "Explicit argument");
Leaving out the second argument causes Loft to use the default "Hello".
assert(greet("World") == "Hello, World!", "Default argument");
add takes a by reference, so every call updates the original variable v. Watch how v accumulates: 1 -> 3 -> 8.
v = 1;
add(v, 2);
v = 1 + 2 = 3
add(v, 4, 1);
v = 3 + 4 + 1 = 8
assert(v == 8, "Reference parameter: {v}");
classify uses early returns to handle each case separately.
assert(classify(-5) == "negative", "Classify negative");
assert(classify(0) == "zero", "Classify zero");
assert(classify(3) == "positive", "Classify positive");
scale marks both parameters const, so the compiler verifies they are not changed.
assert(scale(3, 7) == 21, "scale(3,7)");
Loft selects the right function based on the type of the argument.
assert(describe_int(42) == "integer:42", "Integer describe");
assert(describe_text("hi") == "text:hi", "Text describe");
A function reference is stored in a variable with type fn(...) -> .... Calling it looks exactly like a regular function call.
f = double_it;
assert(f(5) == 10, "fn-ref stored and called: {f(5)}");
Pass function references as arguments to higher-order functions.
assert(apply_fn(double_it, 7) == 14, "fn-ref as arg (double)");
assert(apply_fn(negate_it, 3) == -3, "fn-ref as arg (negate)");
Lambdas with map, filter, and reduce. Use |...| short form — types are inferred from the call site.
nums = [1, 2, 3, 4, 5];
doubled = map(nums, |x| { x * 2 });
assert(doubled[0] == 2, "lambda map first element");
assert(doubled[4] == 10, "lambda map last element");
filter keeps only elements where the lambda returns true.
evens = filter(nums, |x| { x % 2 == 0 });
assert(evens[0] == 2, "filter evens first");
assert(evens[1] == 4, "filter evens second");
reduce collapses the whole list into one value. The lambda receives the running total (acc) and the next element (x).
total = reduce(nums, 0, |acc, x| { acc + x });
assert(total == 15, "reduce sum: {total}");
Use fn(...) long form when you need explicit types — for example when storing a lambda in a variable or passing it without call-site context.
negate = fn(x: integer) -> integer { -x };
assert(negate(7) == -7, "stored lambda: {negate(7)}");
assert(apply_fn(|x| { x * x }, 6) == 36, "lambda as arg: 6^2");
--- Named arguments ---
assert(connect("example.com") == "example.com:8080:true", "all defaults");
assert(connect("example.com", tls: false) == "example.com:8080:false", "skip port");
assert(connect(host: "example.com", tls: false) == "example.com:8080:false", "all named");
assert(connect("example.com", port: 443, tls: false) == "example.com:443:false", "mixed");
assert(greet(name: "World") == "Hello, World!", "named with default greeting");
--- Default argument that depends on another argument --- end defaults to -1 (sentinel); inside the body it becomes len(t).
assert(substring("hello", 1, 3) == "el", "explicit start and end");
assert(substring("hello", 2) == "llo", "end defaults to len(t)");
assert(substring("hello") == "hello", "both defaults");
}