DEV Community

Cover image for FEEL at Runtime in Form-JS: Building an Expression Evaluation Pipeline from Scratch
Sam Abaasi
Sam Abaasi

Posted on

FEEL at Runtime in Form-JS: Building an Expression Evaluation Pipeline from Scratch

Part 3 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"


The Form-JS documentation has a section on FEEL expressions. It shows you how to write = amount * 0.2 in the editor's binding field and explains that the value will be computed dynamically. What it doesn't explain is how that computation actually happens, or how to make it happen yourself in a custom evaluator.

I needed to evaluate FEEL expressions at runtime — not in the editor, but in the running form, as users filled in fields, to dynamically show or hide other fields, compute derived values, and validate inputs. The documentation pointed me nowhere. I eventually found the answer by reading Form-JS's own source code and discovering that it uses a library called feelin for all FEEL evaluation. That library is available to you too. This article documents everything I had to learn to use it correctly.


The Problem

FEEL (Friendly Enough Expression Language) is the expression language used throughout the Camunda platform. Form-JS supports FEEL expressions in the editor — you can bind a field's value to = firstName + " " + lastName and the editor will evaluate it and show the result in the preview.

But when you build custom extensions — evaluators that run at runtime, validators that check conditions, renderers that filter options — you need to evaluate FEEL expressions yourself. The Form-JS runtime provides no public API for this. There is no form.evaluateExpression(expr, context) method. There is no exported evaluator class.

The gap is real and it affects every serious Form-JS extension developer. You're writing expressions in the editor that your custom code needs to evaluate at runtime, and you have no documented path to do it.


What I Tried First

My first attempt was a regular expression parser — I'd parse simple expressions like = amount > 100 by splitting on operators and comparing values. It worked for the three expressions I tested it with and broke on the fourth. FEEL has enough syntax (date arithmetic, list operations, context access, string functions) that hand-rolling a parser is not a viable path.

My second attempt was to use JavaScript's eval(). This works for JavaScript-like expressions but FEEL is not JavaScript. The expression = date and time("2024-01-01") is valid FEEL and meaningless to JavaScript. More subtly, = list contains(values, "active") is valid FEEL and a syntax error in JavaScript. I needed something that actually understood FEEL.

The solution was already in the project. Form-JS uses feelin internally for FEEL evaluation. It's in node_modules. You can import it directly.


The Solution: Using feelin Directly

What feelin Provides

feelin is a FEEL interpreter for JavaScript. It's the same library that powers FEEL evaluation across the bpmn-io ecosystem. You can install it or, if you're already using Form-JS, it's already in your dependencies.

npm install feelin
# or it's already there if you have @bpmn-io/form-js installed
Enter fullscreen mode Exit fullscreen mode

The import that Form-JS itself uses:

import {
  evaluate as evalExpression,
  unaryTest as evalUnaryTest,
  parseExpression,
  parseUnaryTests,
  SyntaxError
} from 'feelin';
Enter fullscreen mode Exit fullscreen mode

There are two distinct evaluation modes:

evaluate — evaluates a FEEL expression and returns a value. Use this for everything: bindings, conditions, hide/show logic, computed values.

import { evaluate } from 'feelin';

const result = evaluate('amount * 0.2', { amount: 500 });
// result === 100
Enter fullscreen mode Exit fullscreen mode

unaryTest — evaluates a FEEL unary test, which is a test against an implicit input value. Use this for validation rules and conditions that test the current field's value.

import { unaryTest } from 'feelin';

const result = unaryTest('>= 18', 21);
// result === true — tests if 21 >= 18
Enter fullscreen mode Exit fullscreen mode

The SyntaxError export is feelin's custom error class for parse failures. You need it to distinguish between "this expression failed to evaluate" and "this expression is syntactically invalid":

import { evaluate, SyntaxError } from 'feelin';

try {
  evaluate('= invalid expression !!!', {});
} catch (err) {
  if (err instanceof SyntaxError) {
    console.error('Invalid FEEL syntax:', err.message);
  } else {
    console.error('Runtime evaluation error:', err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Wrap It in a Class

You could call evaluate directly everywhere you need it. I tried this. The problems:

Context preparation is not trivial. Before you can evaluate = amount > 100, you need to ensure amount is a number, not the string "100" that came from a form input. You need to handle undefined variables (FEEL throws; you want null). You need now() and today() as callable functions. This preparation logic needs to run before every evaluation. Without a class, you repeat it everywhere.

Error handling is not simple. FEEL fails silently on some inputs (returns null) and throws on others. JavaScript new Function() is your fallback for expressions that feelin can't handle. Without a class, you write this fallback logic everywhere you call evaluate.

The FEEL/JavaScript boundary is not consistent. Some expressions work in FEEL but not JavaScript. Some work in JavaScript but not FEEL. A class gives you one place to manage the fallback strategy.

Testing is impossible without encapsulation. A class is testable. Scattered evaluate() calls are not.

The wrapper I built, which I'll show in full at the end, handles all of this once. Every evaluator in my system calls it.


The Three-Stage Evaluation Pipeline

Every expression evaluation goes through three stages: clean, prepare, evaluate. Understanding each stage is essential.

Stage 1: Clean the Expression

Form-JS stores FEEL expressions with a leading = character. This is a convention that signals "this is a FEEL expression, not a static value":

// Stored in the schema as:
field.feelExpression = '= amount * 0.2'

// But feelin expects:
evaluate('amount * 0.2', context)
Enter fullscreen mode Exit fullscreen mode

The first thing you do with every expression is strip the leading =:

function cleanExpression(expression) {
  if (!expression || typeof expression !== 'string') return null;

  const cleaned = expression.trim();

  // Strip the leading = that Form-JS uses to flag FEEL expressions
  if (cleaned.startsWith('=')) {
    return cleaned.substring(1).trim();
  }

  return cleaned;
}
Enter fullscreen mode Exit fullscreen mode

This sounds trivial. It causes bugs when you forget it. evaluate('= amount > 100', context) returns null silently because the = is not valid FEEL syntax at the start of an expression. No error, wrong result.

Stage 2: Prepare the Context

The context is the object you pass to feelin that contains the variable values FEEL expressions can reference. Getting this right is the hardest part.

I cover context preparation in depth in Article 4 because it deserves its own article — the type coercion problem alone took me two days to fully understand. Here's the summary of what you must handle:

Undefined variables cause FEEL errors, not JavaScript errors. If your expression references category but category is not in the context, feelin throws. JavaScript would just give you undefined. The fix: pre-populate all schema fields with null before overlaying actual data values.

// ✅ Pre-populate all schema fields with null
const context = {};
const allComponents = getAllComponents(schema.components);
allComponents.forEach(component => {
  if (component.key) context[component.key] = null;
});

// Then overlay with actual data
Object.assign(context, formData);
Enter fullscreen mode Exit fullscreen mode

Numeric strings break arithmetic. Form inputs return strings. FEEL arithmetic requires numbers. "150" > 100 is null in FEEL (type mismatch), not true. You must convert numeric strings to numbers:

Object.keys(data).forEach(key => {
  const value = data[key];
  if (typeof value === 'string') {
    const num = Number(value);
    context[key] = (!isNaN(num) && value !== '') ? num : value;
  } else {
    context[key] = value ?? null; // ✅ undefined becomes null
  }
});
Enter fullscreen mode Exit fullscreen mode

Date functions must be context functions. FEEL's now() and today() return FEEL date/time values. In a JavaScript context, you need them as callable functions:

context.now = () => new Date();
context.today = () => {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  return d;
};
Enter fullscreen mode Exit fullscreen mode

Stage 3: Evaluate With Fallback

The evaluation stage tries feelin first and falls back to JavaScript new Function() if feelin fails:

function evaluateExpression(cleanedExpr, context) {
  // Try FEEL first
  try {
    const result = evaluate(cleanedExpr, context);

    // feelin returns null for invalid operations (not undefined)
    // null is a valid FEEL result, so we can't treat null as failure
    // We just return whatever feelin gives us
    if (result !== undefined) {
      return result;
    }
  } catch (feelError) {
    if (feelError instanceof SyntaxError) {
      // Syntax errors are permanent failures — don't try JavaScript
      throw feelError;
    }
    // Runtime errors — try JavaScript fallback
    console.warn(`FEEL evaluation failed, trying JavaScript: ${feelError.message}`);
  }

  // JavaScript fallback
  return evaluateJavaScript(cleanedExpr, context);
}
Enter fullscreen mode Exit fullscreen mode

Why the JavaScript fallback matters:

FEEL is strictly typed. JavaScript is not. There are valid JavaScript expressions that feelin will reject:

// FEEL: returns null (type error — can't compare string and number this way in FEEL)
evaluate('status !== "inactive"', { status: "active" })

// JavaScript: returns true
new Function('status', 'return status !== "inactive"')('active')
Enter fullscreen mode Exit fullscreen mode

The expressions I needed to evaluate came from form designers who sometimes wrote JavaScript-style expressions, not strict FEEL. The fallback made the system tolerant of this.

The JavaScript fallback uses new Function() to evaluate expressions in a controlled scope:

function evaluateJavaScript(expression, context) {
  try {
    const keys = Object.keys(context);
    const values = Object.values(context);

    const fnBody = `
      'use strict';
      try {
        return (${expression});
      } catch (e) {
        return undefined;
      }
    `;

    const fn = new Function(...keys, fnBody);
    return fn(...values);
  } catch (err) {
    console.error('JavaScript eval error:', err.message);
    return undefined;
  }
}
Enter fullscreen mode Exit fullscreen mode

The context variables are passed as named function parameters. This is safer than using with() (which is deprecated) and more controlled than global eval(). The expression only has access to what you explicitly pass.

Security note: This evaluates arbitrary JavaScript code. In a Camunda form context, expressions are written by form designers — trusted users in your organization. If expressions could come from untrusted sources (end users filling in forms), you should not use this fallback.


Handling the Cases feelin Gets Wrong

Through building five different evaluators, I found specific categories of expressions where feelin returns null instead of the correct result. Understanding these saves hours of debugging.

Case 1: Comparing Against null or undefined

// Expression: = status = null
// formData: { status: undefined }

// ❌ What happens without context preparation:
// feelin receives: { status: undefined }
// feelin: returns null (undefined is not a valid FEEL value)

// ✅ What happens with context preparation:
// feelin receives: { status: null }
// feelin: returns true (null = null is valid FEEL)
Enter fullscreen mode Exit fullscreen mode

Always convert undefined to null in your context. Never let undefined reach feelin.

Case 2: Numeric String Arithmetic

// Expression: = quantity * price
// formData: { quantity: "5", price: "10.00" }

// ❌ Without type coercion:
// feelin receives: { quantity: "5", price: "10.00" }
// feelin: returns null (can't multiply strings)

// ✅ With type coercion:
// feelin receives: { quantity: 5, price: 10.00 }
// feelin: returns 50
Enter fullscreen mode Exit fullscreen mode

Case 3: Boolean String Values

// Expression: = isApproved = true
// formData: { isApproved: "true" }  ← strings from some form inputs

// ❌ feelin: returns null ("true" !== true in FEEL)

// ✅ Fix: normalize boolean strings
if (value === 'true') context[key] = true;
else if (value === 'false') context[key] = false;
Enter fullscreen mode Exit fullscreen mode

Case 4: Accessing Nested Objects

// Expression: = applicant.age > 18
// formData: { applicant: { age: 25 } }

// ✅ This works in feelin — nested access is supported
// No special handling needed for objects

// ❌ BUT: if applicant is undefined (not in context at all)
// feelin throws a runtime error
// Fix: ensure applicant is at least null in the context
Enter fullscreen mode Exit fullscreen mode

Case 5: List Functions

// Expression: = list contains(selectedValues, "approved")
// formData: { selectedValues: ["pending", "approved"] }

// ✅ This works in feelin — it has built-in list functions
// ❌ This fails in the JavaScript fallback
// (list contains is FEEL syntax, not valid JavaScript)
// Ensure feelin gets a chance to evaluate list expressions
// before falling back to JavaScript
Enter fullscreen mode Exit fullscreen mode

The Parse Error Flow

SyntaxError from feelin is your signal that an expression is fundamentally broken, not just failing at runtime. You want to surface these to form designers, not swallow them.

In a properties panel context (editor), you can surface syntax errors as inline validation:

import { evaluate, SyntaxError } from 'feelin';

function validateFeelExpression(expression) {
  if (!expression) return null;

  const cleaned = expression.startsWith('=')
    ? expression.substring(1).trim()
    : expression;

  try {
    // Try to parse without evaluating
    // Passing an empty context catches syntax errors
    evaluate(cleaned, {});
    return null; // No error
  } catch (err) {
    if (err instanceof SyntaxError) {
      return `Invalid FEEL expression: ${err.message}`;
    }
    // Runtime errors (like missing variables) are not syntax errors
    // They're expected when validating without real context
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

In a runtime evaluator context, syntax errors should be logged and treated as "expression evaluates to false/undefined":

try {
  return evaluateWithPipeline(expression, context);
} catch (err) {
  if (err instanceof SyntaxError) {
    console.error(`[Evaluator] Syntax error in expression "${expression}":`, err.message);
    return undefined; // Don't crash — treat as no result
  }
  throw err; // Re-throw non-syntax errors
}
Enter fullscreen mode Exit fullscreen mode

The Complete FeelEngine Module

Here is the complete module I use across all evaluators. It's a thin wrapper over feelin that handles the pipeline and exposes a clean API:

// src/formjs/extension/FeelEngine.js

import {
  evaluate as evalExpression,
  unaryTest as evalUnaryTest,
  SyntaxError
} from 'feelin';

// Re-export SyntaxError so callers can catch it without importing feelin directly
export { SyntaxError };

/**
 * Evaluate a FEEL expression or unary test.
 *
 * @param {'expression' | 'unaryTests'} type
 * @param {string} expression - The expression to evaluate (without leading =)
 * @param {Object} context - Variables available to the expression
 * @returns {*} The evaluation result
 */
export function evaluate(type, expression, context) {
  if (type === 'expression') {
    return evalExpression(expression, context);
  }

  if (type === 'unaryTests') {
    return evalUnaryTest(expression, context);
  }

  throw new Error(`Unknown evaluation type: ${type}. Use 'expression' or 'unaryTests'.`);
}
Enter fullscreen mode Exit fullscreen mode

And the base evaluator class that every evaluator in my system extends or follows as a template:

// src/formjs/extension/BaseEvaluator.js

import { evaluate, SyntaxError } from './FeelEngine.js';

export class BaseEvaluator {
  constructor() {
    this._feelEngine = { evaluate };
  }

  /**
   * Evaluate a FEEL expression with automatic cleaning, context preparation,
   * and JavaScript fallback.
   *
   * @param {string} expression - Expression from the schema (may have leading =)
   * @param {Object} formData - Current form data
   * @returns {*} Evaluation result
   */
  evaluateFeel(expression, formData) {
    if (!expression || typeof expression !== 'string') {
      return undefined;
    }

    // Stage 1: Clean
    const cleanExpr = expression.trim().startsWith('=')
      ? expression.trim().substring(1).trim()
      : expression.trim();

    if (!cleanExpr) return undefined;

    // Stage 2: Prepare context
    const context = this.prepareContext(formData);

    // Stage 3: Evaluate with fallback
    try {
      const result = this._feelEngine.evaluate('expression', cleanExpr, context);
      if (result !== null && result !== undefined) {
        return result;
      }
    } catch (feelErr) {
      if (feelErr instanceof SyntaxError) {
        console.error(`[${this.constructor.name}] Syntax error: "${expression}"`, feelErr.message);
        return undefined;
      }
      console.warn(`[${this.constructor.name}] FEEL failed, trying JavaScript: ${feelErr.message}`);
    }

    return this.evaluateJavaScript(cleanExpr, context);
  }

  /**
   * Prepare a FEEL-compatible context from form data.
   * Handles type coercion, null/undefined normalization,
   * and date helper functions.
   */
  prepareContext(data, schema = null) {
    const context = {};

    // Pre-populate schema fields with null to prevent "undefined variable" errors
    // (covered in depth in Article 4)
    if (schema) {
      const components = this._getAllComponents(schema.components || []);
      components.forEach(c => {
        if (c.key) context[c.key] = null;
      });
    }

    // Overlay with actual data values, applying type coercion
    Object.keys(data).forEach(key => {
      const value = data[key];

      if (value === null || value === undefined) {
        context[key] = null;
      } else if (typeof value === 'boolean') {
        context[key] = value;
      } else if (typeof value === 'number') {
        context[key] = value;
      } else if (typeof value === 'string') {
        // Normalize boolean strings
        if (value === 'true') { context[key] = true; return; }
        if (value === 'false') { context[key] = false; return; }

        // Convert numeric strings to numbers
        const num = Number(value);
        context[key] = (!isNaN(num) && value.trim() !== '') ? num : value;
      } else {
        // Objects, arrays — pass through as-is
        context[key] = value;
      }
    });

    // Date helper functions
    context.now = () => new Date();
    context.today = () => {
      const d = new Date();
      d.setHours(0, 0, 0, 0);
      return d;
    };

    return context;
  }

  /**
   * JavaScript fallback evaluator for expressions that feelin can't handle.
   * Only use for trusted expression sources (form designers, not end users).
   */
  evaluateJavaScript(expression, context) {
    try {
      const keys = Object.keys(context);
      const values = Object.values(context);

      const fn = new Function(
        ...keys,
        `'use strict'; try { return (${expression}); } catch(e) { return undefined; }`
      );

      return fn(...values);
    } catch (err) {
      console.error(`[${this.constructor.name}] JavaScript eval error:`, err.message);
      return undefined;
    }
  }

  /**
   * Interpret any value as a boolean for use in conditional logic.
   */
  interpretAsBoolean(value) {
    if (typeof value === 'boolean') return value;
    if (typeof value === 'string') {
      const normalized = value.trim().toLowerCase();
      if (normalized === 'true') return true;
      if (normalized === 'false') return false;
      return normalized.length > 0;
    }
    if (typeof value === 'number') return value !== 0;
    return !!value;
  }

  /**
   * Recursively collect all components including nested groups.
   */
  _getAllComponents(components = []) {
    const all = [];
    for (const component of components) {
      all.push(component);
      if (component.type === 'group' && Array.isArray(component.components)) {
        all.push(...this._getAllComponents(component.components));
      }
    }
    return all;
  }
}
Enter fullscreen mode Exit fullscreen mode

A concrete evaluator that uses this base:

// src/formjs/extension/BindingEvaluator.js

import { BaseEvaluator } from './BaseEvaluator.js';

export class BindingEvaluator extends BaseEvaluator {
  constructor(eventBus, form) {
    super();
    this._eventBus = eventBus;
    this._form = form;
    this._evaluating = false;
    this._initialized = false;

    this._eventBus.on('form.init', () => {
      this._initialized = true;
      setTimeout(() => this.evaluateAll(), 50);
    });

    this._eventBus.on('changed', () => {
      if (!this._initialized || this._evaluating) return;
      setTimeout(() => {
        if (!this._evaluating) this.evaluateAll();
      }, 10);
    });
  }

  evaluateAll() {
    if (!this._form?._state?.schema) return;

    this._evaluating = true;

    try {
      const schema = this._form._state.schema;
      const data = this._form._state.data || {};

      const components = this._getAllComponents(schema.components || []);
      const bound = components.filter(c => c.feelExpression && c.key);

      if (!bound.length) return;

      const updates = {};
      let hasChanges = false;

      bound.forEach(component => {
        try {
          // ✅ The three-stage pipeline in action
          const result = this.evaluateFeel(
            component.feelExpression,
            data
          );

          if (result !== undefined && data[component.key] !== result) {
            updates[component.key] = result;
            hasChanges = true;
          }
        } catch (err) {
          console.error(`[BindingEvaluator] Error evaluating ${component.key}:`, err.message);
        }
      });

      if (hasChanges) {
        this._form._setState({ data: { ...data, ...updates } });
      }
    } finally {
      this._evaluating = false;
    }
  }
}

BindingEvaluator.$inject = ['eventBus', 'form'];
Enter fullscreen mode Exit fullscreen mode

Quick Reference: Expression Examples That Work

These all go through the three-stage pipeline and evaluate correctly:

const evaluator = new BaseEvaluator();

// Arithmetic
evaluator.evaluateFeel('= amount * 0.2', { amount: 500 })
// → 100

// Comparison
evaluator.evaluateFeel('= age >= 18', { age: "21" })
// → true (numeric string coercion handles this)

// String concatenation
evaluator.evaluateFeel('= firstName + " " + lastName', {
  firstName: 'John', lastName: 'Doe'
})
// → "John Doe"

// Null check
evaluator.evaluateFeel('= category = null', { category: undefined })
// → true (undefined→null coercion handles this)

// List contains
evaluator.evaluateFeel('= list contains(roles, "admin")', {
  roles: ['user', 'admin']
})
// → true

// Date comparison
evaluator.evaluateFeel('= dueDate < today()', {
  dueDate: '2020-01-01'
})
// → true (today() is available as a context function)

// Conditional (if-then-else)
evaluator.evaluateFeel('= if status = "active" then "Show" else "Hide"', {
  status: 'active'
})
// → "Show"

// Nested object access
evaluator.evaluateFeel('= applicant.age > 18', {
  applicant: { age: 25 }
})
// → true
Enter fullscreen mode Exit fullscreen mode

The Tradeoffs

new Function() is a security boundary. The JavaScript fallback executes arbitrary code. In a Camunda deployment, expressions are written by process designers, not end users — this is an acceptable risk. If your deployment allows end users to provide expression strings, disable the JavaScript fallback entirely and let feelin-only failures return undefined.

feelin returns null for invalid operations, not errors. evaluate('amount * price', { amount: null, price: 50 }) returns null, not 0 and not an error. If your evaluator needs to distinguish "the expression evaluated to null" from "the expression failed to evaluate", you need additional tracking. Most use cases don't need this distinction.

The JavaScript fallback diverges from FEEL semantics. = a or b is valid FEEL (returns a if truthy, else b). In JavaScript, a or b is a syntax error — you'd need a || b. The fallback won't save you here. The safest approach: write expressions in strict FEEL and let the fallback only handle truly ambiguous cases.

No TypeScript types for feelin's context. The context object you pass is any. feelin won't tell you if you've passed the wrong type for a variable — it'll just return null from the expression. Type safety requires discipline in prepareContext, not compiler enforcement.


What Comes Next

You now have a working FEEL evaluation pipeline. But getting the context right — specifically the type coercion that makes "150" > 100 return true instead of null — is a problem deep enough to deserve its own article.

Article 4 covers the context preparation problem in full detail: every type coercion case, the schema pre-population pattern that prevents "undefined variable" errors, and the specific FEEL failures you'll encounter without it.

Article 5 covers how to use this evaluation pipeline inside Form-JS's properties panel, where you need FEEL support for entry components in the editor — a different context with different challenges.


This is Part 3 of "Extending bpmn-io Form-JS Beyond Its Limits." The series covers the complete architecture for production-grade Form-JS extensions — the documentation that doesn't exist yet.


Tags: camunda bpmn formjs feel expression-language javascript devex

Top comments (0)