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
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 parentheses:
result-greet greet("World") // "Hello, World!"
sum add-numbers(5, 3) // 8
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!"
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.
Variadic Functions
Accept any number of arguments with ...:
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
Explicit Lambda Boundary: %(expr)
When % appears inside nested function calls, the implicit lambda wraps at the outermost call boundary. If that's not what you want, use %(expr) to mark exactly where the lambda should be created:
// Without %(expr) — implicit wrapping binds at sort, not map (wrong)
// sort(map(items, %.value))
// With %(expr) — lambda wraps at map (correct)
sort(map(items, %(%.value)))
length(filter(items, %(gt(%, 3))))
Rule of thumb: if % is an argument to a function that is itself an argument to another function, use %(...).
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.
Arguments marked lazy instead arrive as deferred computations. This enables
short-circuit evaluation:
if fn
cond (pred: Any, lazy then: Any): Any {
pred => { do then }
},
cond (pred: Any, 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.
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) - Overload by arity or type
- Use lambdas
(x) { body }for inline functions, or%for concise single-param lambdas - Mark args
lazyfor deferred evaluation - Tail-recursive functions are automatically optimized (TCO)