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:
- TM100 —
superused outside a@mixin fnbody. - 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).