Expressions
Studio writes this format for you. The fx mode on value fields and the formula editor ( Formulas and expressions ) generate everything below, and this page documents the JSON if you want to hand-edit a file, review a diff, or understand what the chips represent.
An
$expression
is a computation or state change written as JSON structure instead of JavaScript source. One operator is applied to a
target
, optionally with a
value
. Because the whole thing is data, schema tooling can validate it, Studio can render it as editable chips, and the compiler can analyze it without parsing code.
The smallest complete expression is a dark-mode toggle handler:
{
"$expression": {
"operator": "=",
"target": { "$ref": "#/state/darkMode" },
"value": { "operator": "!", "target": { "$ref": "#/state/darkMode" } }
}
}
Expressions sit on the middle rung of the escalation ladder. Prefer the least powerful form that does the job:
| Rung | Power | Use when |
|---|---|---|
$ref
binding |
Lowest | Reading a state value |
${}
template |
Low | Single-use computed read |
$expression |
Mid | Declarative computation or state mutation |
$prototype: "Function" |
Highest | Logic not expressible as structure |
Expression nodes
Every node has the same three fields:
| Field | Required | Description |
|---|---|---|
operator |
Yes | A token from the blessed set (see below) |
target |
Yes | The operand the operator acts on: a
$ref
, a literal, or a nested node |
value |
By operator arity | The right-hand operand: a
$ref
, a literal, an array, or a nested node |
Nodes nest: a
target
or
value
may itself be a node, with no inner
$expression
wrapper, which appears only at the state entry or handler boundary.
counter = counter + 1
becomes:
{
"$expression": {
"operator": "=",
"target": { "$ref": "#/state/counter" },
"value": { "operator": "+", "target": { "$ref": "#/state/counter" }, "value": 1 }
}
}
Operands resolve exactly like any
$ref
. State paths are always explicit JSON Pointers (
#/state/counter
), never raw
state.counter
strings, and
$map/
paths resolve through iteration context as elsewhere.
Pure and mutating operators
Every operator is one of two modes, and the mode is never declared; it follows from the operator:
Pure operators compute and return a value, mutating nothing: unary (
!,-), arithmetic, comparison, logical, conditionals, aggregates, and the method operators below. A pure expression used as a state entry is a computed value, read via$refor${}like any other.Mutating operators write to their
targetand return nothing: assignment (=,+=,-=,*=,/=) and the array-mutation methods (push,pop,shift,unshift,splice). A mutating expression is a handler, bound to events.
{ "operator": "push", "target": { "$ref": "#/state/cart" }, "value": { "$ref": "$map/item" } }
{
"operator": "splice",
"target": { "$ref": "#/state/cart" },
"value": [{ "$ref": "$map/index" }, 1]
}
push
/
unshift
take a single
value
;
splice
takes an argument array (
[start, deleteCount, ...items]
);
pop
/
shift
take none. The full operator set is closed and enumerated in the
operator reference
. Anything outside it is a compile-time error, and logic that needs more escalates to a
function body
.
Aggregates
reduce
,
map
, and
filter
are pure operators over an array
target
. Their
value
is a per-item expression evaluated once per element, with the same
$map/
context that
mapped lists
use:
| Reference | Bound during aggregation |
|---|---|
{ "$ref": "$map/item" } |
The current array element |
{ "$ref": "$map/index" } |
The current zero-based index |
{ "$ref": "$reduce/acc" } |
The accumulator (
reduce
only) |
A cart total,
acc + item.price * item.qty
, seeded by
initial
:
{
"operator": "reduce",
"target": { "$ref": "#/state/cart" },
"initial": 0,
"value": {
"operator": "+",
"target": { "$ref": "$reduce/acc" },
"value": {
"operator": "*",
"target": { "$ref": "$map/item/price" },
"value": { "$ref": "$map/item/qty" }
}
}
}
reduce
requires
initial
;
map
and
filter
must not declare it. Aggregates compose by nesting, so a
filter
node can be the
target
of a
reduce
.
Conditionals
?:
selects between two results. Its fields follow the ECMAScript conditional:
target
is the test,
value
the result when true,
initial
the result when false. All three are required. Else-if chains nest another
?:
in
initial
.
{
"operator": "?:",
"target": { "operator": ">", "target": { "$ref": "#/state/cart/length" }, "value": 10 },
"value": "Cart full",
"initial": "Keep shopping"
}
switch
is value-keyed selection, mirroring element-level
$switch
:
target
is the discriminant,
cases
maps its
string form
to results,
default
is optional.
{
"operator": "switch",
"target": { "$ref": "#/state/status" },
"cases": { "loading": "Please wait…", "error": { "$ref": "#/state/errorMessage" } },
"default": { "$ref": "#/state/data/title" }
}
For fallbacks, prefer
??
(nullish coalescing) over
||
when
0
,
""
, or
false
are legitimate values, because it substitutes only for
null
and
undefined
.
Pure method operators
Genuine
String.prototype
,
Array.prototype
, and
Number.prototype
methods are operators too, with the receiver in
target
and arguments in
value
(a bare scalar, or an array for multiple). Only
pure
methods qualify; where the original mutates, the ES2023 change-by-copy name stands in (
toSorted
, not
sort
).
{ "operator": "toUpperCase", "target": { "$ref": "#/state/name" } }
{ "operator": "padStart", "target": { "$ref": "#/state/code" }, "value": [6, "0"] }
Evaluation is null-safe: a missing receiver yields
undefined
rather than throwing. The full method table is in the
operator reference
.
Named formulas and
call
A pure expression entry with
parameters
is a
named formula
: a reusable computation instead of a computed value. Inside its body, parameters resolve through the
$args/
scheme:
{
"lineTotal": {
"parameters": [
{ "name": "price", "type": { "text": "number" } },
{ "name": "qty", "type": { "text": "number" }, "default": 1 }
],
"$expression": {
"operator": "*",
"target": { "$ref": "$args/price" },
"value": { "$ref": "$args/qty" }
}
}
}
The
call
operator invokes it:
target
is the callee pointer,
value
the positional argument list, ordered by the declared
parameters
(omitted arguments take their
default
):
{
"operator": "call",
"target": { "$ref": "#/state/lineTotal" },
"value": [{ "$ref": "$map/item/price" }, { "$ref": "$map/item/qty" }]
}
A callee may also be a pure standard-library global through
window#/
, gated by a closed allowlist (
Math.*
,
JSON.*
,
Object.keys
/
values
/
entries
/
fromEntries
,
Number.*
,
Array.from
/
isArray
/
of
,
parseFloat
,
structuredClone
, …). Impure platform functions (
fetch
,
alert
,
Math.random
,
Date.now
) are deliberately absent; side effects belong to
Function entries
.
{
"operator": "call",
"target": { "$ref": "window#/Math/max" },
"value": [{ "$ref": "#/state/a" }, { "$ref": "#/state/b" }, 0]
}
Formulas declared in
project.json
state
are available on every page. A catalog of ready-made composite formulas ships with Studio's palette. See the
formula catalog
.
Reading the event
Handlers receive the DOM event, and the
event#/
reference scheme reads it without escalating to code. It resolves only inside an expression used as an event handler:
{
"tagName": "input",
"oninput": {
"$expression": {
"operator": "=",
"target": { "$ref": "#/state/name" },
"value": { "$ref": "event#/target/value" }
}
}
}
Where expressions appear
An
$expression
is valid in three positions:
As a
stateentry , a named, reusable operation. A pure entry is a computed value; a mutating entry is a handler you bind with"onclick": { "$ref": "#/state/toggleTheme" }, exactly like a Function entry.Inline as an event handler value on any element, in place of a
$refto a function.As an element's
tagName, described in Choosing an element's tag below.
Prefer the named form when reused; the inline form for single-use handlers.
Choosing an element's tag
Sometimes one element should be a link when it has somewhere to go and a plain box when it doesn't, wrapping the same content either way. Write the choice in
tagName
:
{
"tagName": {
"$expression": {
"operator": "?:",
"target": { "$ref": "#/state/href" },
"value": "a",
"initial": "div"
}
},
"attributes": { "href": "${state.href}" },
"children": ["…your content, written once…"]
}
Use
switch
when there are more than two, and give it a
default
, because an element with no tag can't exist:
{
"tagName": {
"$expression": {
"operator": "switch",
"target": { "$ref": "#/state/level" },
"cases": { "1": "h1", "2": "h2", "3": "h3" },
"default": "p"
}
}
}
Two rules make this different from every other formula:
Every branch is a tag name, not a formula. You choose between tags; you don't compute one. That's what lets Studio tell you at authoring time that
"A LINK"isn't a tag, instead of the page breaking in a browser.The tag is decided once, when the element is created , and isn't re-read afterwards. Changing it later would mean throwing the element away and building a new one, taking your content's focus, typed-in values and scroll position with it. If a tag needs to change in response to something, that's a
$switch, which swaps content rather than the element itself.
A component's own
tagName
, the name at the top of the file, is always a plain name, as is a
$head
entry's. Those become a custom element's registered name and a tag in the page head, which can't vary per instance.
How it works
An expression lowers to the same target a
body
string would, but the function is constructed from the node tree and never parsed from a string, so no
eval
or
new Function
is involved. A mutating expression becomes a handler over the reactive
state
proxy (
(state, event) => { state.darkMode = !state.darkMode; }
); a pure expression becomes a
computed()
, recomputing whenever the state it reads changes. See
Reactivity
.
References inside a node resolve in a fixed order:
1. $map/ — iteration context
2. $reduce/acc — fold accumulator (reduce only)
3. $args/ — named-formula parameters
4. event#/ — handler event context
5. #/state/ — current component scope
6. parent#/ — explicitly passed props
7. window#/ — global window properties
8. document#/ — global document properties
In production,
?:
and
switch
evaluate only the taken branch. In Studio's editor the interpreter runs with a trace that evaluates every branch, which is how each chip carries a live value badge.
Rules
The operator set is closed . An unknown operator is a compile-time error; escalate to a Function
bodyinstead.A mutating node may only appear at the handler boundary, never nested as an operand.
Nested nodes take no
$expressionwrapper; it appears only at the entry or handler boundary.State operands are explicit
$refpointers, never rawstate.xstrings.reducerequiresinitial;mapandfiltermust not declare it.?:requirestarget,value, andinitial.switchmatches on the discriminant's string form, because JSON keys are strings.event#/resolves only in handler position; referencing it from a non-handler entry is a compile-time error.calltargets a named formula or a blessed global; call depth is capped at 64, and statically detectable call cycles are rejected.
Related
Operator reference : every blessed token
Formula catalog : composite formulas in Studio's palette
Statements : multi-step bodies built from expression nodes
Functions and sidecars : the escape hatch to real JavaScript
Reactivity : how computed values track their inputs
Formulas and expressions in Studio : the visual editor for all of this