Lesson 03: Functions, Closures & Pipelines
In this lesson, you will learn how to structure reusable logic in Tinsel using named functions (fn), parameter destructuring, default arguments, anonymous lambdas (->), lexical closures, and the forward pipeline operator (|>).
1. Learning Objectives
By the end of this lesson, you will be able to:
- Define named functions (
fn) with destructured parameters and default parameter values. - Write concise arrow lambdas (
x -> ...) and multi-statement block lambdas. - Capture enclosing state using lexical closures.
- Chain data transformations left-to-right with the
|>pipeline operator and_placeholders.
2. Named Functions (fn), Destructuring & Default Arguments
Functions are declared with fn. A function body is a block expression whose final trailing expression is returned automatically (or you can exit early with return):
fn clamp_val(x, low = 0, high = 100) {
if x < low {
return low
}
if x > high {
high
} else {
x
}
}
assert(clamp_val(50) == 50)
assert(clamp_val(-10) == 0)
assert(clamp_val(250, 0, 200) == 200)
Parameter Pattern Destructuring
Function parameters can destructure tuples, arrays, or structs directly in the signature:
fn taxicab((x1, y1), (x2, y2) = (0, 0)) {
abs(x1 - x2) + abs(y1 - y2)
}
assert(taxicab((3, 4), (1, -2)) == 8)
assert(taxicab((5, -12)) == 17)
3. Anonymous Lambdas (-> and fn(...))
Functions are first-class values in Tinsel. You can write anonymous functions using arrow syntax (->) or fn(...) { ... }:
// Single parameter (parentheses optional)
let square = x -> x * x
assert(square(9) == 81)
// Multiple parameters with default value
let scale_add = (x, factor = 10) -> x * factor + 1
assert(scale_add(4) == 41 && scale_add(4, 2) == 9)
// Destructuring a tuple parameter inside a lambda (note the double parentheses)
let pairs = [(2, 3), (4, 5), (6, 7)]
let products = pairs.map(((a, b)) -> a * b)
assert(products == [6, 20, 42])
// Multi-statement block lambda
let parse_and_double = s -> {
let n = s.trim().to_int()
n * 2
}
assert(parse_and_double(" 35 ") == 70)
You can inspect the number of parameters a function or closure expects at runtime using .arity():
let f = (a, b, c) -> a + b + c
assert(f.arity() == 3)
4. Lexical Closures & Upvalues
A closure captures variables from its enclosing lexical scope—even after the outer function has returned:
fn make_counter(start = 0, step = 1) {
let mut current = start
() -> {
current += step
current
}
}
let next_id = make_counter(100, 5)
assert(next_id() == 105)
assert(next_id() == 110)
assert(next_id() == 115)
Recursive Local Closures
To write a recursive closure assigned to a local variable (for example, when wrapping a recursive function with memoize), declare the variable with let mut first so its binding is in scope inside the closure body:
let mut factorial = nil
factorial = n -> if n <= 1 { 1 } else { n * factorial(n - 1) }
assert(factorial(6) == 720)
5. The Forward Pipeline Operator (|>)
Nested calls like sum(filter(parse_ints(text), x -> x > 0)) force you to read code from the inside out. The pipeline operator |> rewrites transformations into a left-to-right data flow:
x |> fevaluatesf(x).x |> f(a, b)insertsxas the first argument:f(x, a, b).x |> f(a, _, b)substitutesxinto the_placeholder position:f(a, x, b).
from "std/array" import filter, map as arr_map
let raw = "readings: 12, -4, 30, -9, 18"
let total = raw
|> parse_ints
|> filter(x -> x > 0)
|> arr_map(x -> x * 2)
|> sum
assert(total == 120)
// Using the `_` placeholder to pipe into a specific argument slot:
fn divide(num, den) { num / den }
let res = 5 |> divide(100, _) // divide(100, 5)
assert(res == 20)
6. Common Pitfalls & Gotchas
Gotcha 1: Single Tuple Argument vs. Two Arguments in Arrow Lambdas
(x, y) -> x + y defines a 2-argument lambda. If you are passing a callback to .map() over an array of 2-element tuples [(1, 2), (3, 4)], each element is a single tuple, so wrap the tuple pattern in outer parentheses: ((x, y)) -> x + y or [x, y] -> x + y.
Gotcha 2: Lambda Precedence (-> vs. |>)
-> has lower precedence (binding power 10) than |> (binding power 20). When passing a lambda inside a pipeline call (arr |> filter(x -> x > 0)), the parentheses of filter(...) naturally group the lambda. If writing a standalone lambda that uses |>, x -> x |> f |> g means x -> ((x |> f) |> g).
7. Hands-On Exercises
Exercise 3.1: Pipeline Word Filter (Warmup)
Use .words(), filter (from std/array), and len in a |> pipeline to count how many words in "the quick brown fox jumps over the lazy dog" have at least 4 characters.
Solution & Explanation
from "std/array" import filter
let sentence = "the quick brown fox jumps over the lazy dog"
let count = sentence.words()
|> filter(w -> w.len() >= 4)
|> len
assert(count == 5)
println("Words with >= 4 chars:", count)
Exercise 3.2: Function Composition Combinator (Practice)
Write a higher-order function compose_all(fns) that takes an array of 1-argument functions [f1, f2, f3] and returns a single closure that pipes an input x through each function in order.
Solution & Explanation
fn compose_all(fns) {
x -> fns.fold(x, (acc, f) -> f(acc))
}
let transform = compose_all([
x -> x + 3,
x -> x * 4,
x -> x - 2
])
assert(transform(5) == 30) // ((5 + 3) * 4) - 2 = 30
println("Transformed:", transform(5))
Exercise 3.3: Custom Memoization Wrapper (Challenge)
Write a 1-argument memo1(fn) helper using a captured {} map closure, and use it to compute the 35th Fibonacci number in linear time.
Solution & Explanation
fn memo1(f) {
let cache = {}
x -> {
if x in cache {
return cache[x]
}
let val = f(x)
cache[x] = val
val
}
}
let mut fib = nil
fib = memo1(n -> if n <= 1 { n } else { fib(n - 1) + fib(n - 2) })
assert(fib(35) == 9227465)
println("Fib(35):", fib(35))
(Tip: Tinsel's standard library provides a multi-argument memoize in std/memo!)
Next Steps
In Lesson 04: Custom Structs, Options & Pattern Matching, you will learn how to model domain types with struct, handle optional values with Some/None (? and !), and dispatch cleanly with match expressions.