A Unified Expression Language for Template Engines

Draft Specification (source)

2026-07-15

Abstract

This document defines a unified, implementation‑independent expression language for Liquid-style templates.

This specification focuses strictly on the internal mechanics of expressions. Notably, this document does not define the surrounding tag architecture (such as the specific implementation of {% if %} or {% for %}) nor does it provide a “standard library” of filters.

Status

Draft - specification in progress. Feedback and test cases are welcome.

1 Introduction

This specification defines the behavior of a standalone expression evaluator. In practice, this evaluator is intended to function as a component within a larger template engine.

While the template engine manages higher-level concerns - such as file I/O, tag orchestration ({% ... %}), and the provision of a global execution context - this document governs the internal logic of Expressions. By decoupling the expression grammar from the engine’s tag architecture, we ensure that data resolution, mathematical operations, and filter pipelines remain consistent regardless of the tag or context in which they appear.

1.1 Terminology

  • Template: The source document or string containing a mix of static text (to be rendered literally) and dynamic Markup. It serves as the primary unit of execution for the engine.

  • Template Engine: The software system that orchestrates the transformation of a Template into a rendered output. It is responsible for parsing the surrounding Markup, managing the execution context (environment), and invoking the expression evaluator to resolve Expressions, Filters, and Lambdas as defined in this specification. While the engine handles high-level concerns like file loading and Tag execution, it relies on the Unified Expression Grammar for all data resolution and computation.

  • Expression: An expression is the fundamental unit of computation within a template, representing a sequence of identifiers, literals, and operators that resolves to a value. They appear within output delimiters to render data directly to the page (e.g., {{ user.name | upcase }}), inside conditional tags to govern template logic (e.g., {% if item.price > 100 %}), and as the data sources for iterations (e.g., {% for product in collections.frontpage %})

    For each of the preceding examples, this tables isolates the expression part of the markup to illustrate some of the places an expression can appear.

    Markup Expression
    {{ user.name | upcase }} user.name | upcase
    {% if item.price > 100 %} item.price > 100
    {% for product in collections.frontpage %} collections.frontpage
  • Markup: The collective term for the specialized syntax (delimited by {{ and {%) embedded within a template. Markup distinguishes dynamic instructions - such as output statements and tags - from static content.

  • Tag: A structural command used to govern control flow, iteration, or state management (e.g., if, for, assign). While this specification defines the grammar of expressions within tags, the implementation of the tags themselves is considered part of the surrounding architecture.:

  • Filter: A named, total function registered in the environment that transforms a value. Filters are invoked within a pipeline using the pipe (|) syntax, where they accept the result of the preceding expression as their primary input.

  • Lambda: An anonymous inline function. Lambda expressions allow for user-defined filters and custom mapping or sorting logic to be applied to data structures.

  • Total Evaluation: An expression language has total evaluation if every syntactically valid expression evaluates to a value and does not raise a runtime error. Every operator, filter, and conversion must produce a value for every possible input.

  • Total Function: A function is total if it is defined for every possible input in its domain - the set of inputs the function accepts.

1.2 History

This specification is based on Shopify’s Liquid project and other implementations derived from Shopify’s reference implementation.

Historically, Liquid expressions have varied subtly depending on context - certain tags treated operators or filters differently, and precedence rules were not globally consistent.

This document aims to replace ad hoc behavior with a single, composable, context-independent grammar and a clear evaluation model. All operators, filters, and grouping constructs are valid in any expression context, with a single, well-defined precedence hierarchy.

Compatibility with existing Liquid implementations is explicitly not a goal.

1.3 Overview of Unified Expressions

An expression is a composable sequence of components that, when evaluated against a context, produces a single \(RuntimeValue\). This language is total and immutable: every syntactically valid expression resolves to a value, and no expression can modify the state of the execution context.

Formally, for every expression \(e\) and environment \(\rho\):

\[⟦ e ⟧(\rho) \in RuntimeValue\]

Expressions are composed of the following six building blocks:

1.3.1 Literals

Literals are textual representations of fixed values encoded directly in the template source, including:

  • Primitives: Strings (UTF-8), Numbers (High-precision decimals), Booleans (true/false), and Null.
  • Collections: Array literals ([1, 2, 3]) and Object literals ({ key: value }).
  • Ranges: Range literals (1..5) which evaluate to an inclusive sequence of integers.

1.3.2 Variables and Paths

Variables are identifiers used to retrieve data from the execution context. They support deep resolution via segmented path syntax:

  • Dot Notation: user.profile.name
  • Bracket Notation: user["profile"]["name"] or items[index]. If a variable or any segment of a path does not exist, the expression resolves to \(Nothing\).

1.3.3 Operators

Operators perform functional computations or logical comparisons.

  • Arithmetic: Standard mathematical operations (+, -, *, /, %) and unary negation (-).
  • Comparison: Equality (==, !=) and relational (<, <=, >, >=) checks.
  • Logical: Boolean logic (and, or, not) used for truthiness branching.
  • Coalescence: The orElse operator provides a fallback specifically for \(Nothing\) values.
  • Membership: The in and contains operators for checking if a value exists within a collection or a substring exists within a string.

1.3.4 Filters and Pipelines

Filters are named transformations applied to a value via the pipe (|) syntax. They are designed to be chained into pipelines, where the output of one filter serves as the primary input for the next (e.g., product.price | times: 1.1 | round: 2). Filters may accept:

  • Positional Arguments: filter: arg1, arg2
  • Keyword Arguments: filter: key="value"

1.3.5 Lambdas

Lambdas are anonymous, inline functions (e.g., item => item.price > 10). They are first-class objects that can be “called” as a user-defined filter with the pipe operator (|), or passed as arguments to filters to delegate logic back to the expression engine, enabling powerful operations like custom sorting, mapping, and searching within collections.

1.3.6 Control Expressions

  • Conditional (Ternary): The ... if ... else ... construct allows for inline branching based on the truthiness of a condition (e.g., score if score > 50 else "Fail").
  • Grouping: Parentheses ( ... ) are used to override default operator precedence or to clarify the evaluation order of complex nested expressions.

2 Type System for Template Expressions

The expression language operates on a data model that is conceptually “JSON-like”. Every expression, when evaluated, resolves to a value belonging to one of the fundamental types defined in this section.

The core types - Null, Boolean, Number, String, Array, and Object - map directly to JSON types. Any valid JSON document can be represented naturally within this expression language.

All values in the data model are immutable. Operations that appear to “modify” a value (such as filter applications) always return a new value.

To allow templates to interact with complex application logic, the language supports “Drops”. These are opaque extension types provided by the host environment that can implement specific protocols for iteration, property access, or equality.

2.1 Values

\(DataValue\) defines all possible kinds of value that can exist inside the template data model.

\[ \begin{aligned} DataValue = \;& Null \\ | \;& Boolean \\ | \;& Number \\ | \;& String \\ | \;& Array\langle DataValue \rangle \\ | \;& Object\langle String \to DataValue \rangle \\ \end{aligned} \]

\(RuntimeValue\) describes the result of evaluating an expression. Note that \(DataValue\) is the subset of \(RuntimeValue\) that does not contain \(Drop\) or \(Nothing\).

\[ \begin{aligned} RuntimeValue = \;& DataValue \\ | \;& Array\langle RuntimeValue \rangle \\ | \;& Object\langle String \to RuntimeValue \rangle \\ | \;& Drop \\ | \;& Nothing \end{aligned} \]

\(Nothing\) represents the absence of a value produced during evaluation and is distinct from \(Null\) (or implementation-specific nil, None, undefined etc.).

2.2 Numeric Types

\(Number\) represents a decimal numeric value with arbitrary precision and exact decimal semantics.

Implementations MUST perform numeric operations using a decimal arithmetic model. Binary floating-point (e.g., IEEE-754 double) MUST NOT be used as the semantic numeric model.

An implementation MAY use binary floating-point internally, but observable behavior MUST match exact decimal arithmetic.

Integer values are a subset of Number. A number is considered an integer when its decimal representation has no fractional component.

2.2.1 Decimal Arithmetic Model

The language defines numbers using base-10 decimal semantics:

  • Exact representation of finite decimal literals.
  • Exact addition, subtraction, and multiplication.
  • Exact division when representable as a finite decimal.
  • Deterministic rounding when required.

This ensures:

0.1 + 0.2 == 0.3   → true

in all conforming implementations.

Implementations MUST NOT introduce binary floating-point rounding artifacts.

Example (required behavior):

0.1 + 0.2

MUST evaluate to a number equal to decimal 0.3.

It MUST NOT produce:

0.30000000000000004

2.2.2 Division Semantics

Division may produce a non-terminating decimal expansion.

Example:

1 / 3

TODO: Loosen precision requirements

Implementations MUST use decimal division with a minimum precision of 28 decimal digits and MUST round using round-half-even (banker’s rounding), unless a higher precision is supported.

The precision used MUST be consistent within an evaluation.

2.2.3 Numeric Equality

Numeric equality is mathematical equality after decimal normalization.

Examples:

1 == 1.0        → true
0.30 == 0.3     → true

2.2.4 String Conversion

\(ToString(Number)\) MUST produce a canonical decimal representation:

  • No scientific notation.
  • No unnecessary trailing zeros.
  • No trailing decimal point.

TODO: true division
TODO: no decimal point when operands are integers and result is whole

Examples:

1       → "1"
1.0     → "1"
0.300   → "0.3"
1000    → "1000"

2.3 Iterables

Some evaluation algorithms operate on iterable values. An \(Iterable\) is either an \(Array\) or \(Sequence\):

\[ Iterable = Array\langle RuntimeValue \rangle | Sequence \]

Where \(Sequence\) is a \(Drop\) implementing the Sequence protocol.

Array and sequence drops MUST behave identically with respect to iteration semantics.

2.4 Data Type Summary

Type Description JSON Equivalent
Nothing Represents the absence of a data value. N/A
Null Represents a deliberate “empty” data value. null
Boolean Logical true or false. true, false
Number An exact decimal representation (see Section 2.2). number
String A sequence of Unicode characters. string
Array An ordered list of values. array
Object A collection of key-value pairs (where keys are Strings). object
Drop A host-provided extension type. N/A
Iterable An array, or drop implementing the sequence protocol N/A

2.5 Extension Types (Drops)

Implementations may expose developer-defined objects known as Drops. A Drop is an object that can be coerced into a data value when required, with the help of a context hint.

\(Drop\) is defined as an object implementing \(ToPrimitive\).

\[ ToPrimitive : Drop × ContextHint → RuntimeValue \]

Where:

\[ ContextHint ∈ { data, numeric, string, boolean, array, object } \]

With the following constraints:

\[ \begin{aligned} ToPrimitive(drop, data) \;& → DataValue \\ ToPrimitive(drop, boolean) \;& → Boolean | Nothing \\ ToPrimitive(drop, numeric) \;& → Number | Nothing \\ ToPrimitive(drop, string) \;& → String | Nothing \\ ToPrimitive(drop, array) \;& → Array\langle RuntimeValue \rangle | Nothing \\ ToPrimitive(drop, object) \;& → Object\langle String \to RuntimeValue \rangle | Nothing \\ \end{aligned} \]

The result of \(ToPrimitive(drop, data)\) MUST be a valid \(DataValue\) as defined above, meaning it MUST NOT contain \(Drop\) at any depth.

The following table shows when each hint applies.

Context Hint
Arithmetic numeric
String concatenation string
Boolean test (if, and, or) boolean
Comparison data
Filter arguments (general) data
Array literal spread, eager filter arguments array
Object literal spread object

2.5.1 Sequence Protocol

A Drop MAY implement the Sequence protocol to facilitate lazy iteration with the for tag or sequence aware filters.

\(Sequence\) is defined as a \(Drop\) that supports the following abstract behavior.

\[ \begin{aligned} length() \;& \to Number \\ slice(offset, limit, reversed) \;& \to Drop \\ iterate() \;& \to Iterator\langle RuntimeValue \rangle \\ \end{aligned} \]

Constraints:

  • \(length()\) MUST reflect the current logical sequence.
  • \(slice()\) MUST return a Drop implementing the Sequence protocol.
  • \(iterate()\) MUST yield exactly length() elements.

2.5.2 Equality Protocol

A Drop MAY implement the Equality protocol for interaction with == and != operators, without first coercing to a data value.

\(Equals\) MUST NOT throw an error.

\[ Equals : Drop × RuntimeValue → Boolean | Nothing \]

A Drop implements the Equality protocol if it supports:

\[ Equals(x) -> Boolean | Nothing \]

2.5.3 Ordering Protocol

A Drop MAY implement the Ordering protocol for interaction with <, >, <=, and >= operators, without first coercing to a data value.

\(LessThan\) MUST NOT throw and error.

\[ LessThan : Drop × RuntimeValue → Boolean | Nothing \]

A Drop implements the Ordering protocol if it supports:

\[ LessThan(x) -> Boolean | Nothing \]

2.5.4 Membership Protocol

A Drop MAY implement the Membership protocol for interaction with in and contains operators, without first coercing to a data value.

\(Contains\) MUST NOT throw an error.

\[ Contains : Drop × RuntimeValue → Boolean | Nothing \]

A Drop implements the Membership protocol if it supports:

\[ Contains(x) -> Boolean | Nothing \]

2.6 Type Conversion

Automatic type conversion is performed in some contexts. Here we define abstract conversion functions for runtime values, each of which is deterministic and never throws an error.

\[ \begin{aligned} ToBoolean \;&: RuntimeValue \to Boolean \\ ToNumber \;&: RuntimeValue \to Number | Nothing \\ ToString \;&: RuntimeValue \to String \\ ToArray \;&: RuntimeValue \to Array\langle RuntimeValue \rangle \\ ToObject \;&: RuntimeValue \to Object\langle String \to RuntimeValue \rangle \\ ToIterable \;&: RuntimeValue \to Sequence | Array\langle RuntimeValue \rangle \\ \end{aligned} \]

Implicit conversions occur in the following contexts (each uses the corresponding abstract conversion function):

TODO: turn this into a table

  • Arithmetic and numeric operators: ToNumber
  • Unary +/-: ToNumber
  • String concatenation (filters): ToString
  • Boolean conditions used by if, ternary if expressions, and, or, and not: ToBoolean
  • Comparisons that require primitive values: ToPrimitive(…, data) then structural comparison; numeric comparisons use ToNumber when both sides are numeric or coercible to numeric.
  • for iterable expressions: ToArray / ToPrimitive(…, iterable)
  • Filter arguments (general): ToPrimitive(…, data) unless a filter documents a different required hint
  • ToArray helper and sequence normalization: ToArray

2.6.1 Truthiness and ToBoolean(x)

The language adopts structural truthiness. Empty strings, empty arrays, empty objects, zero numbers, null, and absence (Nothing) are falsy. All other values are truthy. This rule is uniform across value types and does not depend on host-language semantics.

Conditions are evaluated by first evaluating the condition expression and then applying ToBoolean to the result.

The abstract operation ToBoolean is defined to be identical to IsTruthy.

\[ ToBoolean, IsTruthy : RuntimeValue → Boolean \]

An evaluation result is truthy if it represents a non-empty, non-zero, non-null value.

Input Type Result
Nothing false
Null false
Boolean identity
Integer x ≠ 0
Float x ≠ 0.0
String length(x) > 0
Array length(x) > 0
Object size(o) > 0
Drop ToPrimitive(x, boolean); false if Nothing

2.6.2 ToNumber(x)

Returns either Integer or Decimal.

\[ ToNumber : RuntimeValue → Number | Nothing \]

Input Type Result
Integer identity
Float identity
Boolean true → 1, false → 0
Nothing Nothing
Null Nothing
String parse numeric literal; if invalid → Nothing
Array Nothing
Object Nothing
Drop ToPrimitive(x, numeric)

2.6.3 ToString(x)

\[ ToString : RuntimeValue → String \]

Input Type Result
String identity
Integer decimal representation
Float canonical decimal
Boolean "true" or "false"
Null ""
Nothing ""
Array JSON-formatted array
Object JSON-formatted object
Drop ToPrimitive(x, string); "" if Nothing

2.6.4 ToArray(x)

\[ ToArray : RuntimeValue → Array<RuntimeValue> \]

Input Type Result
Array identity
Object array of [key, value] pairs
String Array
Null []
Nothing []
Drop ToPrimitive(x, array) or [] if Nothing
Any other value [x]

2.6.4.1 String Semantics

If x is a String, ToArray(x) produces an array containing the string’s Unicode scalar values in order.

\[ ToArray(String) → Array<String> \]

Where each element is a single Unicode scalar value encoded as a string of length 1 (not grapheme clusters).

Example:

\[ ToArray("cat") → ["c", "a", "t"] \]

Unicode example:

\[ ToArray("😀a") → ["😀", "a"] \]

2.6.5 ToObject(x)

\[ ToObject : RuntimeValue → Object<String → RuntimeValue> \]

Input Type Result
Object identity
Drop ToPrimitive(x, object) or {} if Nothing
Any other {}

2.6.6 ToIterable(x)

\[ ToIterable : RuntimeValue → Iterable \]

Input Type Result
Sequence (Drop) identity
Any other ToArray(x)

3 Syntax and Semantics

TODO: overview

The grammar is written using Parsing Expression Grammar (PEG) semantics following the notation introduced by Bryan Ford. Rule definitions and operators follow Ford-style PEG notation (<-, /, *, +, ?, !, &). Literal syntax, escape sequences, and bounded repetition follow the conventions of Pest (parser generator).

TODO: intro sentence

Expression ← TernaryExpr

B          ← " " / "\t" / "\r" / "\n"    // Whitespace
S          ← B*                          // Optional whitespace
C          ← IdentifierChar              // Alias for any name character

TODO: Elaborate on Nothing.

Implementations MAY surface evaluation of Nothing as errors, warnings, or diagnostics, but MUST preserve the observable semantics defined by this specification when execution continues.

3.1 Literals

Literal ← NumberLiteral /
          StringLiteral /
          BooleanLiteral /
          NullLiteral /
          ArrayLiteral /
          ObjectLiteral /
          RangeLiteral

3.1.1 Null Literal

3.1.1.1 Syntax

The language provides two semantically indistinguishable keywords, null and nil, to support different developer preferences.

NullLiteral ← ("null" / "nil") !C

Note: The negative lookahead !C ensures that identifiers beginning with a Null keyword (e.g., nullify) are not incorrectly parsed as literals.

3.1.1.2 Semantics

The null literal represents a deliberate “empty” value within the data model. It is a concrete data value. It is distinct from Nothing, which is the internal signal used to represent evaluation failures, missing variables, or out-of-bounds access.

A null literal always evaluates to a value of type Null. In a boolean context, Null is always “falsy” (see Section 2.6.1).

3.1.1.3 Examples

Expression Evaluation Notes
null null Evaluates to the Null type.
nil null Identical to null.
user.id == null true Returns true if id is explicitly null.
(null orElse "default") null The coalescing operator tests for Nothing. null is a data value.
(null or "default") "default" The logical or operator treats Null as a trigger for the fallback.

3.1.2 Boolean Literals

3.1.2.1 Syntax

The language recognizes the lowercase keywords true and false.

BooleanLiteral ← ("true" / "false") !C

Note: The negative lookahead !C ensures that identifiers beginning with a boolean keyword (e.g., true_value) are not incorrectly parsed as literals.

3.1.2.2 Semantics

Boolean literals represent the two logical truth values. They evaluate to a value of type Boolean. Boolean keywords are case-sensitive. True or TRUE are not recognized as boolean literals and will be interpreted as identifiers (variable names).

The literal true is always “truthy,” and the literal false is always “falsy” (see Section 2.6.1).

3.1.2.3 Examples

Expression Evaluation Notes
true true Evaluates to the Boolean true value.
false false Evaluates to the Boolean false value.
not true false The logical not operator inverts the boolean value.
(true orElse false) true The null coalescing operator returns the first non-null/non-nothing value.

3.1.3 Numeric Literals

3.1.3.1 Syntax

Numeric literals take the form of:

  • An integer part - must be either a single 0 or a non-zero digit followed by any number of digits. Leading zeros are not permitted (e.g., 05 is invalid).
  • An optional fractional part - a decimal point followed by one or more digits.
  • An optional exponent part using either e or E.
NumberLiteral ← Integer Fraction? Exponent? !C
Integer       ← "0" / [1-9] [0-9]*
Fraction      ← "." [0-9]+
Exponent      ← [eE] [+-]? [0-9]+

Syntactically, the NumberLiteral itself is unsigned. Negative numbers are represented by applying the unary negation operator (-) to a literal. See Section 3.4.7.

3.1.3.2 Semantics

Regardless of the notation used (standard or scientific), all numeric literals resolve to the Number type and are stored using exact decimal semantics. An exponent does not “coerce” the number into a floating-point type; 1e2 and 100 are semantically and type-identical.

3.1.3.3 Examples

Expression Evaluation Notes
0 0 Single zero integer.
42 42 Positive integer without leading zeros.
0.001 0.001 Decimal fraction.
1.23e4 12300 Scientific notation (positive exponent).
5E-3 0.005 Scientific notation (negative exponent, case-insensitive).
-10.5 -10.5 Unary negation applied to a literal.
05 Invalid Leading zeros are not allowed in the integer part.

3.1.4 String Literals

3.1.4.1 Syntax

Both single and double quoted strings are supported. Both forms allow interpolation.

Escape sequences follow JSON syntax, with the addition of an escaped single quote for single quoted strings, and \${ to escape string interpolation.

StringLiteral  ← DoubleQuoted / SingleQuoted

DoubleQuoted   ← "\"" (Interpolation / DoubleEscaped / Unescaped / "'")* "\""
SingleQuoted   ← "'"  (Interpolation / SingleEscaped / Unescaped / "\"")* "'"

Interpolation  ← "${" Expression "}"

DoubleEscaped  ← "\\" ( "\"" / Escapable )
SingleEscaped  ← "\\" ( "'" / Escapable )

Unescaped      ← LiteralDollar / SourceChar
LiteralDollar  ← "$" ! "{"

Escapable      ← "b" / "f" / "n" / "r" / "t" / "/" / "\\" / ("u" ~ HexChar) / "${"
HexChar        ← NonSurrogate / HighSurrogate "\\u" LowSurrogate
HighSurrogate  ← [Dd] [AaBb89] [0-9A-Fa-f]{2}
LowSurrogate   ← [Dd] [CcDdEeFf] [0-9A-Fa-f]{2}
NonSurrogate   ← [AaBbCcEeFf0-9] [0-9A-Fa-f]{3} /
                 [Dd] [0-7] [0-9A-Fa-f]{2}

SourceChar     ← " " /
                 "!" /
                 "#" /
                 "%" /
                 "&" /
                 [\u0028-\u0058] /
                 [\u005D-\uD7FF] /
                 [\uE000-\u0010FFFF]

3.1.4.2 Semantics

String literals represent sequences of Unicode scalar values (not grapheme clusters). They support both simple static text and dynamic content via interpolation.

When a string is evaluated, from left to right:

  • If the segment is unescaped text, append it to the result.
  • If the segment is one or more escape sequences, append decoded escape sequences to the result.
  • If the segment is an expression, resolve the expression and convert it to a string using \(ToString\) (Section 2.6.3), and append the string to the result.

There is no semantic difference between single- and double-quoted strings beyond delimiter rules.

Invalid Unicode escape sequences make the string literal invalid at parse time.

3.1.4.3 Examples

Expression Evaluation Notes
"Hello" "Hello" Simple double-quoted string.
'Alice\'s House' "Alice's House" Escaped single quote in a single-quoted string.
"Cost: $100" "Cost: $100" Raw $ is literal because it isn’t followed by {.
"Hi ${user.name}" "Hi Alice" Interpolates the value of user.name.
"Value: \${1}" "Value: ${1}" Escaped ${ prevents interpolation.
"\u00A9" "©" Unicode escape for the Copyright symbol.
"Line\nBreak" A string with a newline Standard escape sequence.
"\uD83D\uDE00" "😀" Decoded surrogate pair.

3.1.5 Array Literals

3.1.5.1 Syntax

Array literals consist of a comma separated sequence of expressions, surrounded by square brackets.

ArrayLiteral ← "[" S (ArrayItem (S "," S ArrayItem)*)? S ","? S "]"
ArrayItem    ← SpreadExpr / Expression
SpreadExpr   ← "..." Expression

Trailing commas are permitted. If an array literal contains a single StringLiteral item, it must be followed by a comma to syntactically differentiate it from a root variable using bracket notation.

3.1.5.2 Semantics

An array literal constructs an immutable ordered collections of values. They are evaluated eagerly from left to right. Each Expression is resolved to a value and inserted into a new Array instance, \(Array\langle RuntimeValue \rangle\).

Arrays are heterogenous; they may contain values of any type, including other arrays and objects.

3.1.5.2.1 The Spread Operator

The spread operator ... allows for the expansion of an iterable into the array literal.

  1. The expression following ... is evaluated and coerced via ToIterable(x) (see Section 2.6.6).
  2. Each of the elements in the resulting iterable is inserted into the new array at the current position.

Note that \(ToIterable\) is total; the spread operator can not fail.

3.1.5.3 Examples

Given a context: {"low": [1, 2], "high": [4, 5]}

Expression Evaluation Notes
[1, 2, 3] [1, 2, 3] A simple array of numbers.
[1, "two", true] [1, "two", true] A heterogeneous array.
[...low, 3, ...high] [1, 2, 3, 4, 5] Spread operators merging two arrays with a literal.
[1, , 2] Invalid Empty items between commas are not permitted.
[1, 2,] [1, 2] Trailing comma is ignored.
[...null] [] Null is coerced to an empty array by \(ToIterable\).
[1, missing_var, 3] [1, Nothing, 3] Arrays can contain Nothing.

3.1.6 Object Literals

3.1.6.1 Syntax

Object literals consist of a comma-separated sequence of key-value pairs or spread expressions, surrounded by curly braces.

ObjectLiteral ← "{" S (ObjectItem (S "," S ObjectItem)*)? S ","? S "}"
ObjectItem    ← SpreadExpr / KeyValuePair
KeyValuePair  ← ObjectKey S ":" S Expression
ObjectKey     ← Identifier / QuotedName
QuotedName    ← ( "\"" DoubleQuotedName "\"" ) / ( "'" SingleQuotedName "'" )

QuotedName follows the same escaping rules as StringLiteral but does not support interpolation. This ensures that object keys are statically determinable at parse-time.

QuotedName         ← DoubleQuotedName / SingleQuotedName

DoubleQuotedName   ← "\"" ( DoubleEscapedName / NameSourceChar / "'")* "\""
SingleQuotedName   ← "'"  ( SingleEscapedName / NameSourceChar / "\"")* "'"

DoubleEscapedName  ← "\\" ( "\"" / EscapableNameChar )
SingleEscapedName  ← "\\" ( "'" / EscapableNameChar )

EscapableNameChar  ← "b" / "f" / "n" / "r" / "t" / "/" / "\\" / ("u" ~ HexChar)

NameSourceChar     ← " " /
                     "!" /
                     "#" /
                     "$" /
                     "%" /
                     "&" /
                     [\u0028-\u0058] /
                     [\u005D-\uD7FF] /
                     [\uE000-\u0010FFFF]

3.1.6.2 Semantics

An object literal constructs an immutable collection of key-value pairs, \(Object\langle String, RuntimeValue \rangle\).

The literal is evaluated eagerly from left to right. If the same key is defined multiple times (either via direct assignment or via the spread operator), the last value evaluated takes precedence.

Once constructed, the object and its key-set cannot be modified.

3.1.6.2.1 Key Resolution
  • If a key is an Identifier, it is treated as a literal string (e.g., { name: "Alice" } uses the string "name" as the key).
  • If a key is a QuotedName, the quotes are stripped and the interior is processed for escapes to produce the string key.
3.1.6.2.2 The Spread Operator

The spread operator ... allows for the merging of another object into the one being constructed.

  1. The expression following ... is evaluated and coerced via ToObject(x) (see Section 2.6.5).
  2. Each key-value pair in the resulting object is inserted into the new object.
  3. If the value cannot be coerced into an object, the spread is ignored (as \(ToObject\) is total).

3.1.6.3 Examples

Given a context: {"base": {"id": 1, "role": "guest"}}

Expression Evaluation Notes
{ name: "Bob", age: 30 } {"name": "Bob", "age": 30} Standard object with identifier keys.
{ "First Name": "Bob" } {"First Name": "Bob"} Quoted keys allow for whitespace and reserved characters.
{ ...base, role: "admin" } {"id": 1, "role": "admin"} The role key from base is overwritten by the literal.
{ a: 1, a: 2 } {"a": 2} Last key wins rule applies.
{ ...null, count: 0 } {"count": 0} Spreading a non-object value results in an empty merge.
{ "${name}": 1 } Invalid Object keys do not support interpolation.

3.1.7 Range Literals

3.1.7.1 Syntax

Range literals consist of two expressions separated by a double-dot (..), enclosed in parentheses.

RangeLiteral ← "(" S Expression S ".." S Expression S ")"

3.1.7.2 Semantics

A range literal constructs an immutable, inclusive sequence of integers.

  1. The start (left) and end (right) expressions are evaluated eagerly before coercing both values to numbers using \(ToNumber\) (see Section 2.6.2).
  2. If a value is a decimal, truncate toward zero.
  3. If either expression evaluates to Nothing, the range literal evaluates to an empty sequence.
  4. The range is inclusive of both the start and end values.
  5. If start > end, the range results in an empty sequence. It does not count backward.

Implementations MAY represent this value as:

  1. An eager \(Array\langle RuntimeValue \rangle\), or
  2. A \(Drop\) implementing the \(Sequence\) protocol.

The observable behavior MUST be indistinguishable.

Implementations MAY define an upper limit to the number of items in a range to guard against excessively large array materialization.

3.1.7.3 Examples

Expression Evaluation Notes
(1..5) [1, 2, 3, 4, 5] Standard ascending inclusive range.
(5..1) [] Start is greater than end; results in empty sequence.
(1.2..3.8) [1, 2, 3] Values are coerced to integers before generation.
(1..1) [1] Single-item sequence.
(0..user.count) [0, 1, 2] Dynamic range (assuming user.count is 2).
(1..null) [] null coerces to Nothing, resulting in an empty range.

3.2 Identifiers

Identifiers are the names used to reference variables, properties, filters, and keyword arguments.

3.2.1 Syntax

An identifier MUST start with an alphabetic character (a-z, A-Z), an underscore (_), or a supported non-ASCII Unicode character. It cannot start with a digit.

After the first character, identifiers may contain any combination of alphanumeric characters, underscores, and supported Unicode ranges.

Identifier      ← IdentifierFirst IdentifierChar* !"?"
IdentifierFirst ← [a-zA-Z_] / [\u{80}-\u{D7FF}] / [\u{E000}-\u{10FFFF}]
IdentifierChar  ← IdentifierFirst / [0-9]

Note: The negative lookahead !"?" ensures that an identifier is not immediately followed by a question mark. This reserves that specific syntactic pattern for Predicates (see Section 3.3.2.2).

3.2.2 Semantics

Identifiers are strictly case-sensitive. myVariable and myvariable are treated as two distinct identifiers.

A sequence of characters that would otherwise be a valid identifier is NOT considered an Identifier if it is followed by a ?. Such sequences are instead parsed as part of a Predicate. Note that ? is not a valid identifier character.

3.2.3 Examples

Identifier Validity Notes
user_name Valid Standard snake_case identifier.
_secret Valid Starts with an underscore.
item2 Valid Contains a digit (but not at the start).
dâtâ Valid Contains non-ASCII Unicode characters.
2fast Invalid Cannot start with a digit.
is_valid? Invalid Matches the syntax for a Predicate, not an Identifier.

3.3 Variables and Paths

3.3.1 Syntax

A variable consists of a “root segment” followed by zero or more segments that make up a “path” to a value.

Segments can be in bracket notation, [<selector>], or shorthand notation, .some_name. Shorthand notation is only available for names that are valid identifiers.

Both single and double quoted names are allowed in bracket notation.

The last segment of a path may be a predicate (Section 3.3.2.2) - an identifier followed by a question mark ?.

Variable      ← VariableRoot Path?
VariableRoot  ← Identifier /
                "[" S StringLiteral S "]"

Path          ← (S Segment)+ Predicate?
Segment       ← "." Identifier /
                "[" S StringLiteral / Integer / QuerySelector S "]"

QuerySelector ← VariableRoot (S Segment)*
Predicate     ← "." IdentifierFirst IdentifierChar "?"

Note: An Identifier cannot include a ?. A Predicate is syntactically distinguished by its trailing question mark and its position as the terminal segment of a variable path.

3.3.2 Semantics

Variable resolution is a sequential process where each part of the path (the Selector) is resolved against the value produced by the preceding part.

To determine if a selector can be applied, the language distinguishes between two categories of values:

  • Structural Values: Values that can contain other values. This includes \(Array\), \(Object\), and \(Drop\) that implements a lookup protocol.

  • Non-Structural Values: Scalar or terminal values that do not support internal navigation. This includes \(Strings\), \(Number\), \(Boolean\), \(Null\), and \(Nothing\).

3.3.2.1 The Resolution Chain

  1. Resolution starts by looking up the VariableRoot in the current execution context.
  2. For each subsequent Segment (Dot or Bracket):
    • If the current value is Structural, the engine attempts to resolve the selector against it (e.g., looking up a key in an Object or an index in an Array). If selector lookup fails (key not found, index out of bounds or non string/integer selector), the segment resolves to Nothing.
    • If the current value is Non-Structural, the segment resolves to Nothing.
  3. Path Termination:
    • Standard Path: If the variable does not end in a predicate, the final value of the last segment is the result of the expression. If any segment in the chain resolved to Nothing, the entire variable expression resolves to Nothing.
    • Predicate Path: If the variable ends in a Predicate, the predicate is invoked using the result of the preceding segment as its input.

3.3.2.2 Predicates

A predicate is a named function registered with the environment. All predicates are total over \(RuntimeValue\) and MUST return \(Boolean\).

\[ Predicate : RuntimeValue → Boolean \]

For any predicate \(.p?\) and accompanying abstract function \(IsP\), x.p? is semantically equivalent to \(IsP(x)\).

Unlike standard segments, Predicates are specifically designed to handle the absence of a value.

  • The predicate is evaluated against the result of the path preceding it. If the path resolution fails at any point, the predicate receives Nothing as its input.
  • A predicate is always invoked, even if the preceding path resolution resulted in Nothing.
  • This allows a path like user.profile.defined? to return false rather than Nothing if user.profile does not exist.
  • If a predicate does not exist in the environment, the result is false. Implementations MAY issue a warning or error at parse time in the event of an unknown predicate.

3.3.3 Predicate Definitions

Here we define abstract predicate functions that are expected to be implemented.

3.3.3.1 blank?

Returns true for empty textual or collection values. Note that Nothing is distinct from Null and is not considered blank.

IsBlank(x) =
  x is Null
 OR x is String and trim(x) = ""
 OR x is Array and length(x) = 0
 OR x is Object and size(x) = 0
 OTHERWISE false

The absence of a value (Nothing) is not considered blank.

3.3.3.2 empty?

Returns true for values that are empty collections or empty strings. As with IsBlank, Nothing is not considered empty.

IsEmpty(x) =
  x is String and length(x) = 0
 OR x is Array and length(x) = 0
 OR x is Object and size(x) = 0
 OTHERWISE false

3.3.3.3 defined?

Returns true if the input is any value other than Nothing.

IsDefined(Nothing) → false
Otherwise → true

3.3.3.4 string?

Returns true if the input is of type \(String\).

IsString(x) =
  x is String → true
  otherwise   → false

3.3.3.5 null?

Returns true if the input is of type \(Null\).

IsNull(x) =
  x is Null → true
  otherwise → false

3.3.3.6 number?

Returns true if the input is of type \(Number\).

IsNumber(x) =
  x is Number → true
  otherwise   → false

3.3.3.7 boolean?

Returns true if the input is of type \(Boolean\).

IsBoolean(x) =
  x is Boolean → true
  otherwise    → false

3.3.3.8 array?

Returns true if the input is of type \(Array\).

IsArray(x) =
  x is Array → true
  otherwise  → false

3.3.3.9 object?

Returns true if the input is of type \(Object\).

IsObject(x) =
  x is Object → true
  otherwise   → false

3.3.4 Examples

Given a context: {"user": {"tags": ["admin"]}}

Expression Evaluation Notes
user.tags[0] "admin" Path through structural values (Object -> Array).
user.id Nothing user is structural, but id is missing.
user.id.type Nothing user.id is Nothing (non-structural), so .type resolves to Nothing.
user.tags.array? true Predicate invoked on a valid Array.
user.id.defined? false user.id is Nothing. The predicate handles Nothing and returns false.
["missing"].defined? false The root is missing (Nothing), but the predicate still evaluates to a Boolean.

Given a context: {"user": {"name": " ", "tags": []}}

Expression Evaluation Notes
user.name.blank? true String is whitespace only.
user.name.empty? false String has length > 0.
user.tags.empty? true Array has length 0.
user.id.defined? false id is missing (Nothing).
user.id.null? false Nothing is not Null.
user.tags.array? true Correct type check.
["missing"].defined? false Root lookup failed, predicate handles Nothing.

3.4 Operators

The following table lists operators from highest to lowest precedence. Operators within the same group are evaluated left-to-right (left-associative).

Operator Type Syntax
Primary Literals, Variables, ( expr )
Unary +, - (Positive/Negative)
Multiplicative *, /, %
Additive +, -
Pipe (Filter) expr | filter
Comparison/Membership ==, !=, <, >, <=, >=, contains, in
Logical NOT not
Logical AND and
Logical OR or
Nothing Coalesce orElse
Ternary consequence if condition else alternative

3.4.1 Ternary Expressions

3.4.1.1 Syntax

The ternary expression follows a “Python-style” postfix conditional syntax.

TernaryExpression ← CoalesceExpression ( "if" !C CoalesceExpression "else" !C CoalesceExpression )?

The keywords if and else are followed by a negative lookahead !C to ensure they are not parsed as prefixes of identifiers.

3.4.1.2 Semantics

Ternary expressions allow for branching logic within a single line. They prioritize the “happy path” by placing the consequence first.

  1. The Condition (CoalesceExpression) is evaluated first.
  2. The result is coerced to a Boolean via ToBoolean(x).
  3. If true, the Consequence is evaluated and returned; the alternative is short-circuited.
  4. If false, the Alternative is evaluated and returned; the consequence is short-circuited.

3.4.1.3 Examples

TODO: better examples

Expression Evaluation Notes
"Hi" if true else "Bye" "Hi" Basic usage.
a if b else c if d else e Invalid Syntax error: c if d else e is not a PipeExpression.
a if b else (c if d else e) Valid: Parentheses restore the Expression context.
‘a’ | upcase if true else ‘b’ "A"
‘a’ if false else ‘b’ | upcase "B"
(‘a’ if true else ‘b’) | upcase "A"

3.4.2 Nothing Coalescing Operator

3.4.2.1 Syntax

The Nothing coalescing operator (orElse) is a binary operator used to provide a fallback for unresolved paths or missing data.

CoalesceExpression ← OrExpression ( S "orElse" !C S OrExpression )*

3.4.2.2 Semantics

The orElse operator evaluates its operands from left to right and returns the first value that is not Nothing.

The operator triggers a fallback only if the left-hand side evaluates to Nothing (the signal for a failed resolution or non-existent variable). If the left-hand side is any value other than Nothing (including false, 0, or ""), the right-hand side is not evaluated.

orElse binds more loosely than logical or, meaning a or b orElse c is evaluated as (a or b) orElse c.

And orElse operator binds more tightly than the pipe operator (|), so a orElse b | f is evaluated as (a orElse b) | f.

3.4.2.3 Examples

Given a context: {"id": 0, "status": null}

Expression Evaluation Notes
id orElse 1 0 0 is not Nothing.
status orElse "active" null null is valid data; no fallback.
missing_var orElse "fallback" "fallback" Nothing triggers the fallback.
user.profile.id orElse 1 1 Failed path resolution results in Nothing.
null orElse "fallback" null Consistent with the “Null is Data” rule.

3.4.3 Logical Operators

3.4.3.1 Syntax

Logical operators perform boolean algebra and support short-circuit evaluation.

OrExpression     ← AndExpression ( S "or" !C S AndExpression )*
AndExpression    ← PrefixExpression ( S "and" !C S PrefixExpression )*
PrefixExpression ← NotExpression / TestExpression
NotExpression    ← "not" !C S PrefixExpression

3.4.3.2 Semantics

Logical operators rely on the \(IsTruthy(x)\) abstract function to evaluate operands (see Section 2.6.1).

or returns the first truthy operand. If all operands are falsy, it returns the result of the last operand. Short-circuits if a truthy value is found.

and returns the first falsy operand. If all operands are truthy, it returns the result of the last operand. Short-circuits if a falsy value is found.

not is a unary operator that returns true if the operand is falsy, and false if the operand is truthy.

3.4.3.3 Examples

Expression Evaluation Notes
true or false true Standard boolean logic.
null or "default" "default" null is falsy.
0 or "fallback" "fallback" 0 is falsy.
"hi" and true true "hi" is truthy; and returns the last value.
missing_var and true Nothing Nothing is falsy; and returns the first falsy value found.
not null true null is falsy, so not null is true.
not 0 true 0 is falsy.

3.4.4 Comparison Operators

3.4.4.1 Syntax

Comparison operators ==, !=, <=, >=, <, > test for equality and ordering.

TestExpression ← PipeExpression ( S TestOperator S PipeExpression )?
TestOperator   ← "==" / "!=" / "<=" / ">=" / "<" / ">" / ("in" !C) / ("contains" !C)

3.4.4.2 Semantics

Comparison is total; if two types are fundamentally incomparable and no protocol is present, the result is false rather than an error.

\[ ==, !=, <, >, <=, >= : RuntimeValue × RuntimeValue → Boolean \]

We first define == and <, then !=, >, <= and >= in terms of == and <.

A comparison using the operator == evaluates to true if the comparison is between:

  • Nothing and Nothing.
  • two values that represent the same data.
  • arrays of the same length where each element of the first array is equal to the corresponding element in the second array.
  • objects with the same collection of names and, for each of those names, the associated values are equal.
  • drops:
    1. If a is a drop and implements \(Equals\) and a.Equals(b) is true.
    2. If b is a drop and implements \(Equals\) and b.Equals(a) is true.
    3. Otherwise coerce both via \(ToPrimitive(…, data)\) and compare the results.

A comparison using the operator < yields true if the comparison is between values that are both numbers, both strings or either is a \(Drop\) and that satisfy the comparison:

  • For numbers: numeric ordering is used. Integers and floating values are compared by their numeric value; implementations may use a single representation (e.g. IEEE 754 BigDecimal) but comparisons must behave as the mathematical numeric ordering (smaller → less-than).
  • For strings: by default strings are ordered by Unicode scalar (code point) order. Implementations MAY provide an option to use the Unicode Collation Algorithm (UCA) for locale sensitive ordering, but the canonical spec semantics are Unicode code point ordering to ensure deterministic results.
  • For drops:
    1. If a is Drop and implements LessThan and a.LessThan(b) is true.
    2. If b is Drop and implements LessThan and b.LessThan(a) is true.
    3. Otherwise coerce both via \(ToPrimitive(…, data)\) and attempt standard ordering on the results.

!=, >, <= and >= are defined in terms of == and <.

\[ \begin{aligned} x \;\texttt{!=}\; y &\to \text{not}(x \;\texttt{==}\; y) \\ x \;\texttt{>}\; y &\to y \;\texttt{<}\; x \\ x \;\texttt{<=}\; y &\to (x \;\texttt{<}\; y) \;\text{or}\; (x \;\texttt{==}\; y) \\ x \;\texttt{>=}\; y &\to (y \;\texttt{<}\; x) \;\text{or}\; (x \;\texttt{==}\; y) \end{aligned} \]

3.4.4.3 Examples

Expression Evaluation Notes
0 == 0.0 true Numeric normalization.
"" == null false Different types, both are falsy but not equal.
5 > "2" false Type mismatch defaults to false.
(1 + 1) == 2 true Arithmetic is resolved before comparison.

3.4.5 Membership Operators

3.4.5.1 Syntax

TestExpression ← PipeExpression ( S TestOperator S PipeExpression )?
TestOperator   ← "==" / "!=" / "<=" / ">=" / "<" / ">" / ("in" !C) / ("contains" !C)

3.4.5.2 Semantics

Membership operators in and contains determine whether a value appears in a container. Both operators return \(Boolean\).

  • element in container
  • container contains element

These forms are semantically equivalent with operands reversed.

Evaluation proceeds as follows.

  1. If container is a Drop that implements the \(Membership\) protocol, call contains(element):
    • If the result is Boolean, return that value.
    • If the result is Nothing, continue evaluation.
  2. If container and element are both \(String\):
    • Test for substring inclusion. Return true if element occurs within container, otherwise false.
  3. If container is an Object, membership tests for the existence of a key.
    • Return true if key exists in the object, otherwise false.
  4. Otherwise coerce to an iterable using \(ToIterable\) and iterate the resulting sequence.
    • Return true if key exists in the sequence, otherwise false.

3.4.5.3 Examples

Expression Evaluation Notes
3 in [1,2,3] true Array is iterable.
3 in (1..5) true Range literals are iterable.
5 in 5 true 5 is coerced to [5] by \(ToIterable\).
"a" in "cat" true Substring membership.
"name" in {name:1} true Object property membership.

3.4.6 Pipe Operator (Filters)

A filter is a named total function registered in the environment. Formally:

\[ \begin{aligned} FilterFunction \;&: RuntimeValue × List\langle FilterArgument \rangle → RuntimeValue \\ FilterArgument \;&: RuntimeValue | Lambda \end{aligned} \]

3.4.6.1 Syntax

PipeExpression     ← ArithmeticExpression ( S "|" S FilterInvocation )*
FilterInvocation   ← Variable ( S ":" S ArgumentList )?
ArgumentList       ← Argument ( S "," S Argument )*
Argument           ← LambdaArgument / KeywordArgument / ArgumentExpression
KeywordArgument    ← Identifier S ( "=" / ":" ) S ( LambdaArgument / ArgumentExpression )
LambdaArgument     ← LambdaParameters S ( "=>" / "->" ) S ArgumentExpression
ArgumentExpression ← ArithmeticExpression

3.4.6.2 Semantics

Every filter application has at least one argument: the input. This is the value resulting from the expression immediately to the left of the pipe. In the conceptual mapping to a standard function, the input always occupies the first positional slot (index 0).

Conceptually, a filter application such as input | filter: arg1, key=arg2 is evaluated as filter(input, arg1, key: arg2).

The evaluation of a PipeExpression follows a linear, left-to-right transformation of data. Each filter in the chain acts as a function that accepts the current result of the pipeline as its first implicit argument.

3.4.6.2.1 Arguments

Positional arguments are additional values passed to a filter, identified by their sequence following the filter name. The first explicit argument provided in the template is treated as the second argument to the underlying function (index 1), the next as the third (index 2), and so on.

Keyword arguments allow values to be passed to specific named parameters of a filter. Keyword arguments may appear in any order relative to each other, provided they follow all positional arguments. The assignment operators = and : are treated as semantically identical. They serve only to bind the expression value to the named parameter.

3.4.6.2.1.1 Lambda Expressions

Lambda expressions provide a way to define anonymous, inline functions that are passed directly to filters. They allow filters to execute custom logic over collections, such as mapping values, filtering arrays, or applying custom sorting rules.

  • When evaluated by the filter, the body expression has access to the internal parameters passed into it by the filter.
  • The body expression also retains read-access to all variables, and context values that were available in the surrounding template scope at the time the lambda was defined.
  • If a lambda parameter shares a name with a variable in the surrounding scope, the lambda parameter strictly shadows the outer variable for the duration of the lambda’s execution.
3.4.6.2.2 Well-Typed Filters

Filter implementations may be accompanied by a Signature. This signature is a piece of metadata intended for tooling, static analysis, and ahead-of-time (AOT) validation.

Signatures allow compilers or IDEs to provide autocomplete, verify argument counts, and check for the presence of required keyword arguments before the template is executed. The execution engine MUST NOT depend on this metadata to perform a filter invocation. The engine’s role is strictly to evaluate arguments and dispatch the resulting values to the filter implementation.

3.4.6.2.3 Totality Requirement

All filters MUST be total functions. A filter is considered total if it returns a valid value for every possible combination of input and arguments.

  • A filter must never throw an exception or halt the execution of the template.
  • If a transformation is mathematically or logically impossible (e.g., division by zero, or passing a String to a filter that only handles Numbers and cannot coerce the value), the filter MUST return Nothing.

Filters MUST NOT raise errors due to incorrect arity:

  1. Too Few Arguments If a required (non-optional, non-variadic) parameter has no corresponding argument, the filter invocation evaluates to Nothing.

  2. Too Many Arguments If extra arguments are supplied and the filter does not declare a variadic parameter, the filter invocation evaluates to Nothing.

  3. Variadic Parameters If a variadic parameter is declared, all remaining arguments are collected into a list and passed as separate positional arguments or as an array, according to the implementation’s calling convention.

Filters that are unknown to the environment evaluate to Nothing. Implementations MAY throw an error or warning at parse time in the event of an unknown filter.

3.4.6.3 Examples

Expression Structure Notes
val | upcase Simple invocation No arguments.
val | round: 2 Positional argument A single numeric literal argument.
val | replace: “a”, “b” Multiple arguments Comma-separated positionals.
val | date: format = “%Y” Keyword argument Uses = for key-value assignment.
users | map: u => u.name Lambda argument Single parameter lambda for transformation.
users | sort: (a, b) => a.age - b.age Multi-arg lambda Grouped parameters for comparison logic.
val | f: (other | g) Nested Pipe Parentheses are required to pipe inside an argument.

3.4.7 Arithmetic Operators

3.4.7.1 Syntax

ArithmeticExpression ← AddExpression
AddExpression        ← MulExpression ( S ( "+" / "-" ) S MulExpression )*
MulExpression        ← UnaryExpression ( S ( "*" / "/" / "%" ) S UnaryExpression )*
UnaryExpression      ← NegExpression / PosExpression / PrimaryExpression
NegExpression        ← "-" UnaryExpression
PosExpression        ← "+" UnaryExpression

3.4.7.2 Semantics

Arithmetic is performed using the Decimal Arithmetic Model to ensure exact precision for base-10 decimals, avoiding the rounding errors typical of binary floating-point systems.

All arithmetic operators are total. Operands are coerced to numbers via \(ToNumber\). If an operand resolves to Nothing, the entire arithmetic operation resolves to Nothing.

+, -, *, /, % : RuntimeValue × RuntimeValue → Number | Nothing
+ , - (unary) : RuntimeValue → Number | Nothing

Arithmetic operators MUST share semantics with their filter equivalents - plus, minus, times, etc.

3.4.7.2.1 Division operator

Division uses true arithmetic division. Evaluation proceeds as follows:

  1. Convert operands using ToNumber.
  2. If either conversion yields Nothing, the result is Nothing.
  3. If the right hand side is equal to 0, the result is Nothing.
  4. Otherwise compute the decimal quotient using the decimal arithmetic model defined in Section 2.2.
  5. Normalize; if the result is mathematically an integer (i.e. it has no fractional component), the result MUST be represented as an integer value.
3.4.7.2.2 Modulo Operator

The modulo operator computes the remainder of division. Evaluation proceeds as follows:

  1. Convert operands using \(ToNumber\).
  2. If either conversion yields Nothing, the result is Nothing.
  3. If the right hand side is equal to 0, the result is Nothing.
  4. Otherwise compute the remainder using \(r = x' - y' * floor(x' / y')\). The result has the same sign as the divisor.
  5. If the result has no fractional component, it MUST be represented as an integer.

3.4.7.3 Examples

Expression Evaluation Notes
4 / 2 2 Integer representation.
0.1 + 0.2 0.3 Exact decimal arithmetic.
10 / 4 2.5 True division (no integer truncation).
"Item" + 1 Nothing "Item" coerces to Nothing. + is not overloaded for concatenation.
10 / 0 Nothing Division by zero is handled safely.
5 * "abc" Nothing Incompatible types resolve to Nothing.
5 % 2 1
4 % 2 0
-5 % 2 1
5 % -2 -1
5.5 % 2 1.5

3.5 Primary and Parenthesized Expressions

3.5.1 Syntax

PrimaryExpression       ← Literal / ParenthesizedExpression / LambdaExpression / Variable
ParenthesizedExpression ← "(" S Expression S ")" Path?

3.5.2 Semantics

A \(PrimaryExpression\) evaluates to a \(RuntimeValue\) as follows:

\(Literal\) evaluates to the corresponding literal value (see Section 3.1).

\(Variable\) is evaluated via environment lookup and path resolution (see Section 3.3).

\(ParenthesizedExpression\) is evaluated by evaluating the inner expression, then applying the optional path (see Section 3.3.2.1).

v      = Eval(Expression)
result = ResolvePath(v, Path)

If no path is present, the result is simply v.

3.5.3 Examples

Given a context: {"a": {"c": ["42"]}}

Expression Evaluation Notes
(2 + 3) * 4 20 Override operator precedence with parentheses.
(a or b).c 42 Path applied on logical expression.

3.6 Lambda Expressions

3.6.1 Syntax

A lambda expression consists of an argument declaration, an arrow operator (=>), and a single expression body.

The left side of the arrow defines the parameters. This can be a single variable name or a parenthesized, comma-separated list of variable names. Parentheses are required if there are zero parameters or multiple parameters.

The right side of the arrow is a single expression that determines the return value of the lambda.

LambdaExpression        ← LambdaParameters S ( "=>" / "->" ) S Expression
LambdaParameters        ← Identifier / ( "(" S ( Identifier ( S "," S Identifier )* )? S ")" )

3.6.2 Semantics

Lambdas are stateless, meaning they do not capture the lexical environment in which they are defined.

TODO: via drop protocol

3.6.3 Examples

TODO

Appendix A. Notable Differences

This appendix highlights some of the differences between this specification and Shopify/liquid, at the time of writing.

  • This specification supports arithmetic operators (+, -, *, /, %) directly in expressions. Arithmetic operators DO NOT default to zero when numeric coercion fails. Instead they evaluate to Nothing, and arithmetic expressions (along with their matching filters) evaluate to Nothing if either operand is Nothing, or when an operation is undefined (such as division or modulo by zero).

  • This specification changes logical operator (and and or) precedence and adds a not operator. and is required to bind tighter than or and evaluation happens from left to right. Parentheses may be used for grouping: not (a and b).

  • This specification introduces first-class filters. Filters are no longer restricted by contextual parsing rules and can appear anywhere expressions are allowed. The pipe operator (|) is treated as a standard expression operator with deterministic behavior.

  • This specification add support for Python-style conditional expressions:

    value if condition else alternative

    Ternary expressions are fully composable and follow defined precedence rules. They may be nested and combined with filters, arithmetic, and logical operators.

  • This specification adds support for array and object literals:

    [1, 2, 3]
    { "name": user.name }

    A spread operator (...) allows composition of structures:

    [...items, 4]
    { ...defaults, "enabled": true }

    This enables template authors to construct collections immutably, without manipulating render context data.

  • This specification adds support for first-class expressions including anonymous expressions as filter arguments. Lambda expressions are stateless, can be “called” using the pipe operator (|), and allow filters to apply custom logic such as mapping or sorting to data structures.

  • This specification replaces the special x == empty and x == blank constructs with x.empty? and x.blank? predicates. Other predicates defined in the spec include defined? and array?.

  • This specification removes value-returning operations such as x.size, x.first and x.last.

Appendix B. Collected PEG Grammar

This appendix collects grammar rules used throughout this document.

Expression              ← TernaryExpr
             
B                       ← " " / "\t" / "\r" / "\n"    // Whitespace
S                       ← B*                          // Optional whitespace
C                       ← IdentifierChar              // Alias for any name character

Literal                 ← NumberLiteral /
                          StringLiteral /
                          BooleanLiteral /
                          NullLiteral /
                          ArrayLiteral /
                          ObjectLiteral /
                          RangeLiteral

NullLiteral             ← ("null" / "nil") !C

BooleanLiteral          ← ("true" / "false") !C

NumberLiteral           ← Integer Fraction? Exponent? !C
Integer                 ← "0" / [1-9] [0-9]*
Fraction                ← "." [0-9]+
Exponent                ← [eE] [+-]? [0-9]+

StringLiteral           ← DoubleQuoted / SingleQuoted
         
DoubleQuoted            ← "\"" (Interpolation / DoubleEscaped / Unescaped / "'")* "\""
SingleQuoted            ← "'"  (Interpolation / SingleEscaped / Unescaped / "\"")* "'"
         
Interpolation           ← "${" Expression "}"
         
DoubleEscaped           ← "\\" ( "\"" / Escapable )
SingleEscaped           ← "\\" ( "'" / Escapable )
         
Unescaped               ← LiteralDollar / SourceChar
LiteralDollar           ← "$" ! "{"
         
Escapable               ← "b" / "f" / "n" / "r" / "t" / "/" / "\\" / ("u" ~ HexChar) / "${"
HexChar                 ← NonSurrogate / HighSurrogate "\\u" LowSurrogate
HighSurrogate           ← [Dd] [AaBb89] [0-9A-Fa-f]{2}
LowSurrogate            ← [Dd] [CcDdEeFf] [0-9A-Fa-f]{2}
NonSurrogate            ← [AaBbCcEeFf0-9] [0-9A-Fa-f]{3} /
                          [Dd] [0-7] [0-9A-Fa-f]{2}
         
SourceChar              ← " " /
                          "!" /
                          "#" /
                          "%" /
                          "&" /
                          [\u0028-\u0058] /
                          [\u005D-\uD7FF] /
                          [\uE000-\u0010FFFF]

ArrayLiteral            ← "[" S (ArrayItem (S "," S ArrayItem)*)? S ","? S "]"
ArrayItem               ← SpreadExpr / Expression
SpreadExpr              ← "..." Expression

ObjectLiteral           ← "{" S (ObjectItem (S "," S ObjectItem)*)? S ","? S "}"
ObjectItem              ← SpreadExpr / KeyValuePair
KeyValuePair            ← ObjectKey S ":" S Expression
ObjectKey               ← Identifier / QuotedName
QuotedName              ← ( "\"" DoubleQuotedName "\"" ) / ( "'" SingleQuotedName "'" )

QuotedName              ← DoubleQuotedName / SingleQuotedName
     
DoubleQuotedName        ← "\"" ( DoubleEscapedName / NameSourceChar / "'")* "\""
SingleQuotedName        ← "'"  ( SingleEscapedName / NameSourceChar / "\"")* "'"
     
DoubleEscapedName       ← "\\" ( "\"" / EscapableNameChar )
SingleEscapedName       ← "\\" ( "'" / EscapableNameChar )
     
EscapableNameChar       ← "b" / "f" / "n" / "r" / "t" / "/" / "\\" / ("u" ~ HexChar)
     
NameSourceChar          ← " " /
                          "!" /
                          "#" /
                          "$" /
                          "%" /
                          "&" /
                          [\u0028-\u0058] /
                          [\u005D-\uD7FF] /
                          [\uE000-\u0010FFFF]

RangeLiteral            ← "(" S Expression S ".." S Expression S ")"

Identifier              ← IdentifierFirst IdentifierChar* !"?"
IdentifierFirst         ← [a-zA-Z_] / [\u{80}-\u{D7FF}] / [\u{E000}-\u{10FFFF}]
IdentifierChar          ← IdentifierFirst / [0-9]

Variable                ← VariableRoot Path?
VariableRoot            ← Identifier /
                          "[" S StringLiteral S "]"
          
Path                    ← (S Segment)+ Predicate?
Segment                 ← "." Identifier /
                          "[" S StringLiteral / Integer / QuerySelector S "]"
          
QuerySelector           ← VariableRoot (S Segment)*
Predicate               ← "." IdentifierFirst IdentifierChar "?"

TernaryExpression       ← CoalesceExpression ( "if" !C CoalesceExpression "else" !C CoalesceExpression )?

CoalesceExpression      ← OrExpression ( S "orElse" !C S OrExpression )*

OrExpression            ← AndExpression ( S "or" !C S AndExpression )*
AndExpression           ← PrefixExpression ( S "and" !C S PrefixExpression )*
PrefixExpression        ← NotExpression / TestExpression
NotExpression           ← "not" !C S PrefixExpression

TestExpression          ← PipeExpression ( S TestOperator S PipeExpression )?
TestOperator            ← "==" / "!=" / "<=" / ">=" / "<" / ">" / ("in" !C) / ("contains" !C)

TestExpression          ← PipeExpression ( S TestOperator S PipeExpression )?
TestOperator            ← "==" / "!=" / "<=" / ">=" / "<" / ">" / ("in" !C) / ("contains" !C)

PipeExpression          ← ArithmeticExpression ( S "|" S FilterInvocation )*
FilterInvocation        ← Variable ( S ":" S ArgumentList )?
ArgumentList            ← Argument ( S "," S Argument )*
Argument                ← LambdaArgument / KeywordArgument / ArgumentExpression
KeywordArgument         ← Identifier S ( "=" / ":" ) S ( LambdaArgument / ArgumentExpression )
LambdaArgument          ← LambdaParameters S ( "=>" / "->" ) S ArgumentExpression
ArgumentExpression      ← ArithmeticExpression

ArithmeticExpression    ← AddExpression
AddExpression           ← MulExpression ( S ( "+" / "-" ) S MulExpression )*
MulExpression           ← UnaryExpression ( S ( "*" / "/" / "%" ) S UnaryExpression )*
UnaryExpression         ← NegExpression / PosExpression / PrimaryExpression
NegExpression           ← "-" UnaryExpression
PosExpression           ← "+" UnaryExpression

PrimaryExpression       ← Literal / ParenthesizedExpression / LambdaExpression / Variable
ParenthesizedExpression ← "(" S Expression S ")" Path?

LambdaExpression        ← LambdaParameters S ( "=>" / "->" ) S Expression
LambdaParameters        ← Identifier / ( "(" S ( Identifier ( S "," S Identifier )* )? S ")" )