DEV Community

Cover image for Preparing a FEEL Context: The Type Coercion Problem Nobody Warns You About
Sam Abaasi
Sam Abaasi

Posted on

Preparing a FEEL Context: The Type Coercion Problem Nobody Warns You About

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


You write = amount > 100 in your Form-JS binding field. The user fills in 150. The expression returns null.

Not false. Not an error. Just null. Silently wrong.

You check the expression syntax — it's correct. You check that amount is in your context — it is. You add console logs and confirm the value is "150". Everything looks right and nothing works.

The problem is the quotes around 150. Your form field returned the string "150", not the number 150. FEEL is strictly typed. "150" > 100 is not a valid FEEL operation — you're comparing a string to a number. FEEL doesn't throw an error for this. It returns null. Silently. With no indication of what went wrong.

This is the type coercion problem that nobody warns you about, and it will affect every FEEL expression you evaluate at runtime until you understand it and fix it systematically.


The Problem

When Form-JS collects user input, everything comes back as strings. Number fields return "150" not 150. Checkbox fields return "true" not true. Empty fields return "" not null. This is normal browser behavior — HTML form inputs are strings.

FEEL, on the other hand, is strictly typed. It distinguishes between:

  • The number 150 and the string "150"
  • The boolean true and the string "true"
  • The value null and an absent variable

When these two worlds collide without careful preparation, your FEEL expressions return null and you spend hours debugging expressions that are syntactically correct but semantically broken.

Here is the full catalog of what goes wrong and exactly why:

// What your form data looks like after a user fills in fields:
const rawFormData = {
  amount:      "150",     // Number field — returns string
  isApproved:  "true",    // Checkbox — returns string
  category:    "A",       // Text field — correct, already string
  quantity:    "",        // Empty number field — returns empty string
  notes:       null,      // Cleared text field — returns null
  address:     undefined, // Field not yet touched — returns undefined
  // "status" field exists in schema but user hasn't touched it yet
  // So it's completely absent from formData
};

// What FEEL needs:
const feelContext = {
  amount:      150,       // Must be a number
  isApproved:  true,      // Must be a boolean
  category:    "A",       // Fine as-is
  quantity:    null,      // Empty → null (not "")
  notes:       null,      // Already null — fine
  address:     null,      // undefined → null
  status:      null,      // Absent fields → null (not missing entirely)
};
Enter fullscreen mode Exit fullscreen mode

Without this transformation, here is what happens to common expressions:

// Expression: = amount > 100
// Raw context: { amount: "150" }
evaluate('amount > 100', { amount: "150" })
// → null  ❌ (string vs number comparison — invalid in FEEL)

// Expression: = isApproved = true
// Raw context: { isApproved: "true" }
evaluate('isApproved = true', { isApproved: "true" })
// → null  ❌ ("true" !== true in FEEL's type system)

// Expression: = quantity > 0
// Raw context: { quantity: "" }
evaluate('quantity > 0', { quantity: "" })
// → null  ❌ (can't compare empty string to number)

// Expression: = status = "active"
// Raw context: {} (status not present at all)
evaluate('status = "active"', {})
// → throws  ❌ (undefined variable — FEEL throws, doesn't return null)
Enter fullscreen mode Exit fullscreen mode

Every one of these returns the wrong result without any error message that points you to the real cause. Let me walk through each case and fix it.


What I Tried First

My first response to the null return was to check my expression syntax. I spent an hour convinced I was writing incorrect FEEL. The expression was fine.

My second response was to add debug logging around the feelin evaluate call. I confirmed the expression was being passed correctly. I confirmed the context contained amount. I confirmed the value was "150". Everything looked correct. The result was still null.

It took a typeof amount check in the context to finally see it: "string". The number field had returned a string. Once I saw that, I understood the whole problem immediately — but by then I'd wasted most of a day.

My first fix was to convert values manually at the call site:

// ❌ The naive approach — manual conversion everywhere
const result = evaluateFeel('= amount > 100', {
  amount: Number(formData.amount)  // Manual conversion
});
Enter fullscreen mode Exit fullscreen mode

This works for one expression. For a form with 30 fields and 15 expressions, you're converting every value manually at every call site. When you add a new field or change a type, you update every call site. This is not maintainable.

The correct approach is systematic: fix the context in one place, before every evaluation, automatically.


The Solution: Systematic Context Preparation

The prepareContext method runs before every FEEL evaluation. It transforms raw form data into a FEEL-compatible context object. Here is the complete implementation with explanations for every decision:

prepareContext(data, schema = null) {
  const context = {};

  // ============================================================
  // Step 1: Pre-populate schema fields with null
  // (Explained in detail below)
  // ============================================================
  if (schema) {
    const allComponents = this._getAllComponents(schema.components || []);
    allComponents.forEach(component => {
      if (component.key) {
        context[component.key] = null;
      }
    });
  }

  // ============================================================
  // Step 2: Overlay with actual data, applying type coercion
  // ============================================================
  Object.keys(data).forEach(key => {
    const value = data[key];
    context[key] = this._coerceValue(value);
  });

  // ============================================================
  // Step 3: Add date/time helper functions
  // ============================================================
  context.now = () => new Date();
  context.today = () => {
    const d = new Date();
    d.setHours(0, 0, 0, 0);
    return d;
  };

  return context;
}

_coerceValue(value) {
  // null and undefined both become null in FEEL
  if (value === null || value === undefined) {
    return null;
  }

  // Booleans stay as booleans
  if (typeof value === 'boolean') {
    return value;
  }

  // Numbers stay as numbers
  if (typeof value === 'number') {
    return value;
  }

  // Objects and arrays pass through
  // (FEEL can access object properties and list items natively)
  if (typeof value === 'object') {
    return value;
  }

  // Strings need the most work
  if (typeof value === 'string') {
    // Empty string → null
    // An empty number field should be treated as "no value"
    if (value.trim() === '') {
      return null;
    }

    // Boolean strings → actual booleans
    if (value === 'true') return true;
    if (value === 'false') return false;

    // Numeric strings → numbers
    // Number("150") → 150
    // Number("3.14") → 3.14
    // Number("") → 0 (caught above by empty string check)
    // Number("abc") → NaN (caught by isNaN check)
    // Number("123abc") → NaN (caught by isNaN check)
    const numValue = Number(value);
    if (!isNaN(numValue)) {
      return numValue;
    }

    // Everything else stays as a string
    return value;
  }

  return value;
}
Enter fullscreen mode Exit fullscreen mode

Let me walk through each decision in detail.


Case 1: null and undefined Both Become null

FEEL has a null value. It does not have undefined. If you pass undefined to feelin, it either throws or produces incorrect results depending on the expression.

The transformation is simple: treat them the same.

if (value === null || value === undefined) {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Why does this matter? Because form data contains undefined values in two scenarios:

  1. The user hasn't touched a field yet. The field exists in the schema but hasn't been interacted with. Form-JS initializes it as undefined in the data object.

  2. A field was cleared. Some form implementations set cleared fields to undefined rather than null.

In both cases, FEEL should see null — a known empty value — rather than a missing variable.

// Before coercion
const data = { amount: undefined };

// feelin with undefined:
evaluate('amount = null', { amount: undefined })
// → throws: "amount is not defined" in some feelin versions
// → null in others
// Behavior is inconsistent

// feelin with null:
evaluate('amount = null', { amount: null })
// → true ✅
Enter fullscreen mode Exit fullscreen mode

Case 2: Numeric Strings Must Become Numbers

This is the case that burns everyone. HTML number inputs return strings. FEEL arithmetic requires numbers.

// Before coercion
const data = { quantity: "5", price: "10.50" };

// ❌ String arithmetic in FEEL:
evaluate('quantity * price', { quantity: "5", price: "10.50" })
// → null (FEEL won't multiply strings)

// ✅ Number arithmetic in FEEL:
evaluate('quantity * price', { quantity: 5, price: 10.50 })
// → 52.5
Enter fullscreen mode Exit fullscreen mode

The conversion uses JavaScript's Number() function with an isNaN guard:

const numValue = Number(value);
if (!isNaN(numValue)) {
  return numValue;
}
Enter fullscreen mode Exit fullscreen mode

What Number() handles correctly:

Number("150")     // → 150     ✅
Number("3.14")    // → 3.14    ✅
Number("0")       // → 0       ✅
Number("-5")      // → -5      ✅
Number("1e3")     // → 1000    ✅
Number("  150 ") // → 150     ✅ (trims whitespace)
Enter fullscreen mode Exit fullscreen mode

What Number() does NOT convert (correctly):

Number("abc")     // → NaN    — isNaN catches this, stays as string ✅
Number("123abc")  // → NaN    — isNaN catches this, stays as string ✅
Number("true")    // → 1      — caught BEFORE this by boolean string check ✅
Number("")        // → 0      — caught BEFORE this by empty string check ✅
Enter fullscreen mode Exit fullscreen mode

The order of checks matters. The empty string check and boolean string check must run before the numeric check to prevent "" from becoming 0 and "true" from becoming 1.


Case 3: Boolean Strings Must Become Booleans

Checkbox fields and toggle fields in Form-JS can return "true" or "false" as strings in some configurations. FEEL's = true check is a strict type comparison.

// ❌ String boolean in FEEL:
evaluate('isApproved = true', { isApproved: "true" })
// → null ("true" is a string, not the boolean true)

// ✅ Actual boolean in FEEL:
evaluate('isApproved = true', { isApproved: true })
// → true
Enter fullscreen mode Exit fullscreen mode

The fix is explicit string matching before the numeric conversion check:

if (value === 'true') return true;
if (value === 'false') return false;
Enter fullscreen mode Exit fullscreen mode

Note: this check is case-sensitive. "True" and "TRUE" are not converted. If your form fields can return mixed-case boolean strings, add:

const lower = value.toLowerCase();
if (lower === 'true') return true;
if (lower === 'false') return false;
Enter fullscreen mode Exit fullscreen mode

I kept it case-sensitive because I wanted to be conservative — I only want to convert values that I'm certain are booleans, not values that happen to look boolean.


Case 4: Empty Strings Become null

An empty string is not the same as no value in FEEL. But in form context, an empty input field means "no value provided."

// ❌ Empty string in FEEL:
evaluate('amount > 0', { amount: "" })
// → null (can't compare empty string to number)

// ✅ null in FEEL:
evaluate('amount > 0', { amount: null })
// → false (null > 0 is false in FEEL, not an error)
Enter fullscreen mode Exit fullscreen mode

The check is simple but must run before the numeric conversion:

if (value.trim() === '') {
  return null;
}
Enter fullscreen mode Exit fullscreen mode

Why .trim()? Because " " (whitespace only) should also be treated as empty. A field containing only spaces is functionally empty.


Case 5: Pre-Populating Schema Fields With null

This is the most important step and the one that took me longest to understand. Here is the scenario:

You have a form with two fields: category and subcategory. The user has filled in category but hasn't touched subcategory yet. Your FEEL expression is = category = "A" and subcategory = null.

Without pre-population:

// formData only contains fields the user has touched
const formData = { category: "A" };
// subcategory is not in formData at all

evaluate(
  'category = "A" and subcategory = null',
  { category: "A" }  // subcategory missing entirely
)
// → throws: cannot read subcategory (undefined variable)
Enter fullscreen mode Exit fullscreen mode

FEEL throws on undefined variables. Not null. Not false. It throws a runtime error.

With pre-population:

// Build context starting with all schema fields = null
const context = {
  category: null,     // Schema field, pre-populated
  subcategory: null,  // Schema field, pre-populated
};

// Overlay with actual data
Object.assign(context, { category: "A" });
// → { category: "A", subcategory: null }

evaluate(
  'category = "A" and subcategory = null',
  { category: "A", subcategory: null }
)
// → true ✅
Enter fullscreen mode Exit fullscreen mode

Pre-population is the difference between "FEEL throws on fields the user hasn't touched yet" and "FEEL treats untouched fields as null, which is correct."

Here is the implementation:

if (schema) {
  const allComponents = this._getAllComponents(schema.components || []);
  allComponents.forEach(component => {
    if (component.key) {
      context[component.key] = null;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

And _getAllComponents handles nested groups:

_getAllComponents(components = []) {
  const all = [];
  for (const component of components) {
    all.push(component);
    // Recurse into groups to find nested fields
    if (component.type === 'group' && Array.isArray(component.components)) {
      all.push(...this._getAllComponents(component.components));
    }
  }
  return all;
}
Enter fullscreen mode Exit fullscreen mode

Important: The overlay order matters. Pre-populate first, then overlay with actual data. If you do it in reverse, actual data values get overwritten by null.

// ✅ Correct order: pre-populate, then overlay
allComponents.forEach(c => { if (c.key) context[c.key] = null; });
Object.keys(data).forEach(key => { context[key] = coerce(data[key]); });

// ❌ Wrong order: overlay, then pre-populate
Object.keys(data).forEach(key => { context[key] = coerce(data[key]); });
allComponents.forEach(c => { if (c.key) context[c.key] = null; }); // Overwrites real data!
Enter fullscreen mode Exit fullscreen mode

Case 6: Date/Time Helper Functions

FEEL has built-in date and time support. Expressions like = dueDate < today() and = createdAt > now() are valid FEEL. But today() and now() need to return something that FEEL can use in date comparisons.

In a pure FEEL environment, today() returns a FEEL date value. In a JavaScript runtime, you're working with JavaScript Date objects. The bridge is to add today and now as context functions that return JavaScript Date objects:

context.now = () => new Date();
context.today = () => {
  const d = new Date();
  d.setHours(0, 0, 0, 0);  // Midnight — date only, no time component
  return d;
};
Enter fullscreen mode Exit fullscreen mode

For date field comparisons to work, the date string stored in form data must be converted to a Date object before comparison. Date fields in Form-JS store values as ISO strings like "2024-01-15":

// Expression: = dueDate < today()
// formData: { dueDate: "2024-01-15" }

// ❌ Without date conversion:
evaluate('dueDate < today()', {
  dueDate: "2024-01-15",
  today: () => new Date()
})
// → null (can't compare string to Date)

// ✅ With date conversion in context:
evaluate('dueDate < today()', {
  dueDate: new Date("2024-01-15"),
  today: () => new Date()
})
// → true or false depending on today's date
Enter fullscreen mode Exit fullscreen mode

Add date string detection to your _coerceValue function:

// After the boolean string checks, before the numeric check:
if (typeof value === 'string') {
  // ISO date format: "2024-01-15"
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    const date = new Date(value + 'T00:00:00');
    if (!isNaN(date.getTime())) return date;
  }

  // ISO datetime format: "2024-01-15T10:30:00"
  if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) {
    const date = new Date(value);
    if (!isNaN(date.getTime())) return date;
  }

  // Space-separated datetime: "2024-01-15 10:30:00"
  if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) {
    const date = new Date(value.replace(' ', 'T'));
    if (!isNaN(date.getTime())) return date;
  }
}
Enter fullscreen mode Exit fullscreen mode

A caution on date conversion: Converting all date-looking strings to Date objects is aggressive. If a text field contains "2024-01-15" as a reference number or code, you probably don't want it converted to a Date. In practice, I only apply date conversion to fields that have type: 'date' or type: 'datetime' in the schema, not to all string values. The context preparation can check the schema type before converting:

// More surgical: only convert date fields
allComponents.forEach(component => {
  if (['date', 'datetime', 'time'].includes(component.type) && component.key) {
    const value = data[component.key];
    if (typeof value === 'string' && value) {
      context[component.key] = this._coerceDateValue(value, component.type);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

The Complete prepareContext Implementation

Here is the final, complete implementation that handles every case:

/**
 * Transforms raw form data into a FEEL-compatible context.
 *
 * Handles:
 * - Numeric strings → numbers
 * - Boolean strings → booleans
 * - Empty strings → null
 * - undefined → null
 * - Pre-population of schema fields with null
 * - Date string → Date object (for date/datetime fields)
 * - now() and today() as context functions
 */
prepareContext(data, schema = null) {
  const context = {};

  // Step 1: Pre-populate all schema fields with null
  // This prevents FEEL errors for fields the user hasn't touched yet
  if (schema) {
    const allComponents = this._getAllComponents(schema.components || []);
    allComponents.forEach(component => {
      if (component.key) {
        context[component.key] = null;
      }
    });
  }

  // Step 2: Overlay with coerced actual values
  Object.keys(data).forEach(key => {
    context[key] = this._coerceValue(data[key]);
  });

  // Step 3: Date/time fields get Date object conversion
  if (schema) {
    const dateComponents = this._getAllComponents(schema.components || [])
      .filter(c => ['date', 'datetime', 'time'].includes(c.type) && c.key);

    dateComponents.forEach(component => {
      const rawValue = data[component.key];
      if (typeof rawValue === 'string' && rawValue.trim() !== '') {
        const converted = this._coerceDateValue(rawValue, component.type);
        if (converted !== null) {
          context[component.key] = converted;
        }
      }
    });
  }

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

  return context;
}

_coerceValue(value) {
  if (value === null || value === undefined) return null;
  if (typeof value === 'boolean') return value;
  if (typeof value === 'number') return value;
  if (typeof value === 'object') return value; // Arrays, objects pass through

  if (typeof value === 'string') {
    if (value.trim() === '') return null;           // Empty → null
    if (value === 'true') return true;              // Boolean string → boolean
    if (value === 'false') return false;            // Boolean string → boolean
    const num = Number(value);
    if (!isNaN(num)) return num;                    // Numeric string → number
    return value;                                   // Everything else stays string
  }

  return value;
}

_coerceDateValue(value, fieldType) {
  try {
    if (fieldType === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
      return new Date(value + 'T00:00:00');
    }
    if (fieldType === 'datetime') {
      const normalized = value.replace(' ', 'T');
      const d = new Date(normalized);
      return isNaN(d.getTime()) ? null : d;
    }
    if (fieldType === 'time' && /^\d{2}:\d{2}:\d{2}$/.test(value)) {
      // Time values can't be compared to Date objects directly
      // Return as string for time comparisons
      return value;
    }
  } catch (e) {
    return null;
  }
  return null;
}

_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

Debugging Context Problems

When a FEEL expression returns null unexpectedly, this is your debugging checklist:

1. Log the context, not just the data:

const context = this.prepareContext(formData, schema);
console.log('FEEL Context:', context);
console.log('Types:', Object.fromEntries(
  Object.entries(context).map(([k, v]) => [k, typeof v])
));
Enter fullscreen mode Exit fullscreen mode

2. Check for string-typed numbers:

Object.entries(context).forEach(([key, value]) => {
  if (typeof value === 'string' && !isNaN(Number(value))) {
    console.warn(`[Context] "${key}" is a numeric string: "${value}"`);
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Check for undefined values that survived coercion:

Object.entries(context).forEach(([key, value]) => {
  if (value === undefined) {
    console.error(`[Context] "${key}" is undefined — should be null`);
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Test the expression with a minimal context:

// Strip the expression down to only the variables it actually uses
// and test with known good values
import { evaluate } from 'feelin';

evaluate('amount > 100', { amount: 150 })   // → true — the expression is correct
evaluate('amount > 100', { amount: "150" }) // → null — there's your problem
Enter fullscreen mode Exit fullscreen mode

5. Check if the variable is in the context at all:

FEEL throws on missing variables, not missing values. null is present. undefined is treated as missing. If you're getting a throw rather than a null return, a variable is missing from the context entirely.


What This Looks Like Before and After

Here is a complete before/after showing the same expressions with raw form data vs prepared context:

// Raw form data from Form-JS after user fills in a form
const rawFormData = {
  firstName:   "John",
  lastName:    "Doe",
  age:         "34",         // Number field → string
  salary:      "75000",      // Number field → string
  isManager:   "true",       // Checkbox → string
  startDate:   "2024-01-15", // Date field → string
  notes:       "",           // Empty text field
  department:  undefined,    // Field not yet touched
  // "status" field exists in schema but absent from data
};

// ❌ BEFORE: using raw data as FEEL context
const rawContext = rawFormData;
evaluate('age > 30',         rawContext) // → null   (string vs number)
evaluate('salary > 50000',   rawContext) // → null   (string vs number)
evaluate('isManager = true', rawContext) // → null   (string vs boolean)
evaluate('notes = null',     rawContext) // → null   ("" vs null)
evaluate('status = null',    rawContext) // → throws (undefined variable)

// ✅ AFTER: using prepareContext
const schema = { components: [
  { key: 'firstName', type: 'textfield' },
  { key: 'lastName',  type: 'textfield' },
  { key: 'age',       type: 'number' },
  { key: 'salary',    type: 'number' },
  { key: 'isManager', type: 'checkbox' },
  { key: 'startDate', type: 'date' },
  { key: 'notes',     type: 'textarea' },
  { key: 'department',type: 'textfield' },
  { key: 'status',    type: 'select' },
]};

const preparedContext = prepareContext(rawFormData, schema);
// {
//   firstName:  "John",
//   lastName:   "Doe",
//   age:        34,             ← number
//   salary:     75000,          ← number
//   isManager:  true,           ← boolean
//   startDate:  Date object,    ← Date
//   notes:      null,           ← null (was "")
//   department: null,           ← null (was undefined)
//   status:     null,           ← null (was absent — pre-populated)
//   now:        [Function],
//   today:      [Function]
// }

evaluate('age > 30',         preparedContext) // → true  ✅
evaluate('salary > 50000',   preparedContext) // → true  ✅
evaluate('isManager = true', preparedContext) // → true  ✅
evaluate('notes = null',     preparedContext) // → true  ✅
evaluate('status = null',    preparedContext) // → true  ✅
Enter fullscreen mode Exit fullscreen mode

The Tradeoffs

Numeric string conversion is lossy in one case. The string "007" becomes the number 7. If you have a field containing a leading-zero code like an employee ID or product SKU, this is destructive. Fix: check whether the field type is number before converting — only convert values from fields that are actually number fields.

// More conservative: only convert values from number-type fields
if (component.type === 'number') {
  const num = Number(value);
  if (!isNaN(num)) context[component.key] = num;
}
Enter fullscreen mode Exit fullscreen mode

Pre-population adds all schema fields to every context. For a 50-field form, every evaluation context has 50+ keys. This is not a performance problem in practice — feelin's lookup is O(1) — but it means your context object is always the full form, not a minimal context for the specific expression.

Date conversion is heuristic. The regex patterns I use for date detection are correct for ISO format dates. If your form fields store dates in other formats (DD/MM/YYYY, MM-DD-YYYY), you'll need to adjust the patterns or add a more sophisticated date parsing strategy.

today() and now() return JavaScript Date objects, not FEEL date values. For simple comparisons (dueDate < today()) this works correctly because feelin's comparison operators handle JavaScript Date objects. For FEEL-specific date functions like date and time("2024-01-01"), you're in JavaScript territory and the behavior may differ from strict FEEL semantics.


What Comes Next

Context preparation solves the data type problem. But there's a second dimension: what happens when you have multiple evaluators all calling prepareContext, all pre-populating the schema, all running on every changed event? At scale this has performance implications.

Article 5 covers the properties panel layer — specifically how to use FEEL expressions inside the editor's properties panel, where the context is different (schema data instead of runtime data) and the evaluation purpose is different (validation of configuration, not form logic).

Article 8 covers scoped re-evaluation — how to make evaluators smart enough to only re-run when a relevant field changes, reducing the number of prepareContext calls in a 50-field form from "on every keystroke for every evaluator" to "only when a field this evaluator cares about changes."


This is Part 4 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 type-coercion debugging expression-language javascript

Top comments (0)