liquidjs icon indicating copy to clipboard operation
liquidjs copied to clipboard

Support Value Expressions as Operands in Conditional and Loop Tags

Open skynetigor opened this issue 1 month ago • 4 comments

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:

  1. Reduced Readability: The intent is less clear when logic is split across multiple statements
  2. Increased Verbosity: More lines of code for simple operations
  3. Variable Pollution: Creates intermediate variables that clutter the template scope and may conflict with other variables
  4. Maintenance Burden: More code to maintain and understand, especially for complex expressions
  5. Cognitive Load: Developers must mentally track intermediate variables and their purpose
  6. 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:

  1. Validation Logic: Checking string lengths, array sizes, or data formats without intermediate variables
  2. Conditional Rendering: Showing/hiding content based on processed values
  3. Access Control: Making decisions based on transformed user data
  4. Data Formatting: Conditional formatting based on calculated values
  5. Dynamic Loops: Iterating with dynamically calculated limits or ranges
  6. Complex Business Logic: Implementing multi-step conditionals with transformations
  7. 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.

skynetigor avatar Nov 06 '25 17:11 skynetigor

👍 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 .size property 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 %}

jg-rp avatar Nov 07 '25 08:11 jg-rp

Yes, it totally makes sense!

skynetigor avatar Nov 07 '25 10:11 skynetigor

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:

  1. raise feature request there, and we'll copy the implementation when that feature is merged.
  2. put these features under an experimentalExpressions option to indicate it can break in future minor versions.

harttle avatar Nov 10 '25 13:11 harttle

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.

jg-rp avatar Nov 11 '25 11:11 jg-rp