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

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.

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.