Composition and Custom Decorators
Multiple decorators can be stacked before the same function or struct. The
application order is bottom-up: the decorator closest to the target is
applied first. The example below combines cache, tracing, and retry in a single
function, and also shows a struct with @derive + @serialize:
@retry + @log + @memoize on a function; @serialize + @derive on a struct; @log + @benchmark together.
// Feature: Stacked decorators — composition
// Syntax: several `@a` `@b` lines before the `fn`/`struct`. Application
// order is bottom to top (the one closest to the target applies first).
// When to use: combine capabilities without entangling logic in the body.
// Case 1: cache + tracing + retry — a "good citizen" function.
@retry(3)
@log
@memoize
fn lookup_user(id: int) -> str {
// In production: a DB/HTTP call. Here, a deterministic stub.
return "user-{id}"
}
print(lookup_user(1))
print(lookup_user(1)) // memoize avoids re-execution
print(lookup_user(2))
// expected:
// user-1
// user-1
// user-2
// Case 2: struct with Debug + Clone + Eq + serialize.
@serialize
@derive(Debug, Clone, Eq)
struct Item {
sku: str,
price: float,
}
let a = Item { sku: "X-1", price: 9.9 }
let b = a.clone()
print(a)
// expected: Item { sku: "X-1", price: 9.9 }
print("a == b? {a == b}")
// expected: a == b? true
print(a.to_json())
// expected: {"sku":"X-1","price":9.9}
// Case 3: log + benchmark — instrument and measure together.
@log
@benchmark
fn hot_path(n: int) -> int {
return n * n + n
}
print("hot(10) = {hot_path(10)}")
// expected: hot(10) = 110
The @const qualifier on a parameter requires the corresponding argument to be
a compile-time constant — a literal, a reference to a const, or an arithmetic
expression over comptime values. This enables validations (via const_assert)
that run at compile time, not at runtime:
Literal, const reference, and comptime arithmetic are accepted; a runtime variable is rejected.
// Feature: `@const` parameter qualifier
// Syntax: `fn name(@const NAME: TYPE, ...)`
// When to use: function runs at runtime, but a specific argument must
// be a comptime-constant — useful for fixed-size buffers, format
// string validation, SQL DSL validation, bit-field accessors, and
// any API where the argument enables compile-time checks via
// `const_assert`.
//
// See `specs/const-parameter.md` for the full specification.
// 1. Basic shape ----------------------------------------------------
fn ring_buffer(@const capacity: int, name: str) -> int {
// (Inside the body the parameter behaves like a runtime value;
// comptime-conscious validation runs at the call site.)
return capacity
}
// 2. Accepted call shapes ------------------------------------------
let r1 = ring_buffer(64, "audio") // literal — OK
print("r1 = {r1}")
const DEFAULT_CAP = 128
let r2 = ring_buffer(DEFAULT_CAP, "video") // const reference — OK
print("r2 = {r2}")
let r3 = ring_buffer(32 + 16, "control") // arithmetic over comptime values — OK
print("r3 = {r3}")
// 3. Rejected call shape -------------------------------------------
// Uncomment to trigger the type error:
//
// let n = read_int()
// let bad = ring_buffer(n, "dynamic")
// // ^ E_ConstArgNotComptime: argument 1 must be a
// // comptime-constant expression (parameter is
// // declared `@const`)
//
// Bypass options:
// - hoist the value into a `const`:
// const N = 256
// ring_buffer(N, "fixed")
// - inline the literal directly:
// ring_buffer(256, "fixed")
// 4. Why @const is useful ------------------------------------------
// Inside `ring_buffer`, the `capacity` parameter can be referenced
// in a `const_assert` to enforce invariants at every call site:
//
// fn ring_buffer(@const capacity: int) {
// const_assert capacity > 0
// const_assert capacity.is_power_of_two()
// ...
// }
//
// (Comptime evaluation of `@const` params in `const_assert` is a
// follow-up — the parameter is comptime-known at the call site, but
// the check passes through runtime today. Track in
// `specs/const-parameter.md` §Interactions.)
@mixin lets you create decorators in pure Zolo. Inside the body, super()
calls the target function and returns its value. The mixin decides what to do
with that value: forward it, transform it, ignore it (short-circuit), or call it
multiple times. Mixins also accept parameters and work on impl methods:
Around, transformation, bottom-up composition, short-circuit, parameter, and mixin on a method.
// Feature: Mixin functions — user-defined decorators written in Zolo.
//
// A `@mixin fn` wraps a target function. Inside the body, `super()` calls the
// wrapped target. You apply a mixin like any decorator: `@name`. Unlike the
// built-in decorators (@memoize, @retry, ...), mixins live in userland — the
// compiler only knows that a mixin is "a fn whose body may call super()".
//
// Mixins compose bottom-up, exactly like stacked decorators: the one closest
// to the `fn` is the innermost layer.
// ── 1. Around: run code before and after the target ──────────────────
// `super()` returns whatever the target returns; the mixin's own return
// value is what the caller sees (here we just pass it through).
@mixin
fn traced() -> int {
print(">> enter")
let r = super()
print("<< exit")
return r
}
@traced
fn square(n: int) -> int {
return n * n
}
print(square(5))
// expected:
// >> enter
// << exit
// 25
// ── 2. Transform the result ──────────────────────────────────────────
// The mixin can do anything with the wrapped call's value.
@mixin
fn doubled() -> int {
return super() * 2
}
@doubled
fn ten() -> int {
return 10
}
print(ten())
// expected: 20
// ── 3. Composition (stacking) ────────────────────────────────────────
// Bottom-up: `plus_one` is innermost (10 + 1 = 11), `times_three` wraps it
// (11 * 3 = 33). Reversing the two lines would yield (10 * 3) + 1 = 31.
@mixin
fn plus_one() -> int {
return super() + 1
}
@mixin
fn times_three() -> int {
return super() * 3
}
@times_three
@plus_one
fn base() -> int {
return 10
}
print(base())
// expected: 33
// ── 4. Short-circuit: a mixin may choose NOT to call super() ──────────
// Useful for feature flags, circuit breakers, dry-runs. The target never
// runs, so its body is skipped entirely.
@mixin
fn disabled() -> int {
print("blocked")
return 0
}
@disabled
fn dangerous() -> int {
print("this should never run")
return 999
}
print(dangerous())
// expected:
// blocked
// 0
// ── 5. Parameterised mixins ──────────────────────────────────────────
// Declare parameters on `@mixin(...)`; pass values at the application site.
// This is the shape of @retry(n), @cache(ttl), @rate_limit(rps), etc. The
// parameter is an ordinary local inside the body; super() still forwards the
// target's own arguments.
@mixin(factor: int)
fn scaled() -> int {
return super() * factor
}
@scaled(4)
fn six() -> int {
return 6
}
print(six())
// expected: 24
// A parameter can also drive how many times the target runs.
@mixin(times: int)
fn repeated() -> int {
var total = 0
for _ in 0..times {
total = total + super()
}
return total
}
@repeated(3)
fn unit() -> int {
return 1
}
print(unit())
// expected: 3
// ── 6. Mixins on methods ─────────────────────────────────────────────
// super() forwards the receiver automatically. A mixin that reads `self`
// becomes a method mixin and binds the receiver in its body.
@mixin
fn audited() -> int {
print("audit: balance was {self.balance}")
return super()
}
struct Account {
balance: int,
}
impl Account {
@audited
fn read(self) -> int {
return self.balance
}
}
let acct = Account { balance: 42 }
print(acct.read())
// expected:
// audit: balance was 42
// 42
Challenge
Create a @mixin called clamped(min: int, max: int) that restricts the
function's return value to the range [min, max]. Apply it to a function that
can return values outside that range and confirm the result.