Skip to content

Expression Language

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.

FormScopeExample
${path}State${user.name}
@{path}Context@{item.price}
#{key}System#{now}, #{user}
LiteralType
42Int
3.14Double
’hello’String (single-quoted)
true / falseBool
null / nilNone
[1, 2, 3]Array
{‘key’: value}Dictionary

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:

"{{ ${user} + {'name': ${newName}} }}"

Operator families: ./[] (postfix access), ^ (exponentiation), * / % (multiplicative), + - (additive), < <= > >= == != ~ (comparison/contains), && || ?? ! (logical), ? : (ternary), |> (pipe).

Postfix access — .name and [index]

Any expression result — including literals, references, function calls, parenthesised sub-expressions, and nested postfix chains — may be followed by one or more postfix operators:

FormSemantics
expr.nameMember 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"]}.

"_source": "{{ first(${videoVariants}).link }}"
"_text": "{{ sort(${users})[0].email }}"
"_data": "{{ ${catalog}.products[-1] }}"
"_label": "{{ (${order} + {'status': 'done'}).status }}"

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 typeRHS typeResult
stringanyString concatenation. RHS is converted to its string representation. "px" + 5"px5"
intstringString concatenation. 3 + "px""3px"
intintInteger sum.
doubledoubleDouble sum.
int/doubledouble/intDouble sum.
dateint/doubleNew date offset forward by that many seconds.
arrayarrayConcatenated array.
arrayanyElement appended to array.
anyarrayElement prepended to array.
dictionarydictionaryMerged dictionary. RHS keys win on conflict.
otherother.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 typeRHS typeResult
intintInteger difference.
doubledoubleDouble difference.
int/doubledouble/intDouble difference.
stringstringRemoves all occurrences of RHS from LHS. "Hello World" - "World""Hello "
dateint/doubleNew date shifted back by that many seconds.
datedateDouble: time interval in seconds between the two dates.
arrayarraySet difference: elements in LHS not present in RHS.
arrayanyLHS with all matching elements filtered out.
dictionarystringDictionary with the named key removed.
noneint/doubleNegation: none - 5-5.
otherother.none

* operator — multiplication, repetition

LHS typeRHS typeResult
stringintString repeated n times. "ab" * 3"ababab"
intstringString repeated n times. 3 * "ab""ababab"
intintInteger product.
doubledoubleDouble product.
int/doubledouble/intDouble product.
otherother.none

/ operator — division, split

LHS typeRHS typeResult
stringstringSplits LHS on RHS separator. "a,b,c" / ","["a","b","c"]. Empty separator splits into individual characters.
intintAlways Double (no integer truncation). 1 / 20.5. Division by zero → .none.
doubledoubleDouble result. Division by zero → .none.
int/doubledouble/intDouble result. Division by zero → .none.
otherother.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 == nonetrue.

< <= > >= — 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.

LHS typeSemanticsExample
StringCase-sensitive substring check.{{ ‘hello world’ ~ ‘world’ }} → true
ArrayChecks whether the array contains the RHS value.{{ ${tags} ~ ‘sale’ }}
DictionaryChecks whether the dictionary has the RHS key.{{ ${config} ~ ‘theme’ }}
Primitive (bool / number)Type-incompatible — resolves to .none (falsy).${entries[hasMood ~ true]} returns empty; use [hasMood == true] instead.
{{ ${web[title ~ ${search}]} }}
// Keeps items where item.title contains the value of ${search}
{{ condition ? trueValue : falseValue }}
{{ ${count} > 0 ? @{item.label} : "No items" }}
{{ ${theme} == "dark" ? "#1A1A1A" : "#FFFFFF" }}

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 ( ):

{{ notEmpty(${winner}) ? ${winner} + ' wins' : ${moves} == 9 ? 'Tie game' : ${turn} + ' to move' }}

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.

FunctionSignatureDescription
switch()switch(test1, value1, [test2, value2, …], default)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.
{{ switch(notEmpty(${winner}), ${winner} + ' wins', ${moves} == 9, 'Tie game', ${turn} + ' to move') }}

is equivalent to, and preferred over, the nested ternary:

{{ notEmpty(${winner}) ? ${winner} + ' wins' : ${moves} == 9 ? 'Tie game' : ${turn} + ' to move' }}

A module that uses switch() SHOULD declare "version": "1.1".

FunctionSignatureDescription
upper()upper(s, [n])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’]).
prefix()prefix(s, n)Returns the first n characters of s.
suffix()suffix(s, n)Returns the last n characters of s.
FunctionSignatureReturnsDescription
count()count(v)IntCharacter count (String), item count (Array), key count (Dictionary). Returns 0 for none.
length()length(v)IntAlias for count().
empty()empty(v)BoolTrue if empty string, empty array, empty dictionary, or none.
notEmpty()notEmpty(v)BoolInverse of empty().
hasPrefix()hasPrefix(s, p)BoolTrue if string s starts with p.
hasSuffix()hasSuffix(s, s2)BoolTrue if string s ends with s2.
contains()contains(v, needle)BoolString: true if needle is a substring. Array: true if element. Dictionary: true if key.
FunctionSignatureDescription
strtojson()strtojson(s)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).
FunctionSignatureDescription
sort()sort(array, [key], [direction])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:

"_data": "{{ map(${photos}, {'kind':'image', 'url':@{item.src.large}, 'by':@{item.photographer}}) +
map(${videos}, {'kind':'video', 'url':first(@{item.video_files}).link, 'by':@{item.user.name}}) }}"

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():

"events": { "onTap": [
{ "state": {
"id": "act_capture",
"input": {
"targetId": "@{item.id}",
"targetName": "@{item.name}"
},
"output": { "success": [
{ "state": {
"id": "act_apply",
"input": {
"devices": "{{ map(${devices}, { 'id': @{item.id}, 'on': @{item.id} == ${targetId} ? true : @{item.on} }) }}"
}
} }
] }
} }
] }

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.

FunctionSignatureReturnsDescription
random()random()DoubleA random value in [0, 1).
random()random(min, max)IntA 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".

PrecedenceOperatorsCategory
90 (postfix). []Postfix member / subscript — binds tighter than all other operators
80 (unary)! + -Unary operators
8^Exponentiation
7* / %Multiplicative
6+ -Additive / string concatenation
5< <= > >= == != ~Comparison and contains
4&&Logical AND
3
2??Nil-coalesce
10 (special)? :Ternary
1 (lowest)>

ℹ Note: Use parentheses ( ) to override precedence. Parenthesized sub-expressions are always evaluated before the surrounding context.

Truthiness

ValueTruthy?
true
Non-zero number
Non-empty string
Non-empty array or object
false
0 or 0.0
"" (empty string)
[] (empty array)
{} (empty object)
null or missing path segment

Null propagation

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).

SituationBehavior
string + numberString concatenation. "count: " + 5"count: 5"
number + stringString concatenation. 3 + "px""3px"
string * intString repetition. "ab" * 3"ababab"
string / stringString 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" == 3false — cross-type equality is always false except int/double.
int / intAlways Double. 1 / 20.5, never 0.
null in arithmeticPropagates to null. null + 5.none.
null == nulltrue.
bool in string concat ("v:" + true)"v:true" — bool is rendered as its string representation.
Explicit conversioncast(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.