Functions
Functions are first-class values in Suji that can be passed around, stored, and returned.
Overview
Functions encapsulate reusable logic and are treated as values like numbers or strings. This page covers functions as a data type: how to create them, what a function value can do, and how they interact with the rest of the language. For deeper treatment of individual topics see Higher-Order Functions, Closures, Recursion and Multiple Return Values.
Key Characteristics
- Lambda syntax only -
name = |a, b| …. There is nofn,def,funcorfunctionkeyword; a function is just a value you bind to a variable. - First-class - Functions can be stored in lists and maps, passed as arguments, and returned from other functions
- Closures - Capture the enclosing scope by reference, and can mutate what they capture
- Default parameter values -
|a, b = 10| … - Multiple return values - Using tuples
- No variadics, no keyword arguments - Take a list or a map instead
When to Use Functions
Use functions for:
- Reusable logic
- Abstraction and composition
- Callbacks and event handlers
- Data transformations
- Building DSLs
Syntax
Function Definition
A function value is written with pipes around the parameter list, followed by
either a single expression or a { … } block.
import std:println
# Basic function
greet = |name| {
"Hello, ${name}!"
}
# Multiple parameters
add = |a, b| {
a + b
}
# No parameters
answer = || {
42
}
# Single expression (no braces needed)
double = |x| x * 2
println(greet("Alice")) # Hello, Alice!
println(add(2, 3)) # 5
println(answer()) # 42
println(double(21)) # 42
Because a function is an ordinary value, it has the usual type predicate and can be stored anywhere:
import std:println
double = |x| x * 2
println(double::is_function()) # true
# Functions in a map, used as a dispatch table
ops = {"double": double, "negate": |x| 0 - x}
println(ops:double(7)) # 14
println(ops:negate(7)) # -7
Function Calls
import std:println
greet = |name| "Hello, ${name}!"
# Call function
message = greet("Alice")
println(message) # Hello, Alice!
# Direct call
println(greet("Bob")) # Hello, Bob!
Arity is checked at call time: passing the wrong number of arguments raises Arity mismatch: Function expects N arguments, got M and stops the program.
There are two pipe-apply operators for feeding a value into a function, which often reads better than nesting calls — see Pipe Apply:
import std:println
double = |x| x * 2
println(5 |> double) # 10 (forward)
println(double <| 5) # 10 (backward)
Parameters
Required Parameters
import std:println
calculate = |x, y, operation| {
match operation {
"add" => x + y,
"subtract" => x - y,
"multiply" => x * y,
"divide" => x / y,
_ => 0,
}
}
println(calculate(10, 5, "add")) # 15
println(calculate(10, 5, "multiply")) # 50
Default Values
A parameter can declare a default, which applies when the argument is omitted:
import std:println
greet = |name, title = "Mr./Ms."| "${title} ${name}"
println(greet("Alice", "Dr.")) # Dr. Alice
println(greet("Bob")) # Mr./Ms. Bob
Defaults only fill in missing arguments. Passing nil explicitly passes nil;
if you want nil to mean “use the default”, check for it in the body.
Variable Arguments
Suji has no variadic parameters and no keyword arguments. Accept a list when the count varies, or a map when you want named options:
import std:println
# Using a list for a variable number of values
sum_all = |numbers| numbers::fold(0, |acc, x| acc + x)
println(sum_all([1, 2, 3, 4, 5])) # 15
# Using a map for named options, with get() supplying defaults
render = |text, options| {
prefix = options::get("prefix", "- ")
upper = options::get("upper", false)
body = match upper {
true => text::upper(),
_ => text,
}
"${prefix}${body}"
}
println(render("hello", {})) # - hello
println(render("hello", {"upper": true})) # - HELLO
Return Values
Explicit Return
import std:println
find_first = |list, predicate| {
loop through list with item {
predicate(item) && return item # Early return
}
nil # Default return
}
numbers = [1, 2, 3, 4, 5]
println(find_first(numbers, |x| x > 3)) # 4
Implicit Return
Last expression is returned:
import std:println
add = |a, b| {
a + b # Returned automatically
}
println(add(3, 4)) # 7
Multiple Return Values
Use tuples:
import std:println
divide_with_remainder = |a, b| {
quotient = (a / b)::floor()
remainder = a % b
return (quotient, remainder)
}
q, r = divide_with_remainder(17, 5)
println("${q} remainder ${r}") # 3 remainder 2
Two details worth remembering: destructuring targets are written without
parentheses (q, r = …), and the tuple is returned with an explicit return
because a line that starts with ( would otherwise be parsed as a call on the
previous line’s value. return a, b builds the tuple for you as well.
Closures
Functions capture variables from their surrounding scope:
import std:println
make_adder = |x| {
|y| x + y # Captures x
}
add_5 = make_adder(5)
add_10 = make_adder(10)
println(add_5(3)) # 8 (5 + 3)
println(add_10(3)) # 13 (10 + 3)
Closure Example
Captured variables are shared by reference, so a closure can keep mutable state.
Note the explicit return: a line beginning with || would otherwise be read as
a logical-or continuing the previous line.
import std:println
make_counter = || {
count = 0
return || {
count = count + 1
count
}
}
counter = make_counter()
println(counter()) # 1
println(counter()) # 2
println(counter()) # 3
Higher-Order Functions
Functions that accept or return functions:
Functions as Parameters
import std:println
apply_twice = |fn, x| {
fn(fn(x))
}
double = |x| x * 2
println(apply_twice(double, 3)) # 12 (double(double(3)))
increment = |x| x + 1
println(apply_twice(increment, 5)) # 7
Functions as Return Values
import std:println
multiplier = |factor| {
|x| x * factor
}
times_2 = multiplier(2)
times_10 = multiplier(10)
println(times_2(5)) # 10
println(times_10(5)) # 50
Common Patterns
Partial Application
There is no partial-application syntax; return a lambda from a lambda instead:
import std:println
greet_with = |greeting| {
|name| "${greeting}, ${name}!"
}
hello = greet_with("Hello")
hi = greet_with("Hi")
println(hello("Alice")) # Hello, Alice!
println(hi("Bob")) # Hi, Bob!
Function Composition
import std:println
compose = |f, g| {
|x| f(g(x))
}
add_1 = |x| x + 1
times_2 = |x| x * 2
# (x + 1) * 2
add_then_multiply = compose(times_2, add_1)
println(add_then_multiply(5)) # 12
# (x * 2) + 1
multiply_then_add = compose(add_1, times_2)
println(multiply_then_add(5)) # 11
Suji also has built-in composition operators, so you rarely need to write
compose yourself: f >> g is “f then g” and f << g is “g then f”. See
Composition Operators.
import std:println
add_1 = |x| x + 1
times_2 = |x| x * 2
println((add_1 >> times_2)(5)) # 12
println((add_1 << times_2)(5)) # 11
Currying
import std:println
# Curried function
curry_add = |a| |b| |c| a + b + c
# Partial application
add_1 = curry_add(1)
add_1_2 = add_1(2)
result = add_1_2(3)
println(result) # 6
# Or all at once
println(curry_add(1)(2)(3)) # 6
Memoization
import std:println
memoize = |fn| {
cache = {}
return |arg| {
match cache::contains(arg) {
true => cache::get(arg),
false => {
result = fn(arg)
cache[arg] = result
result
}
}
}
}
# Example: memoize a function and avoid repeating work
calls = 0
slow_square = |n| {
calls = calls + 1
n * n
}
fast_square = memoize(slow_square)
println(fast_square(5)) # 25
println(fast_square(5)) # 25
println(calls) # 1
Recursion
Functions can call themselves:
import std:println
# Factorial
factorial = |n| {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
println(factorial(5)) # 120
# Fibonacci
fib = |n| {
match n {
0 => 0,
1 => 1,
_ => fib(n - 1) + fib(n - 2),
}
}
println(fib(10)) # 55
Recursion Depth
Suji does not optimise tail calls. Every recursive call is a real stack frame, and somewhere around 600–700 frames deep the process aborts with a stack overflow. Writing a call in tail position does not change that:
import std:println
# "Tail-recursive" in shape, but still one frame per call
factorial = |n| {
helper = |n, acc| {
match n {
0 => acc,
_ => helper(n - 1, n * acc),
}
}
helper(n, 1)
}
println(factorial(5)) # 120
For anything that could run deep, use a loop instead of recursion:
import std:println
sum_to = |n| {
total = 0
loop through 1..=n with i {
total += i
}
total
}
println(sum_to(100000)) # 5000050000
Lambda Expressions
Short anonymous functions:
import std:println
# In map
numbers = [1, 2, 3, 4, 5]
squares = numbers::map(|x| x * x)
println(squares) # [1, 4, 9, 16, 25]
# In filter
evens = numbers::filter(|x| x % 2 == 0)
println(evens) # [2, 4]
# In fold (the method is fold, not reduce)
sum = numbers::fold(0, |acc, x| acc + x)
println(sum) # 15
Common Pitfalls
Pitfall 1: Forgetting to Call Function
import std:println
greet = |name| "Hello, ${name}!"
# This is the function value, not the result
result = greet
println(result::is_function()) # true
# Call the function
result = greet("Alice")
println(result) # Hello, Alice!
Pitfall 2: Closure Variable Capture
Closures capture the enclosing scope by reference, so later changes to a captured variable are visible inside the closure:
import std:println
factor = 2
scale = |x| x * factor
println(scale(5)) # 10
# Reassigning the captured variable changes what the closure computes
factor = 10
println(scale(5)) # 50
Loop bindings, on the other hand, are fresh each iteration, so closures created in a loop each keep their own value — the “all my callbacks see the last index” bug from other languages does not happen here:
import std:println
functions = []
loop through [1, 2, 3] with i {
functions::push(|| i)
}
println(functions::first()()) # 1
println(functions::last()()) # 3
Pitfall 3: Missing Base Case
There is no way to catch a stack overflow, so a runaway recursion kills the process:
import std:println
# No base case - would abort with a stack overflow:
# countdown = |n| {
# println(n)
# countdown(n - 1)
# }
# Always have a base case
countdown = |n| {
n <= 0 && return nil
println(n)
countdown(n - 1)
}
countdown(3)
# 3
# 2
# 1
Best Practices
DO:
- Use descriptive function names (verbs)
- Keep functions small and focused
- Use lambdas for simple transformations
- Leverage closures when appropriate
- Document complex functions
DON’T:
- Create overly long functions
- Mix concerns in one function
- Forget base cases in recursion
- Capture mutable state carelessly
- Ignore function return values
Examples
Map-Filter-Fold Pipeline
import std:println
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
result = numbers
::filter(|x| x % 2 == 0) # [2, 4, 6, 8, 10]
::map(|x| x * x) # [4, 16, 36, 64, 100]
::fold(0, |acc, x| acc + x) # 220
println(result)
Function Builder
import std:println
operation = |op| {
match op {
"add" => |a, b| a + b,
"subtract" => |a, b| a - b,
"multiply" => |a, b| a * b,
"divide" => |a, b| a / b,
_ => |a, b| 0,
}
}
add = operation("add")
multiply = operation("multiply")
println(add(5, 3)) # 8
println(multiply(5, 3)) # 15
Retry Logic
import std:println
retry = |fn, max_attempts| {
attempt = 1
loop {
result = fn()
# Bind the condition first: a line starting with "(" would be parsed
# as a call on the previous line's value.
done = result != nil || attempt >= max_attempts
done && return result
attempt++
}
}
# Simulated flaky function: fails twice, then succeeds
tries = 0
flaky = || {
tries++
match {
tries < 3 => nil,
_ => "ok",
}
}
println(retry(flaky, 5)) # ok
println(tries) # 3
loop always evaluates to nil and break <value> is not supported, so an
explicit return from inside the loop is how you hand a result back.
Next Steps
- Learn about Higher-Order Functions
- Explore Closures in detail
- Study Composition Operators
- Check out Recursion patterns