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
A default value can be any expression, not just a literal — including one that references earlier parameters, which are in scope by the time their default is evaluated. So end can default directly to the length of the earlier t.
fn substring(t: text, start: integer = 0, end: integer = len(t)) -> text {
t[start..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 caller's variable and can read or write it. Without &, a number, boolean or text is copied in, so reassigning it inside the function stays local. A struct or vector parameter still shares its contents with the caller: a field or element write inside the function reaches the caller too. 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
Write const with the TYPE, not before the name — fn f(s: const text), not fn f(const s: text), which does not parse. It tells the compiler "this function must never change this value", and the compiler enforces it: assigning to a const parameter is a compile error ("Cannot modify const parameter"). & is the opposite promise: "this function will modify this value." Declaring & without actually writing to the parameter is also a compile error ("Parameter a has & but is never modified; remove the &"). These rules make it easy to read a function signature and know what it does to its inputs.
const earns its keep on a parameter the function COULD write through — a text, a vector, a struct. On a primitive you never modify it is reported as needless (needless-const-parameter), because a plain integer parameter is already a copy and const adds nothing to it.
fn shout(s: const text) -> text {
s + "!"
}
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
Writing a function's NAME on its own creates a reference to it that you can store or pass around — f = double_it, with no fn in front. (fn double_it is refused: "Use the function name directly, without fn prefix".) The compiler checks that the name exists — 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 uses plain parameters (no const, no &): it only reads them to compute the product, so the caller's values are never touched.
assert(scale(3, 7) == 21, "scale(3,7)");
shout promises not to change its text, and the compiler holds it to that.
assert(shout("hi") == "hi!", "const parameter: {shout(\"hi\")}");
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 len(t), computed from the earlier t parameter.
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");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Functions
// @TITLE: User defined functions
// 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
// A default value can be any expression, not just a literal — including one that
// references earlier parameters, which are in scope by the time their default is
// evaluated. So 'end' can default directly to the length of the earlier 't'.
fn substring(t: text, start: integer = 0, end: integer = len(t)) -> text {
t[start..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 caller's variable and can read or write it.
// Without '&', a number, boolean or text is copied in, so reassigning it inside the
// function stays local. A struct or vector parameter still shares its contents with
// the caller: a field or element write inside the function reaches the caller too.
// 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
// Write 'const' with the TYPE, not before the name — 'fn f(s: const text)', not
// 'fn f(const s: text)', which does not parse. It tells the compiler "this function
// must never change this value", and the compiler enforces it: assigning to a const
// parameter is a compile error ("Cannot modify const parameter").
// '&' is the opposite promise: "this function will modify this value."
// Declaring '&' without actually writing to the parameter is also a compile error
// ("Parameter 'a' has & but is never modified; remove the &").
// These rules make it easy to read a function signature and know what it does to its inputs.
//
// 'const' earns its keep on a parameter the function COULD write through — a text, a
// vector, a struct. On a primitive you never modify it is reported as needless
// ('needless-const-parameter'), because a plain 'integer' parameter is already a copy
// and const adds nothing to it.
fn shout(s: const text) -> text {
s + "!"
}
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
// Writing a function's NAME on its own creates a reference to it that you can store or
// pass around — 'f = double_it', with no 'fn' in front. ('fn double_it' is refused:
// "Use the function name directly, without 'fn' prefix".)
// The compiler checks that the name exists — 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' uses plain parameters (no 'const', no '&'): it only reads them to
// compute the product, so the caller's values are never touched.
assert(scale(3, 7) == 21, "scale(3,7)");
// 'shout' promises not to change its text, and the compiler holds it to that.
assert(shout("hi") == "hi!", "const parameter: {shout(\"hi\")}");
// 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 len(t), computed from the earlier 't' parameter.
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");
}