Guide 9 of 36
Decorators
@memoize, @test, @benchmark, @retry and @log.
On this page
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 namesTest Assertions¶
assert_eq(actual, expected, "message") // assert equality
assert_ne(actual, expected, "message") // assert inequalityComponent tests¶
A Verniz component is a function that returns a View. Test it the way the
user sees it: mount the tree, query by role and accessible name, assert with
the UI matchers. mount and the queries live in std::testing::dom
(use std::testing::dom::{mount} outside a test); the matchers live in std::testing
and are in scope inside @test / @suite without any import.
use std::html::*
fn Pager(page: int) -> View {
<nav aria-label="Pagination">
<a href="/?page={page - 1}" aria-label="Previous page"
aria-disabled={if page == 1 { "true" } else { nil }}>Previous</a>
<a href="/?page={page + 1}" aria-label="Next page">Next</a>
</nav>
}
@suite("Pager")
mod pager_tests {
@test
fn first_page_disables_previous() {
let ui = mount(Pager(1))
let prev = ui.get_by_role("link", "Previous page")
expect(prev).to_be_disabled()
expect(prev).to_have_attr("href", "/?page=0")
expect(ui.get_by_role("link", "Next page")).to_be_enabled()
expect(ui).not.to_contain_element("script")
}
}mount(view) walks the same tree render serializes, so there is no HTML
parsing and no escaping to think about: attribute values are the decoded
ones (&, not &), text is whitespace-normalized, and every element
carries its tag[index] path (nav > ul > li[2] > a) for messages.
Queries come in three families, on any element (the mounted root or a
row you already found): get_by_* returns exactly one element and panics
otherwise, query_by_* returns nil when nothing matches, get_all_by_*
returns a list. Prefer them in this order, the way assistive technology
perceives the page:
ui.get_by_role("link", "Next page") // role + accessible name
ui.get_by_role("heading", nil, #{ "level": 1 }) // filters: level, current, checked, selected
ui.get_by_label("Per page") // <label for>, wrapping <label>, aria-label
ui.get_by_placeholder("Search")
ui.get_by_text("Draft") // the element's own text nodes
ui.get_by_display_value("100") // current input/select value
ui.get_by_alt("Logo") ui.get_by_title("Help") ui.get_by_test_id("row-3")
ui.query("nav a[aria-current=page]") // CSS subset: tag #id .class [attr=v], descendant, childThe name argument is a string or a Regex, positional or labelled
(ui.get_by_role("link", name: "Next page")); extra role filters travel in a
map (opts: #{ "current": "page" }). A failed get_by_* lists the candidates with their role,
name, key attributes and path, and suggests the closest name:
get_by_role("link", "Proxima pagina") found no element.
candidates:
link "Página anterior" href="/?page=2" disabled (nav > ul > li > a)
link "Próxima página" href="/?page=4" (nav > ul > li[2] > a)
did you mean name: "Próxima página"?element.debug() prints the same accessible tree for the whole subtree.
Matchers on expect(element), all negatable with .not and all naming
the element's path when they fail: to_be_in_document, to_have_text(text, exact?), to_have_attr(name, value?), to_have_class(names),
to_have_value, to_be_checked, to_be_selected, to_be_disabled,
to_be_enabled, to_be_visible, to_be_hidden, to_have_accessible_name,
to_have_role, to_contain_element(selector), to_contain_role(role, name?) and to_have_form_values(#{ "q": "ana" }).
Element fields and helpers: tag, attrs, text, own_text, role,
name, path, hidden, children, nodes, plus attr(name),
classes(), value(), is_disabled(), all(), elements(), html()
(normalized, attributes sorted) and debug() (accessible tree).
Snapshots: expect(ui).to_match_snapshot() stores the element's
normalized HTML in __snapshots__/<source>/<suite>.<test>.html next to the
test source; to_match_snapshot("name", "tree") stores the accessible tree
instead, and a plain string snapshots itself. A missing file is written on
the first run (a failure under zolo test --ci), a difference fails with a
line diff, and zolo test --update-snapshots (or ZOLO_UPDATE_SNAPSHOTS=1)
accepts the new output. Prefer the tree format for components whose theme
classes change often. The data-zolo-client-* attributes a client mount
carries (the inline script, a module hash, the owner's path) are left out
of the normalized HTML, so editing an island's script does not change the
snapshot of its markup.
Accessibility: expect(ui).to_be_accessible() runs the static rules a
browser is not needed for: image-alt, control-name, form-label,
duplicate-id, aria-valid, heading-order, tabindex-positive,
disabled-focusable, html-lang and document-title, each reported with
the offending element's path. to_be_accessible(["heading-order"]) skips
named rules explicitly. The compiler already rejects the obvious markup
cases at build time (TE744: an <img> without alt, a <button> without
a name); the runtime check covers what markup analysis cannot see, such as
attributes computed from data and elements built with el(...).
Limits worth knowing: raw(html) chunks are opaque (queries do not look
inside them); visibility is structural (hidden, aria-hidden,
display:none, <template>), not layout; roles and names follow the ARIA
tables, so a <ul> or a role="status" region has no name unless it is
labelled; and the pure-Zolo part (mount, queries) runs on every backend
while the matchers run inside zolo test on the VM.
@story¶
A story is a component in one named state: a top-level function decorated
with a label that returns the View, with no parameters or only parameters
with defaults. It lives next to the component and needs no parallel tooling.
@story("Pagination / first page")
fn pagination_first() -> View {
<UsersPagination page={load_users(42, 1, 50, TOTAL_USERS)} />
}
@story("Pagination / middle of the list")
fn pagination_middle() -> View {
<UsersPagination page={load_users(42, 60, 50, TOTAL_USERS)} />
}zolo test turns every story into a test: it mounts the view, checks it with
to_be_accessible() and compares it with a snapshot under __snapshots__/
(story__Pagination_._first_page.html). Describing a new state of the
component is what gives it coverage. The runner prints the story as
story: <label>; the synthetic test is named story__<fn>, so
--filter pagination_first selects it and the editor's "Run Story" lens
runs exactly that one.
zolo test src/pagination.zolo # tests + stories
zolo test --stories src/pagination.zolo # stories only
zolo test --no-stories src/pagination.zolo # tests only
zolo test -u src/pagination.zolo # accept the new snapshotszolo dev --stories adds a gallery to the dev server: /_zolo/stories
lists stories from the entry and its imported project modules. Stories from
package dependencies (including local path dependencies) are excluded by
default. Add --include-dependencies to include stories from the imported
packages too. Individual story pages are hot-reloaded like any page, so a
component's states can be browsed while they are built.
A file that never calls http.serve (a component library's story file) is
served by the runner itself, on ZOLO_PORT or 3000. Point it at a directory
to get one gallery for every story file in it, grouped by file:
zolo dev --stories src/pagination.zolo # one file
zolo dev --stories stories/ # every file with a @story, one gallery
zolo dev --stories --include-dependencies # also show imported package storiesEach file of the directory is loaded as a module (so it is hot-swapped on
save) and registers its stories under /_zolo/stories/<file>/<n>; a file's
@story_layout applies to its own stories.
A @story_layout function is the page every story is shown in: it takes
the story's View and returns the document with the theme, fonts and
stylesheets the components expect. Tests still mount the bare story.
@story_layout
fn story_page(story: View) -> View {
<document lang="en">
<head><meta charset="utf-8" />{ShadcnTheme()}</head>
<body class="p-8">{story}</body>
</document>
}A story with parameters is a playground: every parameter needs a default,
the tests mount the story with those defaults, and the gallery turns the
parameters into controls. bool is a switch, int and float a number,
str a text field and a unit-variant enum a select; the story is rendered
again on the server with the chosen values. The panel next to the story
also shows its doc comment, parameters and source (Docs) and whether the
current render matches its snapshot, with a diff and a button to accept it
(Snapshot).
/// Every Button prop as a control.
@story("Button / playground")
fn button_playground(
variant: ButtonVariant = .Default,
disabled: bool = false,
label: str = "Save",
) -> View {
<Button variant={variant} disabled={disabled}>{label}</Button>
}TE155 rejects a story without a label, with a parameter that has no
default, inside a mod, with a repeated label, or with named arguments:
play: (an interaction run on the headless client) is reserved until that
layer ships.
@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 cacheHow 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() insteadWithout Message¶
@deprecated
fn legacy_api() {
// ...
}
legacy_api()
// stderr: WARNING: 'legacy_api' is deprecatedBehavior¶
- 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) // trueGenerated 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
// 25How 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)) // 5Short-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()) // 0Composition¶
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 = 33times_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()) // 3Modifying 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. - TM113 — positional, named, defaulted, or typed mixin configuration does not match.
- TM114 — the mixin result is incompatible with the wrapped target (including concrete
self). - TM115 — explicit
super(...)arguments do not match the target signature. - TM116 — a runtime mixin is applied to a non-callable declaration.
- TE802 — a wrapped call would cross an undeclared effect boundary.
Current scope and limitations¶
Mixins enforce the same callable contracts as ordinary functions (see specs/mixin-functions.html for the full design and roadmap):
@mixin(name: type)parameters validate positional/named arguments and optional/defaulted entries. A callable value in the marker slot, such as@mixin(callback: fallback), is a callable default.selfis checked with the concrete method receiver; explicitsuper(...)uses the wrapped target's real parameter signature and automatically reinserts the method receiver.- The wrapped effect row is the union of the target, the mixin body, and any callable configuration argument invoked immediately by the mixin. Creating a deferred closure does not execute its callback.
- A mixin name can't shadow a built-in decorator name (
@memoize,@retry,@log,@test,@bench,@cached,@benchmark,@deprecated,@validate, ...). Pick a different name. - Runtime mixins are covered on VM/Lua, native/Cranelift, LLVM, and wasm-aot.
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).
DOCS / FEEDBACK
Did this page leave a question?
Tell us where the explanation lost you. Documentation is part of the language experience.