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

  • fn modifies a flow to become a function (default is serial, omittable)
  • Use fn cond for conditional functions, fn parallel for 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 lazy for deferred evaluation
  • Tail-recursive functions are automatically optimized (TCO)