Skip to content

04 Expressions

Roberto Ferranti edited this page Nov 10, 2026 · 5 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, so this.name resolves name as a bare identifier would, innermost overlay first.

That lookup sees the rows and responses a render pushes, which is wrong for what the page configured once. this[ftl.Expressions.BOUND](name) resolves the name in the bound data alone: the layers the scope was created with (the registry's defineData/defineOverlay layers, or the data handed to a Template.* factory), the most recent one winning, and never an overlay pushed afterwards by data-tpl-each, data-tpl-with or withOverlay. It is how Localization reads the messages and the locale, so no row can supply or replace them.

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