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
150and the string"150" - The boolean
trueand the string"true" - The value
nulland 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)
};
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)
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
});
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;
}
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;
}
Why does this matter? Because form data contains undefined values in two scenarios:
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
undefinedin the data object.A field was cleared. Some form implementations set cleared fields to
undefinedrather thannull.
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 ✅
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
The conversion uses JavaScript's Number() function with an isNaN guard:
const numValue = Number(value);
if (!isNaN(numValue)) {
return numValue;
}
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)
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 ✅
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
The fix is explicit string matching before the numeric conversion check:
if (value === 'true') return true;
if (value === 'false') return false;
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;
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)
The check is simple but must run before the numeric conversion:
if (value.trim() === '') {
return null;
}
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)
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 ✅
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;
}
});
}
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;
}
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!
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;
};
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
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;
}
}
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);
}
}
});
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;
}
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])
));
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}"`);
}
});
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`);
}
});
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
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 ✅
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;
}
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)