Reference

Grid Language Reference

Grid Language Reference

The complete reference for the Grid formula language.

This document is normative: every form documented here is supported by the parser at version 1.0.0-rc.*. Forms not documented here may exist in the parser as experimental sugar but are not guaranteed to be stable.

The companion documents are:


1. Source File Structure

A .grid source file is a sequence of statements, optionally preceded by metadata directives.

[<directive> ...]
 
<statement>
<statement>
...
 
[END MODEL]

Statements are separated by newlines or semicolons. A trailing separator is allowed.

1.1 Comments

Form Effect
# text to end of line Line comment
/* text */ Block comment, may span lines
# This is a header comment.
A1 = 10  # Trailing comment.
/*
  Multiline
  block comment.
*/

Edge case. A # immediately followed by a known error-literal spelling (N/A, DIV/0!, VALUE!, REF!, NAME?, NULL!, NUM!, CALC!, SPILL!) is parsed as an error literal, not a comment. So # N/A this is fine is a comment but #N/A is the error literal. Add a space (# X) to force comment behavior when in doubt.

1.2 Metadata Directives

Directives appear at the top of the file, before any statements. All are optional but the canonical style strongly prefers a complete header.

Directive Form Purpose
MODEL MODEL "name" Human-readable model name
DESCRIPTION DESCRIPTION "text" Short description
RUNTIME RUNTIME "lua_generated", RUNTIME "lua_interpreter", or RUNTIME "ts" Selects runtime profile
VERSION VERSION "1.0.0" Semver string
AUTHOR AUTHOR "name" Author or team
TAGS TAGS "a", "b", "c" Free-form tags
END MODEL END MODEL Closes the file (optional)

The RUNTIME directive matters most:

Value Meaning
"lua_generated" Model compiles to a self-contained Redis Functions Lua library. Rule blocks (WHEN/EVERY/AT) and rule-action assignment modifiers (+=, ?=, …) are supported via the TS GridService orchestrator with smart wakeup.
"lua_interpreter" Same execution model as lua_generated, but the model is shipped as a JSON IR that a shared Lua runtime in Redis loads. Rule support is identical.
"ts" Model runs in the in-process TypeScript runtime. Behaviorally equivalent to the Lua runtimes for the portable subset; useful for unit tests, local development, and any code path that needs to execute outside Redis.

If no RUNTIME is set, the engine falls back to the deploy-time default (GRID_RUNTIME_ENGINE env var, defaulting to "ts").

Practical note: lua_generated and lua_interpreter are behaviorally identical for the portable subset. Pick lua_generated when you want a one-Lua-script-per-model deployment artifact, and lua_interpreter when you'd rather ship a JSON IR alongside a single shared runtime script.


2. Statements

Top-level statements come in three shapes:

  1. Assignments — bind a value to a target cell.
  2. Rule statementsWHEN / EVERY / AT blocks.
  3. Metadata directives — covered above.

See assignments.md and rules-and-schedules.md for full coverage. The summary:

# Eager assignment
A1 = 10
 
# Lazy assignment
A2 ~= EXPENSIVE_CALL()
 
# Conditional eager (only assign if expression is non-error)
A3 ?= MAYBE_FAIL()
 
# Compound assignment
A4 += 5
 
# Typed assignment
A5 as currency = A1 * 100
 
# Range assignment (broadcasts to each cell)
A6:A10 = B1
 
# Destructuring
[total, avg, ...rest] = SUMMARY(B1:B10)
 
# Input-decorated assignment (writable surface in protected mode)
input price = 0
 
# Output-decorated assignment (readable surface in protected mode)
output total = price * qty
 
# Rule block
WHEN A1 > 0 THEN
  C1 = "active"
END

2.1 Decorators

Two decorator keywords may prefix any assignment:

Decorator Position Effect
input First, before any other token Cell is part of the model's external write surface (setInput/setBatchInput). Restricted to plain = assignments with a default value.
output Either before or after input Cell is part of the model's external read surface (resolve/getRange/getModelSnapshot). Permitted on every assignment shape, including rule-action targets.

In a deployment with protectedMode: true the runtime strictly enforces both surfaces — only declared inputs accept writes, only declared outputs return reads. See assignments.md § 5 for the full semantics.


3. Cell References

A cell name is a symbol. Each cell holds exactly one typed value.

3.1 A1 Notation

A1          # column A, row 1
AA100       # column AA, row 100
$A$1        # absolute (locks both column and row)
$A1         # column-absolute, row-relative
A$1         # column-relative, row-absolute

3.2 R1C1 Notation

R1C1        # row 1, column 1
R[1]C[-1]   # one row down, one column left (relative)
R1C[2]      # row 1 (absolute), two columns right

3.3 Named References

Plain identifiers are named references:

Revenue
fx.rate
total_2026

Names may include letters, digits, underscores, and .. They must not start with a digit and must not look like an A1 or R1C1 address.

3.4 Sheet And Workbook Qualifiers

Sheet1!A1                    # sheet-qualified
'Q4 Revenue'!B2              # quoted (use single quotes for spaces)
'[Book1]Sheet1'!A1           # workbook + sheet

Single quotes are reserved for sheet/workbook qualifiers. Do not use them for string literals.

3.5 Ranges

A1:B10           # finite rectangle
R1C1:R3C3        # finite rectangle in R1C1
A:A              # full column A
A:C              # columns A through C
1:1              # full row 1
1:3              # rows 1 through 3

3.6 Spill References

If a formula returns an array, it spills into a rectangle anchored at its target. The # suffix returns that whole rectangle as a single array value:

A1 = [1, 2, 3, 4, 5]    # spills into A1:A5
B1 = SUM(A1#)           # 15
C1 = A1# * 2            # broadcasts; spills into C1:C5

3.7 Spatial And Neighborhood References (TS Runtime)

Available only in the TS runtime. Useful inside rule actions:

ABOVE         # cell directly above the current target
BELOW         # cell directly below
LEFT          # cell directly to the left
RIGHT         # cell directly to the right
 
ABOVE(3)      # three rows above
SUM(BELOW)    # all cells below the current target
 
NEIGHBORS         # 1-cell radius around the current target
NEIGHBORS(2)      # 2-cell radius

3.8 Temporal References

A reference can be qualified with a temporal suffix that points at a prior or absolute revision:

A1@-1                    # value of A1 one revision back
A1@-5                    # value of A1 five revisions back
A1@dt"2026-04-10T15:30Z" # value of A1 at the given timestamp

The syntax parses and lowers correctly on every runtime, but v1 does not implement history-backed evaluation. Resolving a temporal reference returns an #N/A error with the diagnostic:

Temporal references are not implemented in v1 (<symbol>@offset). The syntax is reserved; runtime support is planned for a future release.

Both the TS runtime and the Lua runtimes emit this same error so callers see a uniform diagnostic. Treat temporal references as a parser-supported construct that is forward-compatible with the planned history store; do not rely on them for working models in v1.


4. Literals

4.1 Numbers

Form Example
Integer 42
Decimal 3.14, .5, 1.
Scientific 1e3, 2.5E-4
Underscored 1_000_000, 0.000_001
Hex 0xFF
Binary 0b1010
Octal 0o755

4.2 Strings

Form Example Notes
Double-quoted "hello" Escape quote with ""
Triple-quoted """line 1\nline 2""" Preserves newlines
Raw raw"C:\temp" No backslash escapes
Raw triple raw"""...""" Both raw and multiline
Backtick `hello {A1}` Interpolation with {expr}

Backtick strings support format specifiers via ::

`Total: {A1:$#,##0.00}`     # desugars to TEXT(A1, "$#,##0.00")

Strings always use double quotes. Single quotes are reserved for sheet qualifiers.

4.3 Booleans

TRUE
FALSE
true       # case-insensitive
False

4.4 Blank

BLANK      # explicit blank value

BLANK is distinct from the empty string "". Blank cells coerce to 0 numerically and FALSE logically. Use IS BLANK to test.

4.5 Symbol Literals

Enum-like labels, prefixed with ::

:draft
:pending
:approved
:gray

Symbols are stored as strings with typeTag = "symbol".

4.6 Typed String Literals

Short tagged strings parse to a typed value:

Prefix Example Result type
date / d date"2026-04-10", d"2026-04-10" date
datetime / dt dt"2026-04-10T15:30:00Z" date
duration / dur dur"P1D", duration"PT15M" duration
cron cron"0 9 * * 1-5" cron
glob glob"report-*.csv" string (glob pattern)
wild wild"inv-????-*.csv" string (wildcard)
raw raw"^\d+$" string (no escapes)
url url"https://example.com/api?q=1" object (typeTag url)
mol / molecule mol"H2O", molecule"C8H10N4O2" object (typeTag molecule)
dna dna"ATCG" object (typeTag dna)
color / colour color"#ff8800", color"rgb(255,136,0)" object (typeTag color)
note note"A4", note"C#5" object (typeTag note)
bin / bytes bin"DEADBEEF", bytes"00112233" object (typeTag bytes) hex
b64 b64"SGVsbG8=" object (typeTag bytes) base64

Domain literals carry both the parsed payload and the original textual form, so URL_PATH(url"https://x/y") resolves without re-parsing while COERCE("https://x/y", url) still produces the same canonical value.

4.7 Structured Literals

Parse JSON / YAML / TOML / XML directly into object values:

A1 = json"""{"a": 1, "nested": {"b": true}}"""
A2 = yaml"""
a: 1
nested:
  b: true
"""
A3 = toml"""title = "TOML"
[owner]
name = "Tom"
"""
A4 = xml"""<root foo="1"><bar>true</bar></root>"""

4.8 Delimited Table Literals

A1 = csv"""
name,amount
Alice,10
Bob,20
"""
 
A2 = tsv"""
name	amount
Alice	10
Bob	20
"""

These spill into a rectangular array.

4.9 Regex Literals

JavaScript-style:

/^INV-\d+$/      # no flags
/^Order #(\d+)/i # case-insensitive

4.10 Suffix Literals

Category Examples typeTag
Duration 30s, 15min, 6h, 3d, 2w, 4mo, 1y, 250ms duration
Basis points 25bp, 25bps basis_points
Percentage 21pct, 5pct percentage
Angle 45deg, 1.57rad angle
Data size 512KB, 2GB, 1TB data_size
Currency / 3-letter codes 100USD, 50EUR unit:USD, unit:EUR

The full duration suffix list is ms, s, min, h, d, w, mo, y. Any uppercase 3-letter code is parsed as a unit literal (100ABCtypeTag = "unit:ABC").

4.11 Array Literals

[1, 2, 3]              # row vector
[1; 2; 3]              # column vector
[1, 2; 3, 4]           # 2x2 matrix
[1, 2, 3, ...A1:A5]    # spread an existing range into the literal

Arrays may spread other arrays or ranges with ....

4.12 Error Literals

Nine error codes are writable in source:

#N/A      #DIV/0!    #VALUE!    #REF!
#NAME?    #NULL!     #NUM!      #CALC!    #SPILL!

Two more codes (#TYPE!, #CIRC!) exist only as runtime-generated values; the parser rejects them as literals. See errors.md for what each error means and when it fires.

4.13 Object Literals

Curly braces build records (key→value maps). Keys may be bare identifiers or double-quoted strings; values are any expression.

{}                                     # empty object
{name: "Ada", level: 9}                # bare-identifier keys
{"first name": "Ada", "level": 9}      # quoted keys (any string)
{id: row@"id", total: row@"amount" * row@"fx"}   # values can be expressions
{owner: customer, address: customer.address}     # values can be other objects

Objects share their access syntax with arrays — both work through dot, bracket, and INDEX:

person = {name: "Ada", role: "engineer"}
person.name              # "Ada"            (dot access)
person["role"]           # "engineer"       (bracket access)
INDEX(person, "name")    # "Ada"            (function form)
person?.missing          # BLANK            (optional chaining)

Pair object literals with OBJECTS to iterate table rows by header name; see section 6.15 for the full table-comprehension pattern.

Object literals participate in the value system as the object kind (see 5.1 Value Kinds). Like arrays, they may be nested inside other objects or inside arrays.

4.14 Imaginary / Complex Literals

A trailing i (or j) suffix on a numeric literal makes it imaginary:

A1 = 5i                # 0 + 5i        kind=complex, typeTag=complex
A2 = 3 + 4i            # 3 + 4i
A3 = (2.5e-3)i         # 0 + 0.0025i
A4 = 1i ^ 2            # -1 + 0i       integer powers stay exact

Complex values are a first-class ValueKind (see 5.1 Value Kinds) and participate in +, -, *, /, and ^ directly — no need for IMSUM/IMSUB. Ordering operators (<, <=, >, >=) and MIN/MAX reject complex operands.

Build complex values explicitly with MAKE_COMPLEX(re, im) and decompose them with REAL, IMAGINARY, MODULUS, ARGUMENT, and CONJUGATE. The legacy Excel-compatible IM* family still accepts both typed complex values and "a+bi" strings.

4.15 Domain Object Literals

The typed-string prefixes from 4.6 cover six domain object kinds — URL, molecule, DNA, color, musical note, and binary bytes. Each prefix dispatches to a domain parser that returns a structured object with a stable shape, so downstream functions operate on parsed fields rather than re-parsing on every call:

A1 = url"https://example.com/api?q=1"
A2 = URL_HOST(A1)                # "example.com"
A3 = URL_QUERY_PARAM(A1, "q")    # "1"
 
B1 = mol"C8H10N4O2"              # caffeine
B2 = MOL_MASS(B1)                # 194.19...
B3 = MOL_ATOM_COUNT(B1, "N")     # 4
 
C1 = dna"ATCGGCTA"
C2 = DNA_GC(C1)                  # 0.5
C3 = DNA_REVCOMP(C1)             # dna value: TAGCCGAT
 
D1 = color"#ff8800"
D2 = COLOR_R(D1)                 # 255
D3 = COLOR_LUMINANCE(D1)         # 0.55... (Rec.709)
 
E1 = note"A4"
E2 = NOTE_FREQUENCY(E1)          # 440
E3 = NOTE_MIDI(E1)               # 69
 
F1 = bin"DEADBEEF"               # bytes value, length 4
F2 = BYTES_TO_BASE64(F1)         # "3q2+7w=="

Domain values round-trip through Redis (the canonical text form is stored under the cell's _values slot, the structured payload is reconstructed from _types).


5. Type System

5.1 Value Kinds

Every Grid value has a kind — the underlying primitive shape:

Kind Holds
blank Absence of a value
number A double-precision float
string UTF-8 text
boolean true or false
date An ISO-8601 instant
array A 2D rectangular grid of typed values
object A structured value (JSON-like)
complex A complex number re + im·i (both parts are doubles)
error A typed error with code and message

The complex kind is unorderable — relational operators (<, <=, >, >=) and MIN/MAX raise #NUM! on complex operands. Equality and arithmetic (+, -, *, /, ^) are fully supported.

5.2 Type Tags

A type tag is a semantic label layered on top of a kind. The same number kind can carry many type tags:

Tag Kind Meaning
number number Plain number
currency number Currency amount (display with currency symbol)
amount number Generic monetary or quantity amount
percentage / percent number Percentage (display as %)
basis_points / bps number Basis points
score number Score (0..1 typical)
rate number Generic rate
fx_rate number Foreign exchange rate
<anything>_rate number Any tag ending in _rate is treated as a numeric rate
integer number Integer-valued
decimal number Decimal
angle number Angle (radians or degrees)
data_size number Byte count
unit:XXX number Any three-letter coded unit (e.g. unit:USD, unit:EUR)
string string Plain text
symbol string Enum-like label (from :foo literal)
duration string ISO-8601 duration
cron string Cron expression
regex object Compiled regex
lambda object A LAMBDA function value
uncertainty object Mean ± uncertainty value (from ± operator or UNCERTAIN())
date / datetime date ISO-8601 instant
csv / tsv array Delimited table
json / yaml / toml / xml object Parsed structured value
complex complex Complex number { re, im } (from Ni literal or MAKE_COMPLEX)
url object Parsed URL { scheme, host, port, path, query, fragment, raw }
molecule object Chemical formula { formula, atoms } (from mol"…")
dna object DNA sequence { sequence } over ACGTN (from dna"…")
color object Color { r, g, b, a } 0–255 + alpha 0–1 (from color"…")
note object Musical note { pitch, accidental, octave, midi } (from note"…")
bytes object Byte blob { bytes: number[] } (from bin"…" / b64"…")
error:XXX error Error with code XXX

Type tags can be applied at assignment time:

A1 as currency = 100000
A2 as percentage = 21pct
A3 as date = d"2026-04-10"

The runtime validates compatibility: A1 as currency = "hello" is rejected with #TYPE!.

5.3 Coercion Rules

When a function expects a kind that doesn't match what's passed, Grid applies these rules:

From → To Rule
stringnumber Number(s); NaN becomes #VALUE!
booleannumber TRUE1, FALSE0
blanknumber 0
numberboolean non-zero → TRUE, zero → FALSE
stringboolean non-empty → TRUE, empty → FALSE
blankboolean FALSE
number / booleanstring String(value)
blankstring ""
error → anything propagates as the error value

Errors short-circuit. A function that receives an error argument returns that error unchanged unless the function is explicitly error-handling (IFERROR, ISERROR, TRY, etc.).


6. Expressions

6.1 Function Calls

SUM(A1:A3)
ROUND(3.14159, 2)
CONCAT("hello", " ", "world")
SUM(1, 2, 3,)            # trailing comma is allowed
INTERPOLATE.LINEAR(x, xs, ys)
Z.TEST(arr, 0, 1)

Function names are case-insensitive. The canonical style uses uppercase. Trailing commas are accepted in function calls, array literals, and MATCH arms — handy for templated codegen.

Function names may include dots (INTERPOLATE.LINEAR, Z.TEST, F.DIST, etc.). The dot is part of the identifier and not a property access. The full set of dotted function names is in functions.md.

A function name with .( instead of ( invokes the broadcast form, which applies the function elementwise across array arguments:

SIN.([1, 2, 3])           # MAP([1,2,3], SIN(_))
ROUND.(prices, 2)         # ROUND each element of prices to 2 digits

6.2 Named Arguments

ROUND(number := 3.14159, digits := 2)
FX_RATE(base := "EUR", quote := "USD")

Named arguments may appear in any order. Mixing positional and named arguments is allowed; positional first.

6.3 Arrow Lambdas

For higher-order functions:

x => x * 2
(acc, x) => acc + x
(units, price) => ROUND(units * price, 2)

6.4 LAMBDA (Excel-style)

LAMBDA(x, x * 2)
LAMBDA(x, y, x + y)

LAMBDA returns a function value usable by MAP, REDUCE, SCAN, MAKEARRAY, BYROW, BYCOL, etc.

6.5 Placeholder Lambdas

Inside higher-order helpers and pipes, _, _1, _2 are placeholders:

MAP(A1:A3, _ * 2)
REDUCE(0, A1:A3, _1 + _2)
A1 >> ROUND(_, 2)

Outside higher-order helpers and pipes, a standalone placeholder expression like ADD(5, _) produces a LAMBDA(__p1, ADD(5, __p1)) function value, not an evaluated expression.

6.6 Conditional (THEN ... ELSE)

A1 > 0 THEN "positive" ELSE "non-positive"

For multiple tiers, chain right-to-left (single line):

score >= 0.9 THEN "A" ELSE score >= 0.7 THEN "B" ELSE score >= 0.5 THEN "C" ELSE "F"

If the expression gets too long for one line, switch to CASE WHEN or extract intermediates with DO ... END.

The C-style ?: form is also accepted (A1 > 0 ? "yes" : "no") but not preferred.

6.7 Null Coalescing

A1 ?? 0              # if A1 is BLANK or error, return 0
A2 DEFAULT "n/a"     # canonical form, same effect

DEFAULT is the canonical spelling; ?? is accepted sugar.

6.8 Pattern Matching With MATCH

MATCH(status, "active" -> 1, "hold" IF score > 10 -> 2, _ -> 0)

Branches are <pattern> -> <result>, optionally guarded by IF. The last _ arm matches anything. MATCH is a function call, so its arms can wrap across lines for readability — see §14.1:

MATCH(status,
  "draft"    -> :gray,
  "pending"  -> :amber,
  "approved" -> :green,
  _          -> :red
)

6.9 SQL-Style CASE WHEN

CASE WHEN score >= 90 THEN "A"
     WHEN score >= 80 THEN "B"
     WHEN score >= 70 THEN "C"
     ELSE "F"
END

Desugars to IFS(...). Use this when each branch tests a different condition. Use MATCH when each branch compares the same subject.

6.10 Local Bindings With DO ... END

DO
  revenue = SUM(A1:A12)
  cost = SUM(B1:B12)
  revenue - cost
END

The last expression is the value of the block. Single-line form:

DO revenue = SUM(A1:A12); cost = SUM(B1:B12); revenue - cost END

Desugars to LET(revenue, SUM(A1:A12), cost, SUM(B1:B12), revenue - cost).

6.11 LET (Excel-Style)

LET(x, 5, y, x + 1, x * y)

DO ... END is preferred for readability; LET is the lower-level form that DO desugars to.

6.12 WITH Expressions (Error-Tolerant Chains)

WITH data = HTTP_JSON(url), users = data.results
THEN users[1].name ELSE "unavailable"

Desugars to nested IFERROR(LET(...), fallback). Use this for external-data chains where any step might fail.

Bindings may span lines (with , at end-of-line), but THEN <expr> ELSE <fallback> must be on a single line.

6.13 Pipes

Forward-pipe >>:

A1 >> ABS() >> ROUND(2)              # ROUND(ABS(A1), 2)
A1 >> ROUND(_, 2)                    # ROUND(A1, 2) with explicit slot
A1 >> IF(_ > 0, _, 0)                # IF(A1 > 0, A1, 0)

Conditional pipe (a regular pipe with a trailing IF):

data >> SORT(_) IF LEN(data) < 1000  # IF(LEN(data) < 1000, SORT(data), data)

When the trailing IF condition is false, the original value is passed through unchanged. The same >> operator handles both the unconditional and conditional forms.

Pipe inspection TAP:

data >> TAP >> SORT(_)                # logs data, passes through
data >> TAP("label") >> SORT(_)       # labeled tap

6.14 Sequence Shorthand

1..10               # 1, 2, 3, ..., 10 (inclusive)
1..<10              # 1, 2, 3, ..., 9 (half-open)
1..2..10            # 1, 3, 5, 7, 9 (with step)
1..<2..10           # 1, 3, 5, 7, 9 (half-open with step)
 
date"2026-01-01"..date"2026-01-03"
date"2026-01-01"..duration"P2D"..date"2026-01-05"
d"2026-01-01"..3d..d"2026-01-07"

6.15 List Comprehensions

Comprehensions desugar to single-cell array calculations, so a comprehension of any size occupies one dependency-graph node — much cheaper than a range broadcast like A1:A1000 = ....

[x * 2 FOR x IN A1:A10]                 # 1D map: returns an N-element column
[x FOR x IN A1:A10 IF x > 0]            # 1D filter: keeps elements where the predicate is true
[r * c FOR r IN 1..3, c IN 1..4]        # 2D: produces a true 3×4 grid
[r * c FOR r IN 1..3, c IN 1..3 IF r <= c]  # 2D + IF: cells where the predicate is false become BLANK

Lowering rules:

  • [body FOR x IN src]MAP(src, LAMBDA(x, body)).
  • [body FOR x IN src IF cond]FILTER over src using a boolean mask built from cond, then MAP over the surviving elements. FILTER returns a row, so a filtered 1D comprehension is a 1×N row even when src is a column.
  • [body FOR r IN s1, c IN s2]MAKEARRAY(rows(s1), rows(s2), …) — a proper 2D grid; not a "column of rows".
  • [body FOR r IN s1, c IN s2 IF cond] → same 2D shape, with cells where cond is false replaced by BLANK. This preserves a rectangular array; use a single-generator filter when you want a shorter result.

Three or more generators are rejected at parse time. Use nested comprehensions or MAKEARRAY directly for higher dimensions.

Comprehensions Over A Table

When the source is a table-with-headers (a 2D array whose first row names the columns), wrap it with OBJECTS to iterate row-by-row using the header names instead of column indices. Combining OBJECTS with object literals gives a SQL-like project / filter:

data = ["id", "amount", "fx", "status";
        1, 100, 1.0, "active";
        2, 200, 1.1, "inactive";
        3,  50, 0.9, "active"]
 
# SELECT id, amount * fx AS total FROM data WHERE status = "active"
totals = [{id: r.id, total: r.amount * r.fx}
          FOR r IN OBJECTS(data)
          IF r.status = "active"]
# -> [{id: 1, total: 100}, {id: 3, total: 45}]

OBJECTS is the natural inverse of writing a table as [headers; row1; row2; …]. See functions.md for the full signature.

6.16 Property And Index Access

Dot and bracket access work uniformly across arrays (numeric index, 1-based) and objects (string field name). Both lower to INDEX so the underlying function dispatches by the source's value kind:

arr = [10, 20, 30]
arr[2]                        # 20            (array, 1-based)
 
obj = {name: "Ada", role: "engineer"}
obj.name                      # "Ada"         (object, identifier key)
obj["role"]                   # "engineer"    (object, string key)
INDEX(obj, "name")            # "Ada"         (function form)
 
HTTP_JSON(url)["results"]     # works on JSON-shaped objects
HTTP_JSON(url).results
HTTP_JSON(url)?.results       # optional chaining: BLANK if missing
HTTP_JSON(url)?["results"]

Dot access uses an identifier-shaped key (obj.name); for keys that aren't valid bare identifiers (with spaces, leading digits, etc.) use the bracket form: obj["first name"].

Tip — to avoid ambiguity with Excel structured references like Sales[Revenue], use a quoted string when bracket-accessing an object: obj["Revenue"] (object access, returns the field) versus Sales[Revenue] (structured reference, returns the named column).

6.17 Slicing And Negative Indexing

data[2:5]            # elements 2 through 5 (1-indexed, inclusive)
data[:3]             # first 3
data[3:]             # from index 3 to end
data[^3:]            # last 3 (^N = from end)
data[-1]             # last element
data[1:10:2]         # every 2nd element from 1 to 10

6.18 Set Operators

[1, 2, 3] UNION [3, 4, 5]        # [1, 2, 3, 4, 5]
[1, 2, 3] INTERSECT [2, 3, 4]    # [2, 3]
[1, 2, 3] EXCEPT [2]             # [1, 3]
[1, 2, 3] EXCLUDE [2]            # [1, 3] — same as EXCEPT

EXCEPT and EXCLUDE are synonyms.

6.19 Reduction Operators (APL-Style)

+/ A1:A10           # SUM(A1:A10)
*/ A1:A10           # PRODUCT(A1:A10)
&/ A1:A10           # CONCAT(A1:A10)

6.20 Broadcast Dot

Apply a function elementwise without MAP:

SIN.([1, 2, 3])           # MAP([1,2,3], SIN(_))
ROUND.(data, 2)           # ROUND elementwise across data

6.21 Spaceship Comparison

A1 <=> B1     # -1 if A1 < B1, 0 if equal, 1 if A1 > B1

6.22 Expression Clauses

amount UNLESS amount = 0
total * rate WHERE total = SUM(A1:A10), rate = 0.1
total * rate USING total AS SUM(A1:A10), rate AS 0.1
value ASSERT value > 0
value ASSERT value > 0 ELSE #N/A
customer WITH KEYS "id", "name", "email"
customer WITHOUT KEYS "ssn", "password_hash"
Clause Meaning
UNLESS If condition true, return BLANK; else return value
WHERE Bind names visible in the value expression (uses =)
USING Same as WHERE but uses AS instead of = for binding
ASSERT If condition false, return #VALUE! (or ELSE clause)
WITH KEYS Project an object or 2D-array-with-headers onto the listed keys (desugars to PICK)
WITHOUT KEYS Drop the listed keys from an object or 2D-array-with-headers (desugars to OMIT)

WHERE and USING both desugar to LET(...). Pick whichever reads better:

total + tax  WHERE  total = SUM(A1:A10), tax = total * 0.21
total + tax  USING  total AS SUM(A1:A10), tax AS total * 0.21

WITH KEYS and WITHOUT KEYS both work on objects and on 2D arrays that carry a header row. They are clause sugar over the PICK and OMIT functions, with the same shape preservation rules:

customer WITH KEYS "id", "name"            # = PICK(customer, "id", "name")
customer WITHOUT KEYS "ssn"                # = OMIT(customer, "ssn")
purchases WITH KEYS "id", "amount"         # 2D array → 2D array, header row preserved

The bindings list may span lines (after each ,), but the value expression and the clause keyword must be on one line each.

6.23 Type And Text Predicates

value IS BLANK
amount IS NOT NUMBER
started_at IS DATE
 
email ENDS WITH "@acme.com"
name STARTS WITH "Jo"
notes CONTAINS "urgent"
customer ILIKE "jo%"
customer NOT ILIKE "x%"

Type predicates: IS BLANK, IS NUMBER, IS STRING, IS BOOLEAN, IS DATE, IS ARRAY, IS OBJECT, IS ERROR, IS COMPLEX, IS URL, IS MOLECULE, IS DNA, IS COLOR (alias IS COLOUR), IS NOTE, IS BYTES. Each accepts IS NOT.

Domain predicates check the value's typeTag (so mol"H2O" IS MOLECULE is TRUE, but a plain string like "H2O" IS MOLECULE is FALSE). IS COMPLEX matches the underlying complex ValueKind regardless of how the value was constructed.

6.24 Structured Table References

Sales[Revenue]      # equivalent to TABLE_REF("Sales", "Revenue")

6.25 Partial Application

When a placeholder appears in a function call outside a higher-order context:

ADD(5, _)    # = LAMBDA(__p1, ADD(5, __p1))

The result is a function value. To actually evaluate immediately, provide all arguments.

6.26 QUERY (SQL-Like)

QUERY(data, "SELECT * WHERE col1 > 5 ORDER BY col2 DESC LIMIT 10")

Supports SELECT, WHERE, ORDER BY, LIMIT. Use this for tabular filtering when an array is shaped like a table.

6.27 Header Access (@, @[…], @![…])

The postfix @ operator selects by header name on a 2D array, or by key on an object. Three shapes are supported:

data@"price"                  # single column / key:    COLUMN_BY_HEADER(data, "price")
data@["id", "name", "email"]  # project to a key list:  PICK(data, "id", "name", "email")
data@!["ssn", "secret"]       # drop a key list:        OMIT(data, "ssn", "secret")
Form Desugars to Source kinds Result kind
expr @ key COLUMN_BY_HEADER(expr, key) array array (single column)
expr @ [k1, k2, …] PICK(expr, k1, k2, …) object or array same kind, projected
expr @ ! [k1, k2, …] OMIT(expr, k1, k2, …) object or array same kind, drop listed

For the bracketed forms the keys are any primary expressions — typically string literals, but also references, function calls, etc. Spaces around @, !, and the brackets are all permitted.

Examples:

revenue = data@"amount"
slim    = customer@["id", "email"]
audit   = customer@!["password_hash", "ssn"]
top10   = SORT(data@["product", "revenue"], 2, FALSE)[1:10]

6.28 Domain Operators (%OF, TO)

Two binary infix operators express common domain math:

25 %OF 200            # PERCENTOF(25, 200) → 0.125 (25 is 12.5% of 200)
100 TO 200            # GROWTH(100, 200) → 1.0 (100% growth from 100 to 200)

%OF is the percentage-of operator (left as a fraction of right). TO is the growth-rate operator (relative growth from left to right).

6.29 Combinatoric Operators

Three infix operators desugar to combinatoric functions:

5 CHOOSE 2            # COMBIN(5, 2) → 10  (binomial coefficient)
5 PERMUTE 2           # PERMUT(5, 2) → 20  (permutations)
5 MULTICHOOSE 2       # COMBINA(5, 2)      (multiset coefficient)

The CHOOSE operator is not the same as the CHOOSE Excel function (which picks an element by index). The infix CHOOSE is always a binomial coefficient. The CHOOSE(...) function call is a selector. Context disambiguates.

6.30 Tolerance / Uncertainty

A value can carry an explicit ± or +/- uncertainty:

100 ± 5               # UNCERTAIN(100, 5)
100 +/- 5             # same
weight ± measurement_error

The result is an object value with mean and uncertainty fields.

6.31 CLAMP Postfix

Constrain a value to a range with a postfix CLAMP[lo, hi]:

score CLAMP[0, 1]                # CLAMP(score, 0, 1)
A1 + 100 CLAMP[0, 50]            # CLAMP(A1 + 100, 0, 50)

CLAMP is a comparison-level postfix; it binds tighter than logical operators but looser than arithmetic.

6.32 TRY With Multiple Branches

TRY accepts a chain of THEN branches; each is tried in order, and the first one that doesn't return an error wins. An optional ELSE provides the final fallback (default is #N/A):

TRY 1 / divisor ELSE 0           # single branch
TRY primary() THEN backup() ELSE "n/a"
TRY a/b THEN c/d THEN e/f ELSE 0  # try a/b first, then c/d, then e/f, finally 0

Desugars to nested IFERROR(body, IFERROR(branch1, IFERROR(branch2, fallback))).


7. Operator Precedence

Highest to lowest. Within a row, operators are listed by associativity.

# Level Operators
1 Unary / reduction @ (implicit intersection), + - ! NOT, +/ */ &/
2 Member / index / header access .field ?.field ["key"] ?["key"] [start:end] [start:end:step] [^n] @key @[k1, k2, …] @![k1, k2, …]
3 Postfix arithmetic % ! !!
4 Power ^ ** (right-associative)
5 Combinatoric CHOOSE PERMUTE MULTICHOOSE
6 Multiplicative * / // %%
7 Domain %OF TO
8 Additive + - (with optional ± / +/- uncertainty postfix)
9 Concatenation &
10 Comparison + suffixes = <> != < <= > >= <=> IN NOT IN LIKE NOT LIKE ILIKE NOT ILIKE STARTS WITH NOT STARTS WITH ENDS WITH NOT ENDS WITH CONTAINS NOT CONTAINS BETWEEN NOT BETWEEN IS IS NOT CLAMP[lo, hi]
11 Set UNION INTERSECT EXCEPT EXCLUDE
12 Logical AND &&, XOR, OR ||
13 Null coalescing ?? DEFAULT
14 Conditional THEN ... ELSE
15 Pipe >> (with optional trailing IF cond)
16 UNLESS clause UNLESS
17 ASSERT clause ASSERT
18 WHERE / WITH KEYS clauses WHERE, USING, WITH KEYS, WITHOUT KEYS

To force a different order, use parentheses.

The ^ operator is right-associative. 2^3^2 parses as 2^(3^2) = 512, not (2^3)^2 = 64. This matches Excel.


8. Implicit Intersection (@)

When a single-cell context references a range, prefix with @ to collapse the range to the intersected cell:

B1 = @A:A         # the cell in column A on the same row as B1

This matches Excel's implicit intersection behavior introduced alongside dynamic arrays.


9. Evaluation Modes

Operator Mode When the cell evaluates
= Eager Whenever any input symbol changes
~= Lazy Only when something reads it
?= Conditional eager Same as =, but skip the assignment if the RHS is an error
+= -= *= /= Eager update Single-cell mutation: A1 += 5A1 = A1 + 5

Compound assignments and conditional eager are not supported inside rule action bodies.

9.1 Decorators And Protected Mode

Decorator Allowed on Effect
input Plain = only, with a default value, top-level statements only Cell is the model's external write surface
output Every assignment shape (eager, lazy, conditional, compound, scheduled, range, destructuring, rule-action) Cell is the model's external read surface

When a model is deployed with protectedMode: true, the runtime rejects any setInput to a non-input symbol and any resolve / getRange of a non-output symbol. Deploys also fail if any implicit input (a referenced-but-unassigned symbol) is missing an input declaration, or if the model declares neither input nor output.

See assignments.md for full coverage.


10. Execution Classes

Each function declares an execution class:

Class Where it runs
lua_sync Inside Redis (compiled to Lua) or in the TS runtime; returns a value synchronously
external_async In a worker process via the job queue; writes back asynchronously

Today three external functions are built in: FX_RATE, HTTP_JSON, ML_SCORE. See external-functions.md for the async semantics.

A model with an external_async call may still be RUNTIME "lua_generated": the compiler lowers the external call into a synthetic symbol, which the worker resolves and writes back through the runtime.

10.1 TS ↔ Lua Parity Caveats

For the portable subset (lua_sync functions, all literals, all operators, and DO/CASE WHEN/MATCH/WITH/TRY constructs), the TS and Lua runtimes are designed to produce byte-equivalent results. A formal parity test corpus is on the v1 roadmap (see V1_SCOPE.md axis A) but has known gaps today.

Current known divergences:

Function / construct Divergence
LEN(s) Lua counts bytes (string.len), TS counts UTF-16 code units (s.length). Multi-byte characters disagree.
MID, LEFT, RIGHT Same byte-vs-codeunit issue when strings contain multi-byte characters.
IF(condition, ...) truthiness Both runtimes treat 0, "", BLANK, FALSE, and any error as falsy. Other values are truthy.
Numeric edge cases Infinity, -Infinity, NaN are coerced to #NUM! in some Lua paths but pass through in TS.
Float representation Both use IEEE 754 doubles; rounding-display gotchas apply equally.
RAND, RANDBETWEEN, NOW, TODAY Marked volatile; values won't match across runtimes for the same call (use deterministic inputs in tests).

Until parity tests are in place, treat these as caveats when porting Excel-derived models. For ASCII-only text and numeric workloads, the two runtimes are interchangeable in practice.


11. Spilling

A formula returning an array writes into a rectangle anchored at its target.

A1 = [10, 20, 30]        # spills into A1, A2, A3
B1 = A1# * 2             # spills into B1, B2, B3 with values 20, 40, 60

Rules:

  • The spill rectangle starts at the formula's target cell.
  • If a cell in the spill rectangle is already populated, the formula yields #SPILL!.
  • A1# refers to the entire spilled rectangle as a single array value.
  • Implicit broadcasting: scalar-to-array, array-to-array of compatible shape.

12. Array Broadcasting

Two arrays broadcast if they have compatible shapes:

Shape A Shape B Result
scalar r×c r×c (scalar repeated)
r×1 r×c r×c (column broadcast across cols)
1×c r×c r×c (row broadcast across rows)
r×c r×c r×c (elementwise)

Incompatible shapes yield #VALUE!.


13. Keywords

Grid keywords come in three tiers based on how strictly the lexer guards them. Tier A and Tier B keywords are hard-reserved: they cannot appear as bare identifiers in any binding or reference position. Tier C words are contextual: their keyword role is matched first (e.g. IF(…) as a conditional) but the same names can still bind to user variables.

Identifier-vs-keyword matching is case-insensitive (AND, and, And are all the logical operator). Function names are also case-insensitive (SUM, sum, Sum all bind to the same function).

13.1 Tier A — Structural (hard-reserved)

Block scaffolding, schedule modifiers, clause keywords, and header directives. These are never function-callable and may never appear as a bare identifier.

Group Words
Rule-block scaffolding WHEN, THEN, END
Schedule modifiers EVERY, AT, ONCE
Missed-run policy SKIP, MISSED, BACKFILL
Type-tag introducer AS
Clause / block keywords ELSE, UNLESS, ASSERT, USING, WHERE, DO, FOR, CASE, TRY, WITH, WITHOUT, KEYS, DEFAULT
Assignment decorators INPUT, OUTPUT
Header directives MODEL, DESCRIPTION, RUNTIME, VERSION, AUTHOR, TAGS

13.2 Tier B — Operators, Predicates, Literals (hard-reserved)

Logical / set operators, comparison particles, type-predicate literals, identifier-shaped binary and postfix operators, and the type-shaped predicate names. Also hard-reserved as bare identifiers; many of these are also legal function-call names (AND(…), OR(…), NOT(…), XOR(…), UNION(…), INTERSECT(…), LEFT(…), RIGHT(…), CHOOSE(…), MATCH(…), NEIGHBORS(…), CLAMP(…), NUMBER(…), TEXT(…), DATE(…), …) — those calls match at the function-call rule before identifier resolution is attempted.

Group Words
Logical AND, OR, XOR, NOT
Comparison particles IS, IN, BETWEEN, LIKE, ILIKE, CONTAINS, STARTS, ENDS
Sequence / range TO, BY
Set UNION, INTERSECT, EXCEPT, EXCLUDE
Literals / type predicates BLANK, NA, TRUE, FALSE
Spatial directionals ABOVE, BELOW, LEFT, RIGHT
Combinatoric / postfix operators CHOOSE, PERMUTE, MULTICHOOSE, CLAMP
Pattern-matching / spatial keywords MATCH, NEIGHBORS
IS-predicate type names NUMBER, TEXT, STRING, BOOLEAN, DATE, DATETIME, DURATION

13.3 Tier C — Contextual (callable, not reserved)

These names act as keywords in their syntactic role but remain available as identifiers and as function calls. Use them as identifiers only when you really must — readability still suffers.

  • IF — used as a function (IF(c, t, e)), as a comprehension filter ([x FOR x IN s IF x > 0]), as a pipe gate (x >> f IF cond), and as a match-arm guard (pat IF cond -> result). Permitted as an identifier because real-world data analysis is full of column names like if_modified_since, gif, etc.; the contextual disambiguation is reliable.
  • LOGICAL, ERROR — appear only after IS / IS NOT (the predicate context). Allowed as identifiers because they read naturally as variable names.

Contextual literal prefixes (lowercase, quote-triggered)

These are not reserved as identifiers because they only become keywords when immediately followed by " or """:

raw, glob, wild, cron, date, datetime, duration, dur, dt, d, json, yaml, toml, xml, csv, tsv

So date = today() works fine; only date"2026-01-01" triggers the typed literal.

Contextual suffix units (number-triggered)

These are not reserved as identifiers because they only become units when immediately preceded by a number:

ms, s, min, h, d, w, mo, y, bp, bps, pct, deg, rad, KB, MB, GB, TB, plus any 3-letter UPPERCASE unit (e.g. USD, EUR, GBP).

So min = 0 works fine; only 5min parses as a duration literal. Caveat: in a binding context like LET(min, 5, 10min + 1), the body expression 10min parses as a unit literal and silently shadows the min binding. If you bind a variable that shares its name with a unit, prefer to write the unit form differently (or rename the variable) to avoid surprise.

13.4 Diagnostics

Using a Tier A or Tier B word as a binding name produces a PARSE_RESERVED_WORD diagnostic with a precise span over the offending token. The parser flags the first reserved word it encounters in a binding position:

A1 = LET(WHEN, 1, WHEN + 2)
         ^^^^
# `WHEN` is a reserved word in Grid and cannot be used as an identifier.
# Pick a different name (for example, prefix or suffix it), or use the
# keyword in its intended syntactic role.

Reserved-word checks fire in every binding or reference position: assignment targets, named references, LET / WHERE / USING / DO / WITH bindings, comprehension iteration variables, lambda parameters, and destructuring elements.

Reserved-word checks do not fire in label positions, because labels reference existing names rather than introducing new ones:

  • Type-tag values (A1 as number = 1) — the type tag is a label for a type kind, not a binding. Reserved type-shaped words are accepted exactly as users would expect to write them.
  • Named-argument labels (ROUND(number := 3.14, digits := 2)) — the label names a function parameter from metadata, not a new variable. Any identifier (including reserved words) is accepted at parse time; the runtime validates the label against the function's parameter list.
  • Symbol literal atoms (:end, :active, :number) — the leading colon makes these unambiguously tag-like atoms; reserved words are fine.

Function-call sites (AND(B1, C1), LEFT(text, 3), UNION(a, b), MATCH(x, 1 -> "a"), NEIGHBORS(2), CLAMP(x, 0, 10)) and the dedicated grammar slots for keywords (WHEN ... THEN ... END, EVERY 5min, value as money, x IS NUMBER) are unaffected.


14. Whitespace And Identifiers

  • Identifiers match [A-Za-z_][A-Za-z0-9_.]*.
  • Whitespace separates tokens within expressions.
  • Newlines separate statements at the top level.

14.1 Multi-Line Rules (Important)

Grid is line-oriented at the top level — one statement per line — but expressions may span multiple lines whenever the parser is inside an unclosed bracket ((...), [...], or {...}). This is the same rule Python and JavaScript use, and it gives you room to format long calls, literals, and comprehensions for readability.

The rule, in one sentence: a newline that appears while any bracket is still open is treated as ordinary whitespace; a newline at the top level always ends the current statement.

Construct Multi-line allowed because...
Function calls f(a, b, c) inside (...)
Parenthesized expressions (a + b * c) inside (...)
Array literals [1, 2, 3] inside [...]
Object literals { key: value } inside {...}
Comprehensions [expr FOR x IN xs IF p] inside [...]
MATCH(subject, a -> b, ...) it's a function call
Bracket access obj[key], slices arr[a:b] inside [...]
Lambda parameter lists (a, b) => inside (...)
DO ... END block form, newlines between bindings
CASE WHEN ... ELSE ... END block form, newlines between arms
WHEN / EVERY / AT rule bodies block form, newlines between actions
WITH <bindings> THEN <expr> ELSE <expr> bindings only — see below
Top-level statements newlines separate statements

Newlines are still not allowed in expressions that aren't bracketed:

  • chained THEN ... ELSE ladders (no surrounding brackets to fold)
  • the THEN <expr> ELSE <expr> tail of a WITH (the bindings list is newline-friendly; the conclusion is single-line)

Comments inside a bracketed block are fine — # ... line comments still terminate at the next real newline, and /* ... */ block comments are passed through verbatim.

Examples:

# OK — wrap long calls across lines
A1 = SUM(
  revenue,
  -costs,
  -taxes,
  bonus
)
 
# OK — multi-line MATCH reads like a table
A2 = MATCH(status,
  "draft"    -> :gray,
  "pending"  -> :amber,
  "approved" -> :green,
  _          -> :red
)
 
# OK — multi-line object literal
A3 = {
  name: "Acme",
  founded: 1999,
  active: TRUE
}
 
# OK — comprehension across lines
A4 = [
  row.revenue
  FOR row IN sales
  IF row.region = "NA"
]
 
# OK — bracket-balanced expression
A5 = (
  base
  + adjustment
  + bonus
)
 
# OK — DO body spans lines (block form)
A6 = DO
  x = 1
  y = 2
  x + y
END
 
# OK — CASE WHEN arms span lines (block form)
A7 = CASE
  WHEN x > 0 THEN "pos"
  WHEN x < 0 THEN "neg"
  ELSE "zero"
END
 
# NOT OK — multi-line chained THEN/ELSE (no brackets to fold into)
A8 = score >= 90 THEN "A"
   ELSE score >= 80 THEN "B"
   ELSE "F"
# Fix: keep on one line, or use CASE WHEN
A8 = score >= 90 THEN "A" ELSE score >= 80 THEN "B" ELSE "F"
A8 = CASE
  WHEN score >= 90 THEN "A"
  WHEN score >= 80 THEN "B"
  ELSE "F"
END

If you find yourself wanting to break an expression that isn't bracketed, either wrap it in (...) to opt into continuation, or extract intermediate names with DO:

A1 = (
  base
  + bonus
  - taxes
)
 
A1 = DO
  bonus = MATCH(grade, "A" -> 5000, "B" -> 3000, "C" -> 1000, _ -> 0)
  bonus * 1.1
END

15. Worked Examples

The seven canonical models cover the language end to end:

File Focus
01-foundations.grid Header, types, DO, THEN ELSE, DEFAULT
02-array-analytics.grid MAP, REDUCE, SCAN, MAKEARRAY, BYROW, BYCOL
03-text-and-quality.grid Regex, wildcards, MATCH, TRY, blanks
04-external-enrichment.grid Async functions, lazy cells, named args, fallbacks
05-rulebook-operations.grid WHEN, EVERY, AT, range rule actions
06-portfolio-risk-engine.grid Large portable model: FX, weights, carry, stress
07-treasury-control-plane.grid Very large model: liquidity, schedules, rules. Runs on lua_generated end-to-end.

Read them in order. Each adds one concept group on top of the previous.


16. JSON-Friendly Grammar Profile

For tooling, the same surface is encoded in grammar.json. It is the source of truth for machine-readable consumption.