Skip to content

04 Expressions

Roberto Ferranti edited this page Nov 3, 2026 · 4 revisions

Expressions

Expressions are the small language inside {{ }} and data-tpl-*="...". They are parsed by a PEG grammar and interpreted over the data stack, and the interpreter calls no eval and builds no new Function of its own. A bare name resolves against the data stack and a #module:fn() call against the registered modules.

The interpreter is not a sandbox. Member access follows the prototype chain as javascript does, so s.constructor.constructor('…')() reaches the Function constructor and through it the whole page. An expression is as powerful as a script tag: write expressions as part of the page's source, and let untrusted values reach them as data, never as expression text. The threat model has the full rules.

Operators

Category Syntax
literals numbers 3 3.1, strings 'a' "a", template strings `a{var}b`, booleans, null, undefined, dict {"a": 1}, array [1, 2, 3]
member a.b, a?.b, a[b], a?.[b]
call a.toLowerCase(), a.toLowerCase?.()
logical !a, a && b, a || b, a ?? b
equality a == b, a != b
relational a < b, a > b, a <= b, a >= b
ternary a ? b : c
elvis a ?: b, which is a if truthy and b otherwise
grouping (a || b) && c

== and != compare strictly, as === and !== do in javascript: 1 == '1' is false.

There is no arithmetic and no unary minus: a + 1, a - 1 and -a are parse errors, and a computed value comes from the data or from a module function. A negative number literal such as -3 parses.

The elvis operator is the short "or default": title ?: 'untitled'. Template strings interpolate with {name} and skip null/undefined parts (they also serve as dict keys, evaluated: {`{k}`: 1} builds an entry named by the value of k):

<p>{{ `{user.first} {user.last}` }}</p>

The placeholder is {name}, not javascript's ${name}: in an ftl template string ${name} renders a stray $ before the value. In a static template written as a javascript template literal, a ${name} never reaches ftl: javascript evaluates it once, when the class is defined, so a template there writes {{ name }}, and {name} inside an ftl template string.

Identifiers and navigation

data = {
    parent: { nested: { text: "I'm the nested obj", empty: null, fruits: ['apple', 'pear'] } },
    today: 'tue',
    carToPick: { mon: 'Ferrari', tue: 'Lambo', wed: 'Porsche' },
}
Expression Result
parent.nested.text "I'm the nested obj"
parent.nested.empty?.text undefined (null-safe chain)
carToPick[today] "Lambo" (computed access)
self the current context (top of the data stack)

Plain identifiers resolve by scanning the data stack top-down: the most recently pushed overlay containing the property wins. An identifier is taken whole: nullable, falseFlag or truex are plain names, the keywords counting only as exact words, so a data field may carry any name. self is exactly the top overlay, so inside data-tpl-each="items" with no var, {{ self }} is the current item.

Method calls run on the resolved value with optional chaining:

<p>{{ parent.nested.fruits.join(', ') }}</p>

Module functions

Functions you cannot reach through the data are registered as modules and called as #module:name(...):

ftl.registry.defineModule('math', {
    sum: (...addends) => addends.reduce((a, b) => a + b, 0),
});
<p>{{ #math:sum(1, 5, 3, 65) }}</p>   <!-- 74 -->

Only templates bound to the registry see its modules: those made with ftl.Templates.*, and a component's this.template(). A template made with ftl.Template.* sees only the modules passed to its factory, and a bound template keeps the modules defined when it was bound, so defineModule comes first. A bare #name(...) resolves against the whole modules object. Module functions are plain javascript functions: whatever they return is the expression's value, and they run with a proxy over the data stack as this, which is how Localization reads the active messages.

Where expressions run

  • data-tpl-*="expr": the whole attribute value is one expression.
  • {{ expr }} inside text, in MODE_TEMPLATED: text and expressions can mix, with the three interpolation forms ({{ }}, {{{ }}}, {{{{ }}}}).
  • template.evaluateExpression(expression, ...data) for one expression and template.evaluateTemplated(text, ...data) for the {{ }} form, each in the template's scope widened by the data. An ExpressionEvaluator gives the same language from javascript, for instance inside a component:
const value = this.template().evaluateExpression('config.fields.length');
const evaluator = ftl.registry.evaluator();   // registry modules + data, ready to evaluate

ftl.Expressions.parse caches ASTs (bounded FIFO), so repeated evaluation of the same expression is cheap.

Clone this wiki locally