Support Value Expressions as Operands in Conditional and Loop Tags
Problem Description
LiquidJS currently does not support using value expressions (filter operations) directly as operands in conditional statements (if, elsif, unless) or in loop tags (for). This limitation requires developers to use workarounds that make templates less readable and more verbose.
Current Limitation
The following syntax is NOT supported:
{% if (strValue | size) > 10 %}
<a href="{{person | prepend: "https://example.com/"}}">
{{ person | capitalize }}
</a>
{% endif %}
This results in a syntax error or unexpected behavior because the value expression (strValue | size) cannot be used directly as an operand in the conditional statement.
Additional Examples of Unsupported Syntax
Example 1: String Length Validation in Conditionals
{% if (username | size) >= 3 %}
Username is valid
{% elsif (username | size) == 0 %}
Username is required
{% else %}
Username is too short
{% endif %}
Example 2: Array Size comparison
{% if (firstArray | size) == (secondArray | size) %}
No items found
{% else %}
Found {{ firstArray | size }} in firstArray and {{ secondArray | size }} in secondArray
{% endif %}
Example 3: String Transformation in Conditions
{% if (email | downcase) contains "@example.com" %}
Internal user detected
{% endif %}
Example 4: Numeric Calculations
{% if (price | times: 0.9) < 100 %}
Discounted price is under $100
{% endif %}
Example 5: Chained Filters in Conditions
{% if (description | strip_html | size) > 50 %}
Long description detected
{% endif %}
Example 6: For Loop with Filtered Range
{# Loop through first N items #}
{% for i in (1..items | size) %}
Item {{ i }}
{% endfor %}
Example 7: Unless with Filter Expression
{% unless (content | strip_html | size) > 0 %}
No content available
{% endunless %}
Example 8: Case Statement with Filtered Value
{% case (status | downcase) %}
{% when "active" %}
Active status
{% when "pending" %}
Pending status
{% endcase %}
Example 9: Complex Logical Expression
{% if (name | size) > 0 and (email | downcase) contains "@" %}
Valid user entry
{% endif %}
Example 10: Nested Filter in Loop Limit
{% for item in items limit: (maxItems | default: 10) %}
{{ item }}
{% endfor %}
Current Workaround
To work around this limitation, you must assign the filtered value to a variable first:
{% assign strValueSize = strValue | size %}
{% if strValueSize > 10 %}
<a href="{{person | prepend: "https://example.com/"}}">
{{ person | capitalize }}
</a>
{% endif %}
More Workaround Examples
{# Example 1: String Length Validation #}
{% assign usernameLength = username | size %}
{% if usernameLength >= 3 %}
Username is valid
{% elsif usernameLength == 0 %}
Username is required
{% else %}
Username is too short
{% endif %}
{# Example 2: Array Size Check #}
{% assign itemCount = items | size %}
{% if itemCount == 0 %}
No items found
{% else %}
Found {{ itemCount }} items
{% endif %}
{# Example 3: String Transformation #}
{% assign emailLower = email | downcase %}
{% if emailLower contains "@example.com" %}
Internal user detected
{% endif %}
{# Example 4: Numeric Calculations #}
{% assign discountedPrice = price | times: 0.9 %}
{% if discountedPrice < 100 %}
Discounted price is under $100
{% endif %}
{# Example 5: Chained Filters #}
{% assign cleanDescription = description | strip_html %}
{% assign descriptionLength = cleanDescription | size %}
{% if descriptionLength > 50 %}
Long description detected
{% endif %}
{# Example 6: For Loop with Filtered Range #}
{% assign itemCount = items | size %}
{% for i in (1..itemCount) %}
Item {{ i }}
{% endfor %}
{# Example 7: Complex Logical Expression #}
{% assign nameLength = name | size %}
{% assign emailLower = email | downcase %}
{% if nameLength > 0 and emailLower contains "@" %}
Valid user entry
{% endif %}
Why This Matters
While the workaround functions correctly, it has several significant drawbacks:
- Reduced Readability: The intent is less clear when logic is split across multiple statements
- Increased Verbosity: More lines of code for simple operations
- Variable Pollution: Creates intermediate variables that clutter the template scope and may conflict with other variables
- Maintenance Burden: More code to maintain and understand, especially for complex expressions
- Cognitive Load: Developers must mentally track intermediate variables and their purpose
- Inconsistency: Filters work inline in output expressions
{{ }}but not in logic expressions{% %}
Desired Behavior
Ideally, LiquidJS should support inline value expressions (filter operations) within logical operations and loop tags, similar to how they work in output statements:
{% if (strValue | size) > 10 %}
{# Direct filter use in condition #}
{% endif %}
This would make templates:
- More concise and readable
- Easier to maintain
- More intuitive for developers
- Consistent with filter usage in output contexts (
{{ }}) - More expressive and powerful
Use Cases
This feature would be particularly useful in scenarios such as:
- Validation Logic: Checking string lengths, array sizes, or data formats without intermediate variables
- Conditional Rendering: Showing/hiding content based on processed values
- Access Control: Making decisions based on transformed user data
- Data Formatting: Conditional formatting based on calculated values
- Dynamic Loops: Iterating with dynamically calculated limits or ranges
- Complex Business Logic: Implementing multi-step conditionals with transformations
- Template Optimization: Reducing template size and complexity
Expected Behavior
Value expressions should be evaluated before being used as operands in conditionals and loops:
{# These should all work #}
{% if (value | size) > 10 %}...{% endif %}
{% for i in (1..items | size) %}...{% endfor %}
{% unless (text | strip_html | size) == 0 %}...{% endunless %}
{% case (status | downcase) %}...{% endcase %}
The parentheses would indicate to the parser that the enclosed expression should be evaluated as a complete value expression before being used in the containing statement.
Comparison with Other Templating Engines
Many other templating engines support inline expressions in conditionals:
- Jinja2 (Python): Supports complex expressions in conditionals
- Twig (PHP): Allows filter operations in conditional statements
- Nunjucks (JavaScript): Supports inline transformations in logic blocks
- Handlebars (JavaScript): Allows helper functions in conditionals
Supporting this syntax would align LiquidJS with industry standards and improve developer experience.
Proposed Solution
Allow value expressions (enclosed in parentheses or maybe not) to be evaluated and used as operands in:
- Conditional tags:
if,elsif,unless,case - Loop tags:
for(range expressions, limit parameters) - Any other tag that accepts value operands
This should maintain backward compatibility while adding this enhancement. The parentheses provide a clear signal to the parser that the enclosed content is a value expression that should be fully evaluated before being used.
👍 I like it. Promoting filters to be a first-class expression rather than a special case for output statements ({{ thing | filter }}) and assign tags make a lot of sense.
However, if we're deviating from the reference implementation, I think it should be opt-in, so application developers can decide if it's right for their use case.
There's also a few things that I'd want to do first, or at the same time, if we're deviating from the standard.
[!NOTE] The special
.sizeproperty is already supported. So example 1 above could be written as:{% if username.size >= 3 %} Username is valid {% elsif username.size == 0 %} Username is required {% else %} Username is too short {% endif %}
Grouping terms with parentheses
Currently, logical operators and and or are right associative and logical expressions do not support grouping terms with parentheses. true and false and false or true is parsed as (true and (false and (false or true))), evaluating to false 🙄.
Parentheses will be needed for filter expressions in some cases. And if you're supporting parentheses there, you must support them in logical expressions too.
Conditional expressions
Make conditions first-class expressions. You could write this:
{{ user.name or "guest" }}
Instead of this:
{% if user.name %}{{ user.name }}{% else %}guest{% endif %}
See jg-rp/liquid #175 and Shopify/liquid #1922
Arithmetic operators
For even better readability, I'd like to see support for arithmetic operators. {% if (price | times: 0.9) < 100 %} feels very unnatural.
It would make sense to implement or plan for arithmetic operators at the same time as implementing parentheses for filter expressions and logical expressions. We would be considering operator precedence for all operators together.
{% if price * 0.9 < 100 %}
Discounted price is under $100
{% endif %}
Yes, it totally makes sense!
Both extending expression syntax and adding operators sound good to me. I've thought about this idea years ago when I find Liquid language is still tedious thus not perfect in some cases. Thank you guys for going through this idea with detailed examples!
One concern is we'll need break changes to align with shopify/liquid if it also comes up with new syntax and designed differently. These 2 ideas are both acceptable:
- raise feature request there, and we'll copy the implementation when that feature is merged.
- put these features under an
experimentalExpressionsoption to indicate it can break in future minor versions.
I think I prefer something like option 2, along with some refactoring to support "pluggable" parsers.
If Shopify does introduce conflicting syntax or behavior, LiquidJS dependents would have the option of implementing their own parser with the syntax and behavior they're used to. Or you could maintain two parsers in LiquidJS.
Instead of a Boolean experimentalExpressions option, you could accept a Parser subclass (edit: or something implementing a parser interface) which defaults to DefaultParser.
This is, of course, not a small task, and will probably introduce breaking API changes for anyone with custom tags.