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