Skip to content
On this page

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

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).

Search Zolo

9 results

enespt-br