Grid Style Guide
Grid Style Guide
This is the canonical authoring style for Grid models. The grammar accepts more forms than this guide teaches; the canonical style picks one preferred way to spell each idea so models stay consistent across authors and tools.
This guide is normative for:
- Models in
examples/canonical/. - Models used in documentation.
- Models used in regression tests and validation fixtures.
- Models written by AI agents (see
ai-agent-guide.md).
It is recommended for any shared or production model.
Looking for the formal contract? That's
docs/specs/parser_authoring.md.
1. Why Have A Style Guide
Grid intentionally accepts a wide range of equivalent forms. That flexibility is useful for migration, exploration, and parser coverage, but it makes models feel inconsistent when reviewed across teams.
The canonical profile makes models:
- Readable — one preferred spelling per idea.
- Predictable — review and refactor without ambiguity.
- Portable — preferred forms map cleanly to both runtimes.
- Diff-friendly — small changes produce small diffs.
- Test-friendly — validators and parsers exercise the same surface.
If your team needs a different convention, fork the guide locally — just pick one rule per idea and stick to it.
2. The Canonical Profile (Cheat Sheet)
| Idea | Prefer | Avoid |
|---|---|---|
| Conditional | THEN ... ELSE |
? : |
| Null fallback | DEFAULT |
?? |
| Local bindings | DO ... END |
top-level LET(...) |
| Multi-branch (same subject) | MATCH(subject, ...) |
repeated IF(=, ...) |
| Multi-branch (different conditions) | THEN ... ELSE chained |
CASE WHEN (acceptable but not preferred) |
| Lambdas | (x) => ... named arrows |
LAMBDA(x, ...) (acceptable) |
| Higher-order helpers | MAP, REDUCE, SCAN, MAKEARRAY, BYROW, BYCOL |
hand-rolled loops |
| Async value | ~= for lazy / one-shot, = for eager |
mixing both for the same value |
| Error guard | DEFAULT then IFERROR, TRY |
nested IF(ISERROR(...)) |
| Type tag | Use on outputs and crossing-boundary values | Tag every intermediate |
| Strings | "double quotes" |
'single quotes' |
| Section comments | # Heading once per section |
One comment per line |
| Rules | WHEN ... THEN ... END blocks |
assignment-level schedule modifiers |
3. Model Shape
Every shared model follows this shape:
MODEL "Name"
DESCRIPTION "What this model does."
RUNTIME "lua_generated" | "ts"
VERSION "x.y.z"
AUTHOR "owner"
TAGS "tag1", "tag2"
# Inputs
A1 as type = ...
# Derived calculations
B1 as type = ...
# Final outputs
C1 as type = ...
# Rules (any runtime)
WHEN ... THEN ... END
END MODELThe pattern is: header → inputs → derivations → outputs → rules → END.
3.1 Picking The Runtime
Pick lua_generated whenever you can. It compiles to Redis Lua, ships
as a self-contained Redis Functions library, and supports the full
language including rule blocks (WHEN, EVERY, AT) and rule-action
assignment modifiers (+=, ?=, …). The TS GridService orchestrates
rule waves and arms a smart-wakeup setTimeout per model, so behavior
is identical to the ts runtime.
Switch to ts only when you need:
- An in-process runtime for development without a Redis dependency.
- A scenario where unit tests are easier to write against an in-process runtime than against a real Redis.
All language features — including spatial references (ABOVE,
BELOW, LEFT, RIGHT, NEIGHBORS) — are supported on every
runtime, so the choice is operational, not a feature gate.
When in doubt, start with lua_generated.
lua_interpreter is a third option: same Lua-in-Redis semantics as
lua_generated but ships the model as a JSON IR loaded by a shared
runtime script. Pick it when you have many models in the same
deployment and want them to share one runtime artifact rather than
each compiling its own Lua library.
3.2 Sectioning
Group statements into clear sections with a single # heading:
# Inputs
A1 as currency = 100000
A2 as percentage = 7pct
# Derivations
B1 as currency = A1 * (1 - A2)
# Outputs
C1 = B1 > 50000 THEN "ok" ELSE "small"Don't comment every line. Section headings carry the structural narrative.
4. Naming
4.1 Cell Names
A1 notation is the default. Reserve named references for things that genuinely have a name in the domain:
revenue = SUM(A1:A12) # named — used many places
B5 = revenue * 0.21 # OK to reference by nameDon't rename single-use intermediates. A3 is fine.
4.2 Lambda Parameters
Use parameter names that reveal meaning:
MAP(units, prices, (units, price) => ROUND(units * price, 2)) # good
MAP(units, prices, (a, b) => ROUND(a * b, 2)) # weak
MAP(units, prices, _1 * _2) # placeholder OK for trivial casesPlaceholder lambdas (_, _1, _2) are reserved for short
higher-order operations and pipe steps where the operation is
self-evident.
4.3 Symbol Literals
Use :identifier for enum-like state labels:
status = MATCH(score, score > 0.9 -> :excellent, score > 0.7 -> :good, _ -> :poor)MATCH is a function call, so its arms may wrap across lines if the
single-line form gets too long:
status = MATCH(score,
score > 0.9 -> :excellent,
score > 0.7 -> :good,
_ -> :poor
)Symbol values display nicely, sort consistently, and signal "this is a state, not free text".
5. Assignments
5.1 Use = By Default
A1 = 10 # eager
A2 as currency = A1 * 100 # eager + type tag5.2 Use ~= For Lazy / Expensive
A3 ~= ML_SCORE(features) # lazy external
A4 ~= LARGE_MATRIX_INVERT(M) # lazy expensive5.3 Use ?= Sparingly
?= preserves a previous value when the RHS errors. Use it when an
input might be temporarily unavailable but you want to keep the last
known good value:
A5 = 0
A5 ?= MAYBE_FAIL_FETCH()Don't use ?= as a substitute for proper error handling
(DEFAULT, IFERROR, WITH).
5.4 Use Compound Assignments For Counters
counter += 1
total += amountOnly inside imperative-feeling sections. Most cells are functional and
should use =.
5.5 Tag Outputs, Not Every Intermediate
A1 as currency = 100000 # input — tag
B1 as currency = A1 * 1.05 # output — tag
B2 = B1 + 100 # intermediate — usually no tagTag inputs (so callers know the shape), tag outputs (so consumers format correctly), and skip tags on internal intermediates unless they carry meaning.
6. Branching
6.1 Single Condition: THEN ... ELSE
status = score > 0.7 THEN "high" ELSE "low"6.2 Multiple Tiers: Chain THEN ... ELSE
Most-specific to least-specific:
grade = score >= 90 THEN "A" ELSE score >= 80 THEN "B" ELSE score >= 70 THEN "C" ELSE "F"Chained THEN ... ELSE is single-line. If it doesn't fit, switch to
CASE WHEN (which spans lines).
6.3 Same Subject, Many Values: MATCH
color = MATCH(status, "draft" -> :gray, "pending" -> :amber, "approved" -> :green, _ -> :red)Use MATCH whenever the branches all compare against the same
subject. The _ arm is required if you don't enumerate every value
and want a default. MATCH arms may also wrap across lines as a
table — see §4.3 above.
6.4 Multi-Condition Tabular Form: CASE WHEN (Acceptable)
When several distinct conditions need separate clauses, CASE WHEN
reads well as a table:
grade = CASE
WHEN score >= 90 THEN "A"
WHEN score >= 80 THEN "B"
WHEN score >= 70 THEN "C"
ELSE "F"
ENDBut chained THEN ... ELSE is also fine, and is the canonical pick
for short chains.
7. Local Bindings
Use DO ... END to name intermediate values:
B1 as currency = DO
revenue = SUM(A1:A12)
cost = SUM(B1:B12)
revenue - cost
ENDPrefer DO over top-level LET(...):
# Avoid
B1 = LET(revenue, SUM(A1:A12), cost, SUM(B1:B12), revenue - cost)DO is what LET desugars to. Authoring with DO keeps the visual
structure of "name some things, then return one thing".
8. Pipes
Use pipes when a value moves through a short, obvious left-to-right chain:
result = raw_value >> ABS() >> ROUND(2)
filtered = data >> FILTER(_, _ > 0) >> SORT(_) >> TAKE(10)Avoid pipes when:
- The chain is one step (just call the function directly).
- The chain has nested branching (use
DO). - The placeholder slot needs to appear in unusual positions (use a named lambda).
9. Higher-Order Helpers
A3 = MAP(A1#, A2#, (units, price) => ROUND(units * price, 2))
A4 = SCAN(0, A3#, (acc, value) => ROUND(acc + value, 2))
A5 = REDUCE(0, A3#, (acc, value) => ROUND(acc + value, 2))
B1 = BYROW(matrix, row => SUM(row))
B2 = BYCOL(matrix, col => AVERAGE(col))
B3 = MAKEARRAY(rows, cols, LAMBDA(r, c, INDEX(matrix, r, c) * scale))Style points:
- Name lambda parameters meaningfully.
- One arrow lambda per helper — don't nest deep lambdas inside lambdas.
- For simple elementwise operations, prefer
SIN.(arr)broadcast overMAP(arr, SIN(_)).
10. Error Handling
10.1 Default Order Of Preference
DEFAULTfor blank/error fallback to a value.IFERROR/IFNAfor explicit error handling.TRY ... ELSEinline form for one-off guards.WITH ... THEN ... ELSEfor multi-step external chains.ASSERTfor invariant checking on inputs.
10.2 Examples
# Cheap fallback
B1 as currency = ROUND(amount * (rate DEFAULT 1.08), 2)
# Explicit error handling
B2 = IFERROR(VLOOKUP(...), "n/a")
# Inline guard
B3 = TRY 1 / divisor ELSE 0
# Multi-step chain
B4 = WITH data = HTTP_JSON(url), first = data.results[1]
THEN first.name ELSE "unknown"
# Input invariant
B5 as percentage = input ASSERT input BETWEEN 0 AND 1Avoid nesting IF(ISERROR(...)) chains — they're harder to read than
the canonical alternatives.
11. External Functions
Pair every external call with a DEFAULT:
fx_rate = FX_RATE("EUR", "USD")
amount_usd as currency = ROUND(amount_eur * (fx_rate DEFAULT 1.08), 2)Use ~= for external calls that are not on the hot path:
nightly_score ~= ML_SCORE(features)Don't put external calls inside frequently-recomputed expressions unless you want every recompute to enqueue a job. Cache them at a named cell.
12. Rules
When a model needs reactive behavior, use full rule blocks rather than schedule modifiers:
# Good
WHEN A1 > 100 THEN
C1 = "alert"
C2 = NOW()
END
EVERY duration"PT15M" SKIP MISSED THEN
D1 = D1 + 1
END
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
E1 = TRUE
END# Avoid in shared models
G1 = NOW() EVERY cron"0 * * * *" BACKFILLSchedule modifiers are convenience sugar. They're fine for one-off scripts but blur the line between formula and rule in larger models.
Always include a missed-run policy (SKIP MISSED or BACKFILL).
13. Type Tags
13.1 Use Tags For
- Inputs that need validation.
- Outputs that need formatting (currency, percentage, date).
- Values crossing model or API boundaries.
- Anything where semantic meaning matters more than brevity.
13.2 Don't Use Tags For
- Trivial scratch intermediates.
- Boolean flags.
- Counters.
13.3 Common Tags
| Tag | When to use |
|---|---|
currency |
Money amounts |
percentage |
Rates expressed 0..1 or 0..100 |
bps |
Basis points |
score |
ML or rating scores |
date / datetime |
Calendar values |
duration |
Time spans |
unit:USD etc. |
Specific currency codes |
14. Comments
# Section heading
A1 = 100 # Inline comment ONLY when the line is non-obvious
/* Block comment for a multi-paragraph
explanation that wouldn't fit on one
line. */Avoid:
- One comment per line restating the formula (
# multiply by tax rate). - Block comments wider than 80 columns.
- Decorative comments (banners, ASCII art, separators).
15. Spacing And Layout
- One blank line between sections.
- No blank lines inside a
DOblock, a rule body, or a tightly related sequence of cells. - Use spaces around operators:
A1 + A2, notA1+A2.
15.1 When To Wrap Across Lines
Grid lets any expression inside (...), [...], or {...} span
multiple lines (see reference.md §14.1).
Use that freely when it makes the formula easier to scan — not for
its own sake.
Wrap when:
- A function call has more than ~3 meaningful arguments, or any argument is itself a non-trivial expression.
- A
MATCHhas more than ~3 arms, or the patterns/results are wide enough that side-by-side reading suffers. - An object literal has more than ~3 fields.
- A pipe (
>>) chain has more than ~2 stages. - A boolean predicate or arithmetic expression mixes operators of different precedence and benefits from one term per line.
Keep it on one line when it already fits and reads cleanly — most two- and three-argument calls don't need wrapping.
15.2 Wrapping Conventions
When you do wrap, follow these conventions so models stay legible across a team:
# Function calls: one argument per line, two-space indent, closing
# paren on its own line.
A1 = MATCH(status,
"draft" -> :gray,
"pending" -> :amber,
"approved" -> :green,
_ -> :red
)
# Object literals: one field per line, trailing comma optional,
# closing brace on its own line at the parent indent.
A2 = {
name: "Acme",
founded: 1999,
active: TRUE
}
# Comprehensions: keep the head expression on the first line, then
# put each FOR / IF clause on its own line.
A3 = [
row.revenue
FOR row IN sales
IF row.region = "NA"
]
# Pipes: wrap the whole chain in parens, with the leading >> at the
# start of each continuation line.
A4 = (data
>> FILTER(_, _ > 0)
>> SORT(_)
>> TAKE(10))
# Long predicates: parens + leading operator per line.
A5 = (
is_active
AND tier = :gold
AND balance > 0
)Align -> arrows in MATCH arms when the patterns are short and
similar-shaped (it makes the table effect work). Don't force
alignment when patterns vary widely — uneven padding is worse than
no padding.
16. What's Out Of Style
These forms work but are not canonical for shared models:
| Form | Why avoid in shared models |
|---|---|
LET(...) at top level |
Use DO ... END |
? : ternary |
Use THEN ... ELSE |
?? |
Use DEFAULT |
'single quotes' for strings |
Use "double"; single is for sheet qualifiers |
| Schedule modifier on a single cell | Use a full rule block |
Nested IF(ISERROR(...)) chains |
Use IFERROR, WITH, or DEFAULT |
| Untyped published outputs | Add an as <tag> annotation |
| Heavy placeholder use in long expressions | Use named arrow lambdas |
The parser still accepts all of these. They're just not preferred for shared, reviewed, or AI-generated code.
17. Worked Comparison
A model written in two styles:
17.1 Loose Style (Acceptable, Not Canonical)
A1 = 100
B1 = A1 ?? 0
C1 = LET(t, A1 * 1.21, t)
D1 = A1 > 50 ? "big" : "small"
E1 = IF(ISERROR(FX_RATE("EUR","USD")), 1.08, FX_RATE("EUR","USD"))
F1 = NOW() EVERY cron"0 * * * *" SKIP MISSED17.2 Canonical Style
MODEL "Example"
DESCRIPTION "Show the canonical style."
RUNTIME "lua_generated"
VERSION "1.0.0"
# Inputs
A1 as currency = 100
# Derivations
B1 as currency = A1 DEFAULT 0
C1 as currency = DO
total = A1 * 1.21
total
END
D1 = A1 > 50 THEN "big" ELSE "small"
E1 as fx_rate = FX_RATE("EUR", "USD") DEFAULT 1.08
# Periodic refresh
EVERY cron"0 * * * *" SKIP MISSED THEN
F1 = NOW()
END
END MODELThe canonical version is longer but it's structurally clear and tooling-friendly.
18. Linting And Validation
A canonical-style linter is not yet shipped. The current validators are:
- Build-time diagnostics from the parser/model builder.
- The seven canonical models in
examples/canonical/as a regression fixture. - The
npm test -- canonicalModelstest target.
When the canonical-style linter ships, it will check the rules in this guide programmatically.
19. See Also
reference.md— full language reference.assignments.md— assignment shapes in detail.rules-and-schedules.md— rule blocks.ai-agent-guide.md— strict AI generation rules.docs/specs/parser_authoring.md— formal grammar contract.examples/canonical/— worked examples.