Skip to content

Decorators

Decorators are annotations that modify the behavior of functions and structs. They use the @name syntax placed before a declaration.

@test #

Marks a function as a test. Test functions are collected and run with zolo test.

@test
fn test_addition() {
    assert_eq(2 + 2, 4, "basic math")
}

@test
fn test_string_concat() {
    let result = "hello" + " " + "world"
    assert_eq(result, "hello world", "string concat")
}

Running Tests #

bash
zolo test my_tests.zolo              # run all tests
zolo test my_tests.zolo --filter fib # only matching tests
zolo test my_tests.zolo --list       # list test names

Test Assertions #

assert_eq(actual, expected, "message")   // assert equality
assert_ne(actual, expected, "message")   // assert inequality

@memoize #

Automatically caches function results. Repeated calls with the same arguments return the cached value instead of recomputing.

@memoize
fn fibonacci(n: int) -> int {
    if n <= 1 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}

// First call computes normally
print(fibonacci(40))  // fast! cached intermediate results

// Subsequent calls with same args are instant
print(fibonacci(40))  // returns from cache

How It Works #

The compiler wraps the function with a cache table. Arguments are serialized as a key, and the result is stored. On repeated calls with the same arguments, the cached result is returned immediately.

Best For #

  • Recursive functions (like fibonacci, tree traversals)
  • Pure functions with expensive computation
  • Functions called repeatedly with the same inputs

Limitations #

  • Only works with serializable arguments
  • Cache grows unbounded (no eviction)
  • Not suitable for functions with side effects

@deprecated #

Marks a function as deprecated. When called, it prints a warning to stderr (once per function).

@deprecated("use new_calculate() instead")
fn old_calculate(x: int) -> int {
    x * 2
}

old_calculate(5)
// stderr: WARNING: 'old_calculate' is deprecated: use new_calculate() instead

Without Message #

@deprecated
fn legacy_api() {
    // ...
}

legacy_api()
// stderr: WARNING: 'legacy_api' is deprecated

Behavior #

  • The warning is printed only once per deprecated function (not on every call)
  • The function still executes normally after the warning
  • Output goes to stderr, not stdout

@builder #

Generates a builder pattern for structs. The builder allows constructing structs field-by-field with method chaining.

@builder
struct Config {
    host: str,
    port: int,
    debug: bool,
}

let cfg = Config.builder()
    .host("localhost")
    .port(8080)
    .debug(true)
    .build()

print(cfg.host)   // "localhost"
print(cfg.port)   // 8080
print(cfg.debug)  // true

Generated Methods #

For each field name: Type in the struct, @builder generates:

  • StructName.builder() — creates a new builder instance
  • .field_name(value) — sets the field value, returns the builder
  • .build() — creates the final struct instance

Example: Complex Builder #

@builder
struct Request {
    url: str,
    method: str,
    timeout: int,
    headers: {str: str},
}

let req = Request.builder()
    .url("https://api.example.com")
    .method("POST")
    .timeout(30)
    .build()

@op / @operator #

Marks a method inside an impl block as the implementation of a specific operator. The compiler wires the method to the matching Lua metamethod and — for the native backend — to the canonical operator method name. Unlike the name-convention path (fn add__add), the decorator lets the method have any name; the intent stays explicit at the declaration site.

struct Vec2 {
    x: int,
    y: int,
}

impl Vec2 {
    @op("+")
    fn plus(self, other) {
        return Vec2 { x: self.x + other.x, y: self.y + other.y }
    }

    @op("-")
    fn minus(self, other) {
        return Vec2 { x: self.x - other.x, y: self.y - other.y }
    }

    @op("unary-")
    fn invert(self) {
        return Vec2 { x: -self.x, y: -self.y }
    }

    @op("==")
    fn same(self, other) {
        return self.x == other.x && self.y == other.y
    }
}

let a = Vec2 { x: 3, y: 4 }
let b = Vec2 { x: 1, y: 2 }
print((a + b).x)   // 4
print((-a).x)      // -3
print(a == b)      // false

@operator("symbol") is a verbose alias that produces the same effect.

Supported symbols #

Symbol Lua metamethod Canonical name
"+" __add add
"-" __sub sub
"*" __mul mul
"/" __div div
"%" __mod mod_
"**" __pow pow
"unary-" __unm neg
"==" __eq eq
"<" __lt lt
"<=" __le le
".." __concat concat
"#" __len len
"()" __call call
"@" __tostring to_string

- is binary subtraction; use "unary-" for the unary minus operator (Lua's __unm).

Coexists with the name convention #

The existing convention still works: a method literally named add is still wired to __add automatically — no decorator needed. The decorator is only required when you want a different method name or want to be explicit about the binding.

impl Money {
    @op("+")
    fn combine(self, other) {     // any name
        return Money { cents: self.cents + other.cents }
    }

    fn mul(self, other) {         // name convention — no decorator
        return Money { cents: self.cents * other.cents }
    }
}

Custom Decorators #

Decorators follow the syntax:

@name
@name(arg1, arg2)

The decorator name and arguments are stored in the AST and processed during compilation. The built-in decorators above are handled by the compiler. To define your own decorators in Zolo, use mixin functions (below).

Mixin Functions #

A mixin is a user-defined decorator written in plain Zolo. You declare it with @mixin fn name() { ... } and apply it to any function with @name. Inside the mixin body, super() calls the wrapped target.

@mixin
fn traced() -> int {
    print(">> enter")
    let r = super()   // calls the wrapped function
    print("<< exit")
    return r           // the mixin's return value is the call's result
}

@traced
fn square(n: int) -> int {
    return n * n
}

print(square(5))
// >> enter
// << exit
// 25

How it works #

Applying @traced rebinds square to a wrapper. Calling square(5) runs the mixin body; super() invokes the original square, re-passing the same arguments. The mixin can run code before and after super(), transform its result, or skip it entirely.

Passing arguments through #

super() with no arguments forwards the original call's arguments unchanged — you don't repeat the parameter list:

@mixin
fn logged() -> int { return super() }

@logged
fn add(a: int, b: int) -> int { return a + b }

print(add(2, 3))   // 5

Short-circuit: skip the target #

A mixin that returns without calling super() never runs the target. This is the basis for feature flags, circuit breakers, and dry-runs:

@mixin
fn disabled() -> int {
    return 0       // super() is never called — the target body is skipped
}

@disabled
fn dangerous() -> int {
    return 999     // never runs
}

print(dangerous())   // 0

Composition #

Mixins stack like any decorator, bottom-up — the mixin closest to the fn is the innermost layer:

@mixin
fn plus_one() -> int { return super() + 1 }

@mixin
fn times_three() -> int { return super() * 3 }

@times_three   // outermost
@plus_one      // innermost
fn base() -> int { return 10 }

print(base())   // (10 + 1) * 3 = 33

times_three.super() runs plus_one, and plus_one.super() runs base.

Parameterised mixins #

Declare parameters on @mixin(...) and pass values at the application site. This is the shape of @retry(n), @cache(ttl), @rate_limit(rps), and friends. The parameter is an ordinary local in the body; super() still forwards the target's own arguments.

@mixin(times: int)
fn repeated() -> int {
    var total = 0
    for _ in 0..times {
        total = total + super()   // run the target `times` times
    }
    return total
}

@repeated(3)
fn one() -> int { return 1 }

print(one())   // 3

Modifying the arguments #

super(a, b) calls the wrapped function with explicit arguments instead of forwarding the originals — useful for sanitising or defaulting inputs. This composes through a stack: an outer mixin's super(x) flows down to the next layer.

@mixin
fn clamp_positive() -> int {
    let n = super(0)   // ignore the caller's value, force 0
    return n
}

Mixins on methods #

A mixin applies to an impl method too. super() forwards the receiver automatically, and if the mixin body touches self, it reads the receiver directly:

@mixin
fn audited() -> int {
    let before = self.balance   // the receiver is in scope
    return super() + before
}

struct Account { balance: int }

impl Account {
    @audited
    fn snapshot(self) -> int { return self.balance }
}

A mixin that uses self is a method mixin and can only be applied to methods (not free functions).

Diagnostics #

zolo check reports:

  • TM100super used outside a @mixin fn body.
  • TM112 — a self-using (method) mixin applied to a free function.

Current scope and limitations #

Mixins are new; the first cut covers the core (see specs/mixin-functions.html for the full design and roadmap):

  • Parameter types on @mixin(name: type) are advisory — they're not yet enforced by the type checker.
  • A mixin's effect set (with E) is not yet propagated onto the wrapped function's type — effect tracking is still advisory (v1).
  • A mixin name can't shadow a built-in decorator name (@memoize, @retry, @log, @test, @bench, @cached, @benchmark, @deprecated, @validate, ...). Pick a different name.
  • Supported on the VM/Lua backend.

Combining Decorators #

Multiple decorators can be applied to a single declaration:

@test
@memoize
fn test_cached_fibonacci() {
    assert_eq(fibonacci(10), 55, "fib(10)")
}

Decorators are applied in bottom-up order (closest to the function first).

enespt-br