StemJSON expressions are inline computed values. An expression is any string value wrapped in double curly braces: {{ }}.
ℹ Note: Plain state and context references — ${key} and @{key} — are valid on their own without {{ }} wrappers. Use {{ }} only when combining references with operators, function calls, or mixed content.
Array literals support full expressions as elements:
"{{ [1, 'hello', true] }}"
"{{ [${x}, ${y} + 1, ${flag} ? 'a' : 'b'] }}"
"{{ [] }}"
"{{ [[1, 2], [3, 4]] }}"
"{{ ${existing} + [${newItem}] }}"
Each element is evaluated at runtime. State refs, context refs, arithmetic, ternary, and function calls are all valid inside array literals.
Dictionary literals use single-quoted string keys and expression values:
"{{ {'name': 'Alice', 'age': 30} }}"
"{{ {'done': true, 'count': ${n} + 1} }}"
"{{ {} }}"
"{{ {'items': [1, 2, 3]} }}"
"{{ ${item} + {'done': true} }}"
Keys must be string literals. Values are full expressions evaluated at runtime. The + operator merges dictionaries (RHS keys win), enabling field-level updates:
Any expression result — including literals, references, function calls, parenthesised sub-expressions, and nested postfix chains — may be followed by one or more postfix operators:
Form
Semantics
expr.name
Member access. Resolves to the named key of expr when it is a dictionary, else .none.
expr[indexExpr]
Subscript access. Integer index into arrays (negative from tail); string index into dictionaries.
Chaining is left-associative: expr.a.b[0].c is equivalent to ((((expr.a).b)[0]).c).
ℹ Expression postfix […] is index/key only. Filter predicates of the form [field ~ val] are a path-only construct (see §6.2.1) — they work inside a ${…} / @{…} path, NOT as a postfix on an arbitrary expression. ${devices}[room ~ "living"] is parsed by the expression engine as a subscript whose value is the boolean room ~ "living", which then resolves to .none. Write filters inside the path: ${devices[room ~ "living"]}.
Equivalence with path syntax.${arr[0]} and ${arr}[0] resolve to the same value. The path form is evaluated inside the path resolver (see §6.2.1); the postfix form is evaluated by the expression engine. Prefer the path form when the whole chain is rooted at a single reference — it is terser and produces a flatter AST. Use postfix operators when the base is a function call, literal, or parenthesised expression.
Null propagation. Applying .name to a non-dictionary value, or [n] to a non-array value (or out-of-bounds index), resolves to .none. Downstream operators receive .none and propagate it per §8.8. No runtime error is raised.
+ operator — addition, concatenation, append
LHS type
RHS type
Result
string
any
String concatenation. RHS is converted to its string representation. "px" + 5 → "px5"
int
string
String concatenation. 3 + "px" → "3px"
int
int
Integer sum.
double
double
Double sum.
int/double
double/int
Double sum.
date
int/double
New date offset forward by that many seconds.
array
array
Concatenated array.
array
any
Element appended to array.
any
array
Element prepended to array.
dictionary
dictionary
Merged dictionary. RHS keys win on conflict.
other
other
.none
ℹ Note: There is NO implicit string-to-number coercion. "3" + 2 evaluates to "32" (string), not 5. Use cast() for explicit type conversion.
- operator — subtraction, removal
LHS type
RHS type
Result
int
int
Integer difference.
double
double
Double difference.
int/double
double/int
Double difference.
string
string
Removes all occurrences of RHS from LHS. "Hello World" - "World" → "Hello "
date
int/double
New date shifted back by that many seconds.
date
date
Double: time interval in seconds between the two dates.
array
array
Set difference: elements in LHS not present in RHS.
array
any
LHS with all matching elements filtered out.
dictionary
string
Dictionary with the named key removed.
none
int/double
Negation: none - 5 → -5.
other
other
.none
* operator — multiplication, repetition
LHS type
RHS type
Result
string
int
String repeated n times. "ab" * 3 → "ababab"
int
string
String repeated n times. 3 * "ab" → "ababab"
int
int
Integer product.
double
double
Double product.
int/double
double/int
Double product.
other
other
.none
/ operator — division, split
LHS type
RHS type
Result
string
string
Splits LHS on RHS separator. "a,b,c" / "," → ["a","b","c"]. Empty separator splits into individual characters.
int
int
Always Double (no integer truncation). 1 / 2 → 0.5. Division by zero → .none.
double
double
Double result. Division by zero → .none.
int/double
double/int
Double result. Division by zero → .none.
other
other
.none
% operator — modulo
Numeric types only (int/double combinations). Division by zero → .none. Non-numeric types → .none.
== / != — equality
Strict by type. int vs double is cross-compared numerically. All other cross-type comparisons return false — no implicit coercion. none == none → true.
<<=>>= — ordering
Same type or int/double cross-type only. Incompatible types (e.g. string vs array) return false.
Pipe |>
{{${rawJson} |> strtojson() }}
// equivalent to: strtojson(${rawJson})
{{${name} |> trim() |> upper() }}
// equivalent to: upper(trim(${name}))
{{${name} |> replace('a', 'b') }}
// equivalent to: replace(${name}, 'a', 'b')
The piped value binds as the first argument of the right-hand function; any arguments written in the call follow it.
Chained ternaries take NO parentheses. The ternary is right-associative and
has the lowest precedence of any operator except the pipe (|>). A multi-way
choice therefore chains directly — the else branch may be a further ternary
with no wrapping ( ):
This parses as cond1 ? a : (cond2 ? b : c) automatically. Do NOT add the
parentheses yourself: a ? b : (c ? d : e) and a ? b : c ? d : e are
equivalent, but the parenthesized form is the dominant source of
unbalanced-) syntax errors in generated modules — one stray closing paren
makes the whole expression fail to parse (Expected end of input). Always write
the flat, paren-free chain. Use ( ) only to override the natural precedence of
the conditions (e.g. grouping an &&/|| test), never to wrap a nested
ternary. For three or more branches, switch() (§8.6) is the preferred flat
alternative — it removes the nesting entirely.
Flat multi-way conditional. Evaluates the tests left to right and returns the value paired with the first test that evaluates to boolean true. If no test matches, returns the trailing default. Available from spec 1.1.0.
switch() is the flat alternative to nested ternaries for three or more
branches. It evaluates lazily and short-circuits, exactly like the ternary:
tests are evaluated in order only until one is true, then the paired value is
evaluated and returned; no later test, no unmatched value, is ever evaluated
(so an expression that would error in an unmatched branch is harmless).
Arity is always odd: N test, value pairs followed by one mandatory
default (minimum 3 arguments). A default is required — there is no
implicit none fall-through.
Each test must evaluate to a boolean; a non-boolean test is treated as
not-matched.
value and default may be of differing types, like the ternary’s branches.
Uppercase. With positive n: first n chars. With negative n: last
lower()
lower(s, [n])
Lowercase. Same n behavior as upper().
trim()
trim(s)
Strips leading and trailing whitespace.
replace()
replace(s, from, to)
Replaces all occurrences of from with to. On a string: substring replacement. On an array: whole-element replacement — each element equal to from becomes to (e.g. replace([‘a’,‘b’,‘a’], ‘a’, ‘c’) → [‘c’,‘b’,‘c’]).
Parses a JSON-encoded string into a structured value. Returns none on failure.
localize()
localize(code, msg, [args])
Looks up l10n://code. Falls back to msg if the key is not found.
cast()
cast(v, type)
Casts value v to: int | double | float | number | string | str | bool | boolean | expression | expr | date. Date conversions:date → str returns the locale-formatted display string (e.g. "5/21/2026, 4:14 PM"), not an epoch timestamp. date → int returns 0 (no epoch conversion). For date arithmetic / comparison / sorting, keep the value as date rather than casting.
format()
format(v, descriptor)
Applies a format descriptor (same as the _format context key, see §4.3).
Sorts array. direction: “asc” (default) or “desc”. With key, sorts dictionaries by that key.
first()
first(array, [key])
Returns the first element, or none if empty.
last()
last(array, [key])
Returns the last element, or none if empty.
min()
min(array, [key])
Scalar array: smallest value. Dictionary array with key: full element with smallest key.
max()
max(array, [key])
Scalar array: largest value. Dictionary array with key: full element with largest key.
sum()
sum(array, [key])
Sums all numeric values. Returns 0 for empty array.
map()
map(array, template)
Per-element transform. The template is re-evaluated for each item with @{item} and @{index} rebound. Returns a new array of the same length. Returns .none if array does not resolve to an array.
range()
range(n) | range(start, end)
Builds an integer sequence: range(n) → [0, 1, … n-1]; range(start, end) → [start … end-1] (end exclusive). An empty or inverted span yields []. Pairs with map() to generate structured data without hardcoding — see §8.6.1. Available from spec 1.1.0.
setAt()
setAt(array, index, value)
New array with the element at index replaced by value. A negative index counts from the tail (matching subscript). An out-of-range index (including index == count) or a non-array subject returns the subject unchanged — setAt only sets existing positions; append with ${array} + value. Immutable. Available from spec 1.1.0.
removeAt()
removeAt(array, index)
New array with the element at index removed. Negative index from the tail; out-of-range or non-array returns the subject unchanged. Immutable. Available from spec 1.1.0.
insertAt()
insertAt(array, index, value)
New array with value inserted before index; index == count appends. Negative index from the tail; out-of-range or non-array returns the subject unchanged. Immutable. Available from spec 1.1.0.
keys()
keys(dict)
Array of the dictionary’s keys, sorted ascending. A stable order is required because the dictionary is unordered and render output must be a pure function of state. A non-dictionary subject yields []. Available from spec 1.1.0.
values()
values(dict)
Array of the dictionary’s values, ordered to match keys(dict) (index-aligned). A non-dictionary subject yields []. Available from spec 1.1.0.
removeKey()
removeKey(dict, key)
New dictionary without key. An absent key or non-dictionary subject returns the subject unchanged. To set or add a key, use ${dict} + {'key': value}. Immutable. Available from spec 1.1.0.
ℹ Positional edits & duplicate-aware reads.setAt / removeAt / insertAt are the only way to edit an array element by position (append / concat / dict-merge already exist via +; filter via path predicates). They are evaluated in the surrounding expression scope, so they sidestep the map() shadowing rule (§8.6) for the common “set the cell at the tapped @{index}” case — write setAt(${board}, @{index}, …) directly in the action, not inside a map(). Function names are case-sensitive (setAt, not setat).
map() — per-element transform
The second argument is an arbitrary expression — typically a dictionary literal that shapes each element into a unified record, but any expression is valid. Inside the template, @{item} is the current element and @{index} is the zero-based position. Combine with + for heterogeneous concatenation:
This is the canonical pattern for a heterogeneous feed (mixed photo + video posts, search results, news + ads, etc.). After normalising shapes, render with a single dynamic whose prototype switches on @{item.kind} via conditional.
ℹ Note: lazy-template semantics. Unlike all other built-ins, map() does NOT pre-resolve its template argument before calling. The template is evaluated once per element — necessary so that @{item} and @{index} rebind. Authoring tools and validators MUST treat the template as deferred.
⚠ @{item} / @{index} shadow the outer scope. When map() is nested inside another iteration (a dynamic prototype, an enclosing map(), etc.), the inner @{item} shadows the outer one — there is no alias syntax to refer to the outer item from inside the template. If you need both, capture the outer item’s fields into state keys first via a two-step state action, then read them back with ${…} refs inside the map() template.
Canonical two-step capture pattern — button inside an outer dynamic whose onTap needs to rebuild a separate array with map():
The capture pattern: in an earlier event (e.g. onAppear of the surrounding screen, or as a prior step in an async chain like repo.success), write the outer @{item} value into a module state key — e.g. ${targetId}. Then any later map() / dynamic inside that screen can safely use ${targetId} to access the captured outer value; its own inner @{item} rebinds to the iteration element without conflict.
A module declares generated data instead of hardcoding it, so games and puzzles can produce a fresh board on every start or reset.
Function
Signature
Returns
Description
random()
random()
Double
A random value in [0, 1).
random()
random(min, max)
Int
A random integer in [min, max]inclusive (e.g. random(1, 6) for a die). Reversed bounds are tolerated (swapped). Select a random element with random(0, count(${arr}) - 1).
random() is nondeterministic — it yields a different value each time it is resolved, and results are not reproducible across platforms or runs (there is no seed).
⚠ Evaluate once, freeze into state — never in a render binding. Expressions are re-resolved on every render. A random() in a binding (a text, a style, a visibility test) would therefore re-roll on every render and the UI would flicker. random() MUST be used only in action / lifecycle value expressions — e.g. the value of a state action fired from onAppear or a “New Game” button — where it is resolved once and the concrete result is frozen into state. The render reads the stable state value. A reset is simply re-running that action.
Structured generation. Combine random() with range() and map() to generate grids and boards declaratively, with no hardcoded data. A fresh Sumplete 5×5 grid, generated on onAppear and re-generated by a “New Game” button:
"events": { "onAppear": [
{ "state": { "id": "act_new_game", "input": {
"grid": "{{ map(range(25), random(1, 9)) }}"
} } }
] }
Each of the 25 cells is an independent draw because map() re-resolves its template per element (§8.6). Because the result is written through a state action it is frozen — the grid stays stable between renders and changes only when the action runs again.
A module that uses random() or range() SHOULD declare "version": "1.1".
If any segment of a path expression resolves to null or does not exist, the entire expression evaluates to null — not an error. Example: {{ ${user.address.city} }} returns null if user is null or address is missing. Downstream arithmetic or string operations on null also evaluate to null, not an error.
Type semantics — no implicit coercion
StemJSON operators do not coerce types implicitly. The result type is determined by the LHS operand and the operator. Use cast(v, type) for explicit conversion (see §8.6).
Situation
Behavior
string + number
String concatenation. "count: " + 5 → "count: 5"
number + string
String concatenation. 3 + "px" → "3px"
string * int
String repetition. "ab" * 3 → "ababab"
string / string
String splitting. "a,b,c" / "," → ["a","b","c"]
"3" + 2
"32" — NOT 5. No numeric coercion of strings.
"50" * 2
"5050" — string repetition, NOT 100.
"3" == 3
false — cross-type equality is always false except int/double.
int / int
Always Double. 1 / 2 → 0.5, never 0.
null in arithmetic
Propagates to null. null + 5 → .none.
null == null
true.
bool in string concat ("v:" + true)
"v:true" — bool is rendered as its string representation.
Explicit conversion
cast(v, "int"), cast(v, "string"), etc.
ℹ Common pitfall — textfield and picker values are always strings. A textfield bound to ${billAmount} stores "50" (string), not 50 (number). Arithmetic like {{ ${billAmount} * 2 }} produces "5050" (string repetition), not 100. Always cast first: {{ cast(${billAmount}, 'double') * 2 }}.
Failure behavior
If a function receives an argument of an unexpected type (e.g. length() called on a number), the expression evaluates to null. Runtime expression errors are silent — they produce null rather than blocking rendering or reporting a user-visible error.
Path resolution order
When evaluating ${key}, the runtime resolves the reference within the current module’s declared state only. If the key is not declared in the current module, the reference resolves to .none (null). There is no cross-module read resolution: parent, child, and sibling modules’ state are not accessible from outside the declaring module.
Context references @{key} behave differently from state: author-declared (non-underscore-prefixed) context keys DO propagate down across every nested component, including across module boundaries. When a module is embedded as a child (under tab, navigation, dynamic.prototype, link.destination, or any other component), the parent’s context is merged into the child module’s context at init time. Inside the child, @{key} therefore resolves against the chain child's own context → parent context → … → root context, with the nearest binding winning.
Internal _-prefixed context keys (e.g. _text, _source, _value) are component-local and do NOT merge across — each component declares its own. This is the canonical way to thread shared data (a dataset, a selected id, a search string, a filter) from a root module into every nested view without duplicating it as state.