Closures and Anonymous Functions: The Glue That Holds Luma Together

Closures and Anonymous Functions: The Glue That Holds Luma Together

February 14, 2026·
Luma Core Team

Most languages have closures. Few make them feel like they belong.

In a lot of languages, anonymous functions are bolted on. They require special keywords, awkward syntax, or ceremony that makes them feel like a different dialect from the rest of the code. You end up with function(x) { return x + 1; } sitting inside a method call that was otherwise clean and readable. The cost isn’t just keystrokes — it’s cognitive overhead. Every time you see the syntax, you have to context-switch.

We wanted Luma’s closures to feel invisible. Not because they’re hidden, but because they look like everything else. Today, that vision is complete. Luma now supports zero-parameter, single-parameter, and multi-parameter anonymous functions — all with the same arrow syntax used throughout the language.

Here’s what shipped, and more importantly, why it matters.

What closures actually give you

Before diving into syntax, it’s worth asking a more basic question: why should a language have closures at all?

The answer isn’t “because other languages do.” Closures solve a specific problem — they let you define behavior where you need it, without the overhead of naming and declaring a function elsewhere. And when those anonymous functions can capture variables from their surroundings, they become something more powerful: portable pieces of logic that carry their own context.

This matters in practice for three reasons.

Callbacks. When you pass a function to another function, you often need it to know about something from the calling scope. A button click handler that references a form field. A retry function that knows about a configuration value. Without closures, you’d need to thread that context through parameters or global state.

Data pipelines. Filtering, mapping, and transforming collections is where most developers first encounter closures. The pattern list.filter(x -> x > threshold) is only possible because the lambda captures threshold from the surrounding scope. Without capture, you’d need a separate named function for every threshold value.

Factories. Functions that produce other functions — configured, specialized, ready to use. A make_adder(5) that returns a function adding 5 to its argument. A make_validator("email") that returns a validation function bound to a specific format. Closures make this pattern trivial.

These aren’t academic patterns. They’re how real programs are structured. And Luma now handles all of them cleanly.

The syntax: three forms, one idea

Luma uses the arrow -> to define anonymous functions. The arrow is already familiar from function return types (fn foo() -> int), so it reads naturally in this context too.

Single parameter

The simplest form. No parentheses needed:

x -> x * 2

This is the form you’ll use most often — inside filter, map, and anywhere a function expects a single-argument callback:

nums: [int] = [1, 2, 3, 4, 5, 6]
evens: [int] = nums.filter(x -> x % 2 == 0)
print(evens)   // [2, 4, 6]

Zero parameters

Use empty parentheses when the function takes no arguments:

() -> 42

This form is essential for deferred execution — when you want to describe what should happen without doing it yet:

action: () -> int = () -> 42
print(action())   // 42

Zero-parameter lambdas are the building block for callback patterns where the caller decides when to invoke the action, not the definer.

Multiple parameters

Wrap parameters in parentheses:

(a, b) -> a + b

This is where things get interesting. Multi-parameter lambdas unlock general-purpose higher-order functions:

fn apply(f: (int, int) -> int, a: int, b: int) -> int {
    return f(a, b)
}

result: int = apply((a, b) -> a + b, 3, 4)
print(result)   // 7

All three forms support block bodies for multi-statement logic:

(a, b) -> {
    sum: int = a + b
    return sum * 2
}

That’s it. One arrow, zero to many parameters, expression or block body. No lambda keyword, no function boilerplate, no => vs -> distinction. One syntax for everything.

Why capture matters

A lambda without capture is just a shorter way to write a named function. Useful, but not transformative. Capture is what makes closures closures.

When a lambda references a variable from its enclosing scope, it captures that variable. The variable lives on, accessible to the lambda, even after the scope that created it has ended:

fn make_adder(a: int) -> (int) -> int {
    return b -> a + b
}

add5: (int) -> int = make_adder(5)
add10: (int) -> int = make_adder(10)

print(add5(3))    // 8
print(add10(3))   // 13

Each call to make_adder produces a new closure that remembers its own value of a. The two closures don’t interfere with each other. They’re independent values, like any other data in your program.

This works because Luma captures by reference. The lambda shares the variable with its enclosing scope — not a snapshot, but the actual variable:

count: int = 0
counter: () -> int = () -> {
    count = count + 1
    return count
}

print(counter())   // 1
print(counter())   // 2
print(counter())   // 3

Each call to counter() mutates the same count. This is deliberate — it makes closures useful for stateful patterns like counters, caches, and accumulators without introducing separate mechanisms.

What this unlocks for Luma developers

With closures fully in place, several patterns that were previously awkward or impossible become natural.

Configuration through factories

Instead of passing configuration values through every function call, you create a configured function once and use it everywhere:

fn make_prefixer(prefix: str) -> (str) -> str {
    return name -> "${prefix}${name}"
}

greet: (str) -> str = make_prefixer("Hello, ")
shout: (str) -> str = make_prefixer("HEY ")

print(greet("Luma"))   // Hello, Luma
print(shout("Luma"))   // HEY Luma

One function, infinite variations, no code duplication.

Callbacks and deferred actions

Zero-parameter lambdas make callback patterns straightforward:

fn with_retry(action: () -> str, fallback: str) -> str {
    return action().or(fallback)
}

The caller decides what the action does. The library decides when and how to call it. This separation is the basis of nearly every framework pattern.

Pipelines that read like sentences

When lambdas are lightweight, data pipelines become readable:

names: [str] = ["Alice", "Bob", "Charlie", "Dave"]
result: [str] = names.filter(n -> n.length() > 3).map(n -> n.upper())
print(result)   // [ALICE, CHARLIE, DAVE]

Each step says what it does. No temporary variables, no loop bodies, no index management. The code reads like a description of the transformation.

Trailing lambda sugar

Luma supports a syntactic sugar for the common pattern of passing a lambda as the last argument to a method:

nums: [int] = [1, 2, 3, 4, 5]
evens: [int] = nums.filter(x) -> x % 2 == 0

This is equivalent to nums.filter(x -> x % 2 == 0) but reads slightly more naturally when the lambda body is the focal point. With multi-parameter support, this extends to:

fn reduce(list: [int], f: (int, int) -> int, init: int) -> int {
    // ...
}

The syntax scales because the underlying model is uniform.

Error handling with lambdas

Closures integrate directly with Luma’s .or() error handling:

fn divide(a: int, b: int) -> int {
    if b == 0 {
        error("Division by zero")
    }
    return a / b
}

x: int = divide(10, 0).or(err -> {
    print("Failed: ${err}")
    return -1
})

The lambda captures nothing here — but it could. An error handler that logs to a captured logger, increments a captured error counter, or falls back to a captured default value are all natural patterns.

Why we didn’t add special syntax

Some languages introduce dedicated syntax for different closure use cases. Kotlin has it for single-parameter lambdas. Ruby has blocks, procs, and lambdas as three separate concepts. Swift has $0, $1 shorthand arguments.

We considered and rejected these approaches.

Implicit parameters (it, $0) save a few characters but hide what’s happening. In list.filter { it > 5 }, the reader has to know that it refers to the current element. In list.filter(x -> x > 5), the parameter is right there. Explicitness costs almost nothing and pays back in readability.

Multiple anonymous function types create a “which one do I use?” problem that never goes away. We wanted one concept that works everywhere — as a function argument, as a stored value, as a return value, inside .or(). One syntax, one mental model.

Special block syntax ties anonymous functions to specific calling conventions. In Luma, a lambda is a value. You can store it, pass it, return it, call it. No special rules for “last argument” or “trailing block.” The trailing lambda sugar is purely syntactic — underneath, it’s the same value.

The result is that Luma has exactly one way to write an anonymous function. You learn it once, and it works everywhere.

How it works under the hood

For the curious: Luma compiles to Go, and closures map directly to Go’s anonymous function literals.

A lambda like x -> x + 1 in a context expecting (int) -> int compiles to:

func(x int) int { return x + 1 }

A zero-parameter lambda () -> 42 expecting () -> int compiles to:

func() int { return 42 }

A multi-parameter lambda (a, b) -> a + b expecting (int, int) -> int compiles to:

func(a int, b int) int { return a + b }

Variable capture works automatically because Go handles it natively. When a Luma lambda references a variable from its enclosing scope, the compiled Go closure captures it the same way. No extra runtime machinery, no heap allocation tricks, no GC pressure. The compiled output is exactly what a Go developer would write by hand.

The compiler inspects the expected function type from context — the parameter’s declared type, the variable’s declared type, or the method signature — and generates a precisely typed Go function literal. This means Luma closures have zero overhead compared to handwritten Go code.

What we didn’t add

Partial application. Calling add(1) to get a function that adds 1 isn’t supported yet. You can achieve the same thing with x -> add(1, x), which is explicit and clear. True partial application may come later if real-world usage shows it would reduce boilerplate significantly.

Closure types with named fields. Some languages let you inspect or serialize closures. Luma closures are opaque values — you can call them, but you can’t look inside. This keeps the model simple and the compilation straightforward.

Async closures. Luma doesn’t have an async model yet, so there’s nothing to integrate with. When concurrency arrives, closures will be part of the design from the start.

The story so far

This feature completes a journey that started months ago with “Breaking the Lambda Curse” — the blog post about rebuilding our parser to handle even basic single-parameter lambdas correctly. Since then, we’ve gone from x -> x + 1 as our only anonymous function form to a complete closure system.

Feature Syntax Example
Single-param lambda x -> expr x -> x * 2
Zero-param lambda () -> expr () -> 42
Multi-param lambda (a, b) -> expr (a, b) -> a + b
Block body x -> { stmts } x -> { return x * 2 }
Stored in variable name: Type = lambda f: (int) -> int = x -> x + 1
Passed as argument fn foo(f: Type) apply(x -> x + 1, 5)
Returned from function -> (Type) -> Type return b -> a + b
Variable capture References outer scope () -> count
Trailing lambda method(params) -> body nums.filter(x) -> x > 0
Error handling .or(err -> expr) val.or(err -> -1)

Closures are the connective tissue of modern programs. They’re how you express “do this later,” “do this for each element,” “do this if something goes wrong,” and “make me a function that does this.” With all three parameter forms now in place, Luma developers have the full toolkit.

No ceremony. No special cases. Just arrows.

Last updated on