Functions
In Hot, everything is a Var/Value pair—function names are Vars, and function definitions are Values.
Defining Functions
Use fn followed by parameters and a body:
greet fn (name: Str): Str {
`Hello, ${name}!`
}
add-numbers fn (a: Int, b: Int): Int {
add(a, b)
}
greet("World") → "Hello, World!"
add-numbers(5, 3) → 8
A parameter is a named input declared by a function. An argument is an
expression supplied in a call. In greet fn (name: Str): Str { name }, name is
a parameter; in greet("Alice"), "Alice" is an argument.
The last expression in the body is the return value—no return keyword needed.
Functions are Flows
The fn keyword modifies a flow to turn it into a function definition. The default flow is serial, which can be omitted:
// These are equivalent:
greet-v1 fn (name: Str): Str { `Hello, ${name}!` }
greet-v2 fn serial (name: Str): Str { `Hello, ${name}!` }
You can use other flow types to change how the function body executes:
// Conditional function using cond flow
is-admin fn cond (user): Bool {
eq(user.role, "admin") => { true }
=> { false }
}
See Flows for more on serial, parallel, cond, and cond-all.
Calling Functions
Call functions with arguments in parentheses:
result-greet greet("World") // "Hello, World!"
sum add-numbers(5, 3) // 8
A call never has a space before (. With a space, name (...) binds a
variable named name to the value in the parentheses, wherever it appears.
So assert (x) and if (x) { ... } bind variables named assert and if
instead of calling those functions. hot check warns with
binding-shadows-function when such a binding shadows a function.
A parameter or local binding shadows any function with the same name inside its body, so a higher-order function can name its function parameter freely:
scale fn (x: Int): Int { mul(x, 2) }
apply fn (x: Int, scale: Fn): Int { scale(x) } // calls the parameter
apply(3, add(%, 1)) // 4
A lazy parameter is the exception. It holds a deferred argument, so calling
its name still calls the function.
Qualified Calls
Call functions from other namespaces using the full path:
upper ::hot::str/uppercase("hello") // "HELLO"
data ::hot::http/get("https://api.example.com")
Or create a function alias:
uppercase ::hot::str/uppercase
result uppercase("hello") // "HELLO"
Or create a namespace alias:
::str ::hot::str
result ::str/uppercase("hello") // "HELLO"
Parameter Types
Types are optional but recommended:
// Fully typed
greet-typed fn (name: Str): Str {
`Hello, ${name}!`
}
// Untyped (accepts anything)
echo fn (x) {
x
}
// Mixed
process fn (data: Map, options): Map {
data
}
Nullable Parameters
Use ? for types that accept null (shorthand for Type | Null):
greet-titled fn (name: Str, title: Str?): Str {
if(title,
`Hello, ${title} ${name}!`,
`Hello, ${name}!`)
}
greet-titled("Alice", null) → "Hello, Alice!"
greet-titled("Smith", "Dr") → "Hello, Dr Smith!"
Parameter Constraints
A parameter's type, the parameter list, and the return type can each take a
where clause. A failing argument returns a ConstraintError before the body
runs:
withdraw fn (amount: Int, balance: Int)
where lte(%.amount, %.balance)
: Int where gte(%, 0)
{
sub(balance, amount)
}
See Constraints.
Long Parameter Lists
A parameter list can span lines, like a call's arguments. A line can hold one
parameter or several, and the return type and { can follow the closing )
on the same line. A trailing comma after the last parameter is optional:
format-price fn (
amount: Dec,
currency: Str,
symbol: Str?,
): Str {
if(symbol, `${symbol}${amount}`, `${amount} ${currency}`)
}
format-price(9.50, "USD", "$") → "$9.50"
format-price(9.50, "EUR", null) → "9.50 EUR"
A lambda's parameter list can span lines too. Its { must be on the same line
as its closing ):
order-total reduce(
[{price: 5, qty: 2}, {price: 3, qty: 1}],
(
sum: Int,
item: Map,
): Int {
add(sum, mul(item.price, item.qty))
},
0,
)
order-total → 13
Function Overloading
Define multiple versions of a function with different parameter counts (arity):
slice fn
(coll: Vec, start: Int): Vec {
// slice from start to end (stub)
coll
},
(coll: Vec, start: Int, end: Int): Vec {
// slice from start to end (stub)
coll
}
Or different parameter types:
process-data fn
(x: Int): Str { `Integer: ${x}` },
(x: Str): Str { `String: ${x}` },
(x: Vec): Str { `Vector with ${length(x)} items` }
Hot dispatches to the correct version based on arguments.
When overloads of the same arity differ in which parameters are lazy, only literal arguments choose between them. A call defers its arguments before their types are known.
Variadic Functions
A variadic parameter declared with ... accepts any number of arguments:
concat-all fn (first-vec: Vec, ...rest: Vec): Vec {
reduce(rest, (acc, v) { concat(acc, v) }, first-vec)
}
Lambdas (Anonymous Functions)
Create inline functions with (params) { body }:
doubled map([1, 2, 3], (x) { mul(x, 2) })
sum-lambda reduce([1, 2, 3], (acc, x) { add(acc, x) }, 0)
map([1, 2, 3], (x) { mul(x, 2) }) → [2, 4, 6]
reduce([1, 2, 3], (acc, x) { add(acc, x) }, 0) → 6
Lambdas are just values—assign them to vars:
double-fn (x) { mul(x, 2) }
result-double double-fn(5) // 10
Placeholder Lambdas (%)
For single-parameter lambdas, use % as shorthand. Hot automatically wraps the expression in a lambda:
// These are equivalent:
map([1, 2, 3], (x) { mul(x, 2) })
map([1, 2, 3], mul(%, 2))
// Property access:
filter(users, (u) { u.active })
filter(users, %.active)
// In pipelines:
names |> map(%.email) |> filter(ends-with(%, "@company.com"))
For multi-parameter lambdas, use %1, %2, etc. (bare % is the same as %1):
reduce([1, 2, 3], add(%1, %2), 0) // 6
Where % Binds
A % binds to the nearest enclosing argument whose parameter is a function (Fn). Calls without a function parameter are transparent, so these need no extra syntax:
sort(map(items, %.value)) // the lambda wraps at map's function argument
length(filter(items, gt(%, 3))) // the lambda wraps at filter's function argument
If no enclosing argument takes a function, the % is a compile error.
An implicit % lambda can't contain another one. In map(groups, filter(%, gt(%, 0))) the first % would be each group and the second each element of a group, so Hot rejects it with a nested-placeholder error. Write the inner function explicitly:
map(groups, filter(%, %(gt(%, 0))))
map(groups, filter(%, (n) { gt(n, 0) }))
Explicit Lambda Boundary: %(expr)
%(expr) creates a lambda exactly where you write it, and every % inside it is that lambda's parameter. Use it for a lambda value where no argument takes a function, or for an inner function as above:
square %(mul(%, %))
map([1, 2, 3], square) // [1, 4, 9]
Use explicit (params) { body } when:
- The lambda has multiple statements
- The parameter is unused (side-effect only)
- Clarity is more important than brevity
Lazy Arguments
Ordinary function arguments are evaluated before the function body runs.
When a parameter is marked lazy, its argument expression is deferred until
the function forces it with do. This enables short-circuit evaluation:
if fn
cond (pred: Result, lazy then: Any): Any {
pred => { do then }
},
cond (pred: Result, lazy then: Any, lazy else: Any): Any {
pred => { do then }
=> { do else }
}
Use do to evaluate a lazy argument:
safe-access fn (data, lazy fallback) {
if(data, data, do fallback)
}
This is how if, and, and or avoid evaluating unused branches.
Lazy arguments are not memoized. Each do forces the deferred computation
again. Bind the result of do if it will be used more than once, especially
when the argument performs I/O or another side effect:
use-twice fn (lazy value: Any): Vec {
forced do value
[forced, forced]
}
This argument-level laziness is separate from Iter: a lazy parameter defers
one expression, while an iterator produces sequence elements on demand.
Laziness changes only when an argument is evaluated, not how a Result is
handled. To receive a Result without consuming it, declare the parameter
Result.
Result Parameters
A parameter declared Result receives its argument as it is, an Err
included. Any other parameter consumes it: an Ok unwraps, and an Err halts
before the body runs. This holds however the function is called, directly or
through a higher-order function such as map. if declares its condition
this way, so an Err condition is falsy. See
Accepting a Result.
Metadata on Functions
Add documentation, test markers, or event handlers:
// Documentation
greet meta {doc: "Greets a user by name"}
fn (name: Str): Str {
`Hello, ${name}!`
}
// Test function
test-greet meta ["test"]
fn () {
assert-eq(greet("World"), "Hello, World!")
}
// Event handler
on-user-created meta {on-event: "user:created"}
fn (event) {
send-welcome-email(event.data.email)
}
// Scheduled function
daily-cleanup meta {schedule: "@daily"}
fn (event) {
cleanup-old-records()
}
Core Functions
These functions are available everywhere without imports. See hot-std for full documentation.
Math: add, sub, mul, div, mod, pow, round, floor, ceil, rand
Comparison: eq, ne, lt, gt, lte, gte
Logic: if, and, or, not, is-truthy
Collections (eager): map, filter, reduce, first, rest, last, length, concat, flatten, merge, keys, vals, some, all, range, sort, reverse, distinct, slice
Iterators (lazy): Iter, next, collect, for-each, take, range
Strings: uppercase, lowercase, trim, split, join, starts-with, ends-with, contains, replace
Results: ok, err, is-ok, is-err, Result
Types: Str, Int, Dec, Bool, Vec, Map, Any, Null, is-null, is-some
Tail Call Optimization (TCO)
Hot automatically optimizes tail-recursive functions, enabling stack-safe recursion for any depth.
A call is in tail position when its result is returned directly without further processing:
factorial fn cond (n: Int, acc: Int): Int {
lte(n, 1) => { acc }
=> { factorial(sub(n, 1), mul(n, acc)) }
}
factorial(5, 1) → 120
factorial(1, 1) → 1
Use the accumulator pattern to make functions tail-recursive:
// NOT tail-recursive (result passed to add, not returned)
sum fn (xs: Vec): Int {
if(is-empty(xs), 0, add(first(xs), sum(rest(xs))))
}
// Tail-recursive with accumulator (stack-safe)
sum fn (xs: Vec): Int { sum-acc(xs, 0) }
sum-acc fn cond (xs: Vec, acc: Int): Int {
is-empty(xs) => { acc }
=> { sum-acc(rest(xs), add(acc, first(xs))) }
}
Summary
fnmodifies a flow to become a function (default isserial, omittable)- Use
fn condfor conditional functions,fn parallelfor concurrent execution - Call with no space before
(:func(args) - Parameters and local bindings shadow functions with the same name
- Overload by arity or type
- Use lambdas
(x) { body }for inline functions, or%for concise single-param lambdas - Mark parameters
lazyto defer evaluation of their arguments - Tail-recursive functions are automatically optimized (TCO)