A powerful, hierarchical rule engine for dynamic field configuration using a condition-action pattern. Flywheel enables complex form logic, calculations, and field state management with automatic dependency tracking and intelligent caching.
- Features
- Installation
- Quick Start
- Core Concepts
- API Reference
- Logic Operators
- Action Types
- Examples
- Advanced Features
- Migration Guide
- 🎯 Condition-Action Rules: Define when conditions trigger specific actions
- 🔄 Automatic Dependency Tracking: Intelligent evaluation order and caching
- ⚡ Performance Optimized: Smart caching with dependency-based invalidation
- 🏗️ Modular Architecture: Separate components for field state, caching, and dependency management
- 🚀 Field Initialization: Context-aware field initialization with init actions
- 🧩 Extensible: Custom operators, actions, and field state properties with dependency tracking
- 🔍 Debug-Friendly: Comprehensive validation and evaluation tracing
- 📊 Rich Logic System: 25+ built-in operators with unlimited nesting
- 🏗️ Type-Safe: Full TypeScript support with comprehensive type definitions
npm install flywheel-rulesimport { RuleEngine } from 'flywheel-rules';
// Initialize the rule engine
const engine = new RuleEngine({
onEvent: (eventType, params) => {
console.log(`Event: ${eventType}`, params);
}
});
// Define rules
const ruleSet = {
field1: [
{
condition: { ">": [{ "var": ["age"] }, 18] },
action: { set: { target: "field1.isVisible", value: true } },
priority: 1
}
]
};
// Load rules and evaluate
engine.loadRuleSet(ruleSet);
engine.updateFieldValue({ age: 25 });
const fieldState = engine.evaluateField("field1");
console.log(fieldState); // { isVisible: true, isRequired: false, ... }Rules follow a condition-action pattern with priority-based execution:
interface FieldRule {
condition: Logic; // When to execute
action: Action; // What to execute
priority: number; // Execution order (lower = first)
}
interface RuleSet {
[fieldName: string]: FieldRule[];
}Each field maintains state properties that can be modified by rules:
interface FieldState {
value?: any; // Field value
isVisible: boolean; // Field visibility
isRequired: boolean; // Field requirement
calculatedValue?: any; // Computed values
// ... extensible via onFieldStateCreation
}Fields can be initialized with default state and values using init actions:
// Context-based field initialization
{
condition: { "==": [{ "var": ["userType"] }, "premium"] },
action: {
"init": {
fieldState: { isVisible: true, theme: "premium" },
fieldValue: "default-premium-value"
}
},
priority: 0 // Init actions typically run first
}constructor(options?: RuleEngineOptions)Options:
onEvent?: Handler for custom events triggered by rulesonFieldStateCreation?: Customize default field state properties
// Load and validate rule set
loadRuleSet(ruleSet: RuleSet): void
// Update field values and trigger re-evaluation
updateFieldValue(fieldUpdates: Record<string, any>): string[]
// Get current field value
getFieldValue(fieldName: string): any
// Evaluate specific field and return complete state
evaluateField(fieldName: string): FieldState
// Register reusable condition logic
registerSharedRules(sharedRules: Record<string, Logic>): void
// Register lookup tables for data relationships
registerLookupTables(tables: LookupTable[]): void
// Register custom action types with optional dependency tracking
registerActionHandler(params: {
actionType: string;
handler: (payload: any, context: any, helpers?: ActionHandlerOptions) => void;
dependencyVisitor?: CustomActionDependencyVisitor;
}): void
// Register custom logic operators with optional dependency tracking
registerCustomLogic(params: {
operator: string;
handler: (args: any[], context: any) => any;
dependencyVisitor?: CustomLogicDependencyVisitor;
}): void
// Debug utilities
getDependenciesOf(fieldName: string): string[]
getLogicResolver(): LogicResolverinterface LookupTable {
table: any[]; // Array of lookup records
primaryKey: string; // Field to match against
name?: string; // Optional table name
}{ "var": ["fieldName"] } // Access field value
{ "var": ["field.nested.property"] } // Dot notation support
{ "var": ["$"] } // Current item in array operations{ "+": [1, 2, 3] } // Addition: 6
{ "-": [10, 3] } // Subtraction: 7
{ "*": [4, 5] } // Multiplication: 20
{ "/": [15, 3] } // Division: 5
{ "sqrt": [16] } // Square root: 4
{ "floor": [3.7] } // Floor: 3
{ "abs": [-5] } // Absolute: 5{ ">": [5, 3] } // Greater than: true
{ "<": [2, 7] } // Less than: true
{ ">=": [5, 5] } // Greater or equal: true
{ "<=": [3, 5] } // Less or equal: true
{ "==": ["hello", "hello"] } // Equal: true
{ "!=": [1, 2] } // Not equal: true{ "and": [true, false] } // Logical AND: false
{ "or": [true, false] } // Logical OR: true
{ "not": [true] } // Logical NOT: false{ "if": [condition, trueValue, falseValue] }
// Example
{ "if": [
{ ">": [{ "var": ["age"] }, 18] },
"Adult",
"Minor"
]}// Test if any element matches condition
{ "some": [
{ "var": ["items"] },
{ ">": [{ "var": ["$"] }, 10] }
]}
// Test if all elements match condition
{ "every": [
{ "var": ["scores"] },
{ ">=": [{ "var": ["$"] }, 60] }
]}
// Transform array elements
{ "map": [
{ "var": ["prices"] },
{ "*": [{ "var": ["$"] }, 1.1] }
]}{ "fieldState": ["otherField.isVisible"] } // Access other field's state
{ "fieldState": ["field.calculatedValue"] } // Access calculated values// Lookup table syntax sugar
{ "varTable": "userId@users.name" }
// Explicit lookup operation
{ "lookup": ["users", { "var": ["userId"] }, "name"] }// Reference shared rule
{ "$ref": "isAdult" }
// Register shared rules
engine.registerSharedRules({
"isAdult": { ">=": [{ "var": ["age"] }, 18] },
"hasEmail": { "!=": [{ "var": ["email"] }, ""] }
});// Initialize field state and/or value (processed before other rules)
{ "init": {
fieldState: { isVisible: true, currency: "USD" },
fieldValue: "default-value"
}}
// Conditional initialization based on context
{
condition: { "==": [{ "var": ["user.role"] }, "premium"] },
action: {
"init": {
fieldState: {
paymentMethods: ["card", "paypal", "crypto"],
allowSavedCards: true
},
fieldValue: { paymentMethod: "card" }
}
},
priority: 0 // Init rules typically use priority 0 or negative
}// Set any field property (value or state)
{ "set": { target: "fieldName.value", value: "Hello World" } }
{ "set": { target: "fieldName.isVisible", value: true } }
{ "set": { target: "fieldName.customProperty", value: "custom" } }
// Copy value from another field
{ "copy": { source: "sourceField.value", target: "targetField.value" } }// Calculate field values
{ "calculate": {
target: "total.value",
formula: { "+": [{ "var": ["price.value"] }, { "var": ["tax.value"] }] }
}}
// Calculate field state properties
{ "calculate": {
target: "field.calculatedValue",
formula: { "+": [{ "var": ["price.value"] }, { "var": ["tax.value"] }] }
}}// Trigger custom events
{ "trigger": { event: "validation_failed", params: { field: "email" } } }// Execute multiple actions
{ "batch": [
{ "set": { target: "field1.isVisible", value: true } },
{ "calculate": { target: "total.value", formula: { "+": [1, 2] } } },
{ "trigger": { event: "form_updated" } }
]}Flywheel provides powerful dependency tracking for custom operations and actions, ensuring proper cache invalidation and evaluation order.
// For custom logic operators
interface CustomLogicDependencyVisitor {
visitLogic(params: {
operator: string;
operands: any;
}): DependencyInfo;
}
// For custom actions
interface CustomActionDependencyVisitor {
visitAction(params: {
actionType: string;
payload: any;
}): DependencyInfo;
}
interface DependencyInfo {
dependencies: string[]; // Fields this operation reads from
dependents: string[]; // Fields this operation writes to
}// Create a dependency visitor for a merge action
const mergeActionVisitor: CustomActionDependencyVisitor = {
visitAction: ({ payload }): DependencyInfo => {
return {
dependencies: payload.sources || [], // Fields being read
dependents: payload.target ? [payload.target] : [] // Fields being written
};
}
};
// Register action with dependency tracking
engine.registerActionHandler({
actionType: 'merge',
handler: (payload, context, helpers) => {
const result = payload.sources
.map(field => context[field]?.value || '')
.join(' ');
helpers?.onFieldPropertySet?.(payload.target + '.value', result);
},
dependencyVisitor: mergeActionVisitor
});
// Now the engine properly tracks dependencies
const ruleSet = {
fullName: [{
condition: true,
action: { merge: { sources: ["firstName", "lastName"], target: "fullName" } } as any,
priority: 1
}]
};
engine.loadRuleSet(ruleSet);
engine.updateFieldValue({ firstName: "John", lastName: "Doe" });
// When firstName changes, fullName is automatically invalidated and re-evaluated
const invalidated = engine.updateFieldValue({ firstName: "Jane" });
console.log(invalidated); // includes 'fullName'// Create a dependency visitor for a compareFields operator
const compareFieldsVisitor: CustomLogicDependencyVisitor = {
visitLogic: ({ operands }): DependencyInfo => {
// This operator compares two field values
const [field1, field2] = operands;
return {
dependencies: [field1, field2], // Reads from both fields
dependents: [] // Logic operators don't write to fields
};
}
};
// Register logic with dependency tracking
engine.registerCustomLogic({
operator: 'compareFields',
handler: (args, context) => {
const [field1, field2] = args;
const value1 = context[field1]?.value;
const value2 = context[field2]?.value;
return value1 === value2;
},
dependencyVisitor: compareFieldsVisitor
});
// Use in rules
const validationRules = {
passwordMatch: [{
condition: { compareFields: ["password", "confirmPassword"] },
action: { set: { target: "submitButton.isVisible", value: true } },
priority: 1
}]
};
engine.loadRuleSet(validationRules);
// submitButton now properly depends on both password and confirmPassword fieldsAutomatic Cache Invalidation: When dependencies change, dependent fields are automatically invalidated and re-evaluated.
Correct Evaluation Order: Dependencies are resolved in the correct order, preventing stale data.
Performance Optimization: Only affected fields are re-evaluated when changes occur.
Debug Visibility: Use getDependenciesOf() to see the complete dependency graph including custom operations.
// Debug dependency relationships
console.log(engine.getDependenciesOf("fullName"));
// Output: ["firstName", "lastName"] - includes custom action dependencies// ❌ Without dependency visitor - cache invalidation broken
engine.registerActionHandler({
actionType: 'concat',
handler: (payload, context, helpers) => {
// This works, but dependency tracking is broken
const result = payload.sources.map(s => context[s]?.value || '').join('');
helpers?.onFieldPropertySet?.(payload.target + '.value', result);
}
// No dependencyVisitor provided!
});
// When firstName changes, fullName won't be invalidated
// Leading to stale data and incorrect results// ✅ With dependency visitor - proper cache invalidation
const concatVisitor: CustomActionDependencyVisitor = {
visitAction: ({ payload }) => ({
dependencies: payload.sources || [],
dependents: payload.target ? [payload.target] : []
})
};
engine.registerActionHandler({
actionType: 'concat',
handler: (payload, context, helpers) => {
const result = payload.sources.map(s => context[s]?.value || '').join('');
helpers?.onFieldPropertySet?.(payload.target + '.value', result);
},
dependencyVisitor: concatVisitor // ✅ Proper dependency tracking
});
// Now firstName changes correctly invalidate fullNameconst engine = new RuleEngine({
onFieldStateCreation: () => ({
theme: 'light',
readOnly: false,
customProps: {}
})
});
const initRules = {
"userSettings": [
// Premium users get advanced settings
{
condition: { "==": [{ "var": ["user.subscription"] }, "premium"] },
action: {
"init": {
fieldState: {
isVisible: true,
theme: 'dark',
features: ['advanced-analytics', 'custom-themes', 'api-access'],
maxExports: 'unlimited'
},
fieldValue: { theme: 'dark', notifications: true }
}
},
priority: 0
},
// Free users get basic settings
{
condition: { "==": [1, 1] }, // Fallback rule
action: {
"init": {
fieldState: {
isVisible: true,
theme: 'light',
features: ['basic-analytics'],
maxExports: 5
},
fieldValue: { theme: 'light', notifications: false }
}
},
priority: 1
}
]
};
engine.loadRuleSet(initRules);
engine.updateFieldValue({ user: { subscription: 'premium' } });
const settings = engine.evaluateField("userSettings");
console.log(settings);
// {
// isVisible: true,
// theme: 'dark',
// features: ['advanced-analytics', 'custom-themes', 'api-access'],
// maxExports: 'unlimited',
// readOnly: false,
// customProps: {}
// }const engine = new RuleEngine();
const formRules = {
"spouseInfo": [
{
condition: { "==": [{ "var": ["maritalStatus.value"] }, "married"] },
action: { "set": { target: "spouseInfo.isVisible", value: true } },
priority: 1
}
],
"dependentCount": [
{
condition: { ">": [{ "var": ["children.value"] }, 0] },
action: { "set": { target: "dependentCount.isVisible", value: true } },
priority: 1
}
]
};
engine.loadRuleSet(formRules);
// User selects "married" - spouse info becomes visible
engine.updateFieldValue({ maritalStatus: "married" });
console.log(engine.evaluateField("spouseInfo").isVisible); // true
// User enters children count - dependent section appears
engine.updateFieldValue({ children: 2 });
console.log(engine.evaluateField("dependentCount").isVisible); // trueconst calculationRules = {
"totalPrice": [
{
condition: true, // Always execute
action: {
"calculate": {
target: "totalPrice.calculatedValue",
formula: {
"+": [
{ "*": [{ "var": ["quantity.value"] }, { "var": ["unitPrice.value"] }] },
{ "if": [
{ ">=": [{ "var": ["quantity.value"] }, 10] },
0, // No tax for bulk orders
{ "*": [
{ "*": [{ "var": ["quantity.value"] }, { "var": ["unitPrice.value"] }] },
0.08
]}
]}
]
}
}
},
priority: 1
}
],
"submitButton": [
{
condition: { ">": [{ "fieldState": ["totalPrice.calculatedValue"] }, 0] },
action: { "set": { target: "submitButton.isVisible", value: true } },
priority: 1
}
]
};
engine.loadRuleSet(calculationRules);
engine.updateFieldValue({ quantity: 5, unitPrice: 20.00 });
const totalField = engine.evaluateField("totalPrice");
console.log(totalField.calculatedValue); // 108.00 (100 + 8% tax)
const submitButton = engine.evaluateField("submitButton");
console.log(submitButton.isVisible); // true// Register product catalog
engine.registerLookupTables([
{
name: "products",
primaryKey: "id",
table: [
{ id: "P001", name: "Laptop", category: "electronics", price: 999.99 },
{ id: "P002", name: "Book", category: "media", price: 15.99 },
{ id: "P003", name: "Shirt", category: "clothing", price: 29.99 }
]
}
]);
const productRules = {
"productName": [
{
condition: { "!=": [{ "var": ["selectedProductId.value"] }, ""] },
action: {
"calculate": {
target: "productName.calculatedValue",
formula: { "varTable": "selectedProductId.value@products.name" }
}
},
priority: 1
}
],
"shippingSection": [
{
condition: { "==": [{ "varTable": "selectedProductId.value@products.category" }, "electronics"] },
action: { "set": { target: "shippingSection.isVisible", value: true } },
priority: 1
}
]
};
engine.loadRuleSet(productRules);
engine.updateFieldValue({ selectedProductId: "P001" });
console.log(engine.evaluateField("productName").calculatedValue); // "Laptop"
console.log(engine.evaluateField("shippingSection").isVisible); // true// Register reusable business logic
engine.registerSharedRules({
"isAdult": { ">=": [{ "var": ["age.value"] }, 18] },
"hasValidEmail": { "and": [
{ "!=": [{ "var": ["email.value"] }, ""] },
{ "like": [{ "var": ["email.value"] }, "*@*.*"] }
]},
"isEligibleForDiscount": { "and": [
{ "$ref": "isAdult" },
{ ">": [{ "var": ["membershipYears.value"] }, 2] }
]}
});
const membershipRules = {
"discountField": [
{
condition: { "$ref": "isEligibleForDiscount" },
action: { "set": { target: "discountField.isVisible", value: true } },
priority: 1
},
{
condition: { "$ref": "isEligibleForDiscount" },
action: {
"calculate": {
target: "discountField.calculatedValue",
formula: { "*": [{ "var": ["orderTotal.value"] }, 0.1] }
}
},
priority: 2
}
],
"emailRequired": [
{
condition: { "not": [{ "$ref": "hasValidEmail" }] },
action: { "set": { target: "email.isRequired", value: true } },
priority: 1
}
]
};
engine.loadRuleSet(membershipRules);
engine.updateFieldValue({
age: 25,
membershipYears: 3,
orderTotal: 100,
email: ""
});
console.log(engine.evaluateField("discountField").isVisible); // true
console.log(engine.evaluateField("discountField").calculatedValue); // 10
console.log(engine.evaluateField("email").isRequired); // trueconst engine = new RuleEngine({
onEvent: (eventType, params) => {
switch (eventType) {
case 'validation_error':
console.error('Validation failed:', params);
break;
case 'calculation_complete':
console.log('Calculation result:', params.result);
break;
case 'audit_log':
// Log to external system
break;
}
}
});
// Register custom action with dependency tracking
const validateVisitor: CustomActionDependencyVisitor = {
visitAction: ({ payload }) => ({
dependencies: payload.field ? [payload.field] : [], // Reads from the field being validated
dependents: [] // Validation doesn't write to fields directly
})
};
engine.registerActionHandler({
actionType: 'validate',
handler: (payload, context, helpers) => {
const { field, rules } = payload;
const fieldValue = engine.getFieldValue(field);
const isValid = validateField(fieldValue, rules);
if (!isValid) {
// Trigger event through the helper system
helpers?.onEvent?.('validation_error', { field });
}
},
dependencyVisitor: validateVisitor
});
const validationRules = {
"passwordConfirm": [
{
condition: { "!=": [{ "var": ["password.value"] }, { "var": ["confirmPassword.value"] }] },
action: { "trigger": {
event: "validation_error",
params: { field: "confirmPassword", message: "Passwords do not match" }
}},
priority: 1
}
],
"emailField": [
{
condition: { "!=": [{ "var": ["email.value"] }, ""] },
action: { "validate": {
field: "email",
rules: ["required", "email_format"]
}},
priority: 1
}
],
"submitButton": [
{
condition: { "==": [{ "var": ["password.value"] }, { "var": ["confirmPassword.value"] }] },
action: { "set": { target: "submitButton.isVisible", value: true } },
priority: 1
}
]
};
engine.loadRuleSet(validationRules);
// Trigger validation when email is entered
engine.updateFieldValue({
email: "invalid-email",
password: "secret123",
confirmPassword: "secret123"
});
// This will trigger the custom 'validate' action for emailFieldconst engine = new RuleEngine({
onFieldStateCreation: (props) => ({
...props,
// Add custom properties
permissions: { read: true, write: true },
validation: { errors: [], warnings: [] },
metadata: { lastModified: null }
})
});
// Rules can now target custom properties
const customRules = {
"adminField": [
{
condition: { "==": [{ "var": ["userRole.value"] }, "admin"] },
action: { "set": {
target: "adminField.permissions.write",
value: true
}},
priority: 1
}
]
};// Register custom operators with dependency tracking
const containsVisitor: CustomLogicDependencyVisitor = {
visitLogic: ({ operands }) => ({
// If first operand is a field reference, depend on it
dependencies: typeof operands[0] === 'string' && !operands[0].includes('.') ? [operands[0]] : [],
dependents: []
})
};
const currencyVisitor: CustomLogicDependencyVisitor = {
visitLogic: ({ operands }) => ({
dependencies: typeof operands[0] === 'string' ? [operands[0]] : [],
dependents: []
})
};
engine.registerCustomLogic({
operator: 'contains',
handler: (args, context) => {
const [haystack, needle] = args;
return String(haystack).includes(String(needle));
},
dependencyVisitor: containsVisitor
});
engine.registerCustomLogic({
operator: 'currency',
handler: (args, context) => {
const [amount] = args;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
},
dependencyVisitor: currencyVisitor
});
// Use in rules
const customLogicRules = {
"warningMessage": [
{
condition: { "contains": [{ "var": ["description.value"] }, "urgent"] },
action: { "set": { target: "warningMessage.isVisible", value: true } },
priority: 1
}
]
};Flywheel automatically optimizes performance through:
// 1. Dependency-based caching
engine.updateFieldValue({ age: 25 }); // Only age-dependent fields re-evaluate
// 2. Intelligent invalidation
const invalidatedFields = engine.updateFieldValue({ name: "John" });
console.log(invalidatedFields); // ['displayName', 'greeting', ...]
// 3. Debug utilities for performance analysis
console.log(engine.getDependenciesOf("calculatedTotal"));
// ['price', 'quantity', 'taxRate', 'discountPercent']// Comprehensive debugging utilities
const dependencies = engine.getDependenciesOf("totalPrice");
console.log("totalPrice depends on:", dependencies);
// Validation utilities
try {
engine.loadRuleSet(ruleSet);
} catch (error) {
console.error("Rule validation failed:", error.message);
}
// Test rule evaluation
engine.updateFieldValue({ age: 30, email: "test@example.com" });
const result = engine.evaluateField("userProfile");
expect(result.isVisible).toBe(true);Problem: Custom actions don't trigger re-evaluation of dependent fields.
Solution: Ensure you provide a dependencyVisitor when registering the action.
Problem: Fields show stale data after custom logic operations. Solution: Make sure your custom logic dependency visitor correctly identifies field dependencies.
Problem: Circular dependencies detected with custom operations. Solution: Review your dependency visitors to ensure they don't create circular references.
// ❌ Incorrect - missing dependencies
const badVisitor: CustomActionDependencyVisitor = {
visitAction: () => ({ dependencies: [], dependents: [] })
};
// ✅ Correct - properly declares dependencies
const goodVisitor: CustomActionDependencyVisitor = {
visitAction: ({ payload }) => ({
dependencies: payload.sources || [],
dependents: payload.target ? [payload.target] : []
})
};Flywheel provides a comprehensive solution for complex dynamic form logic, business rule management, and field state orchestration. Its powerful yet intuitive API makes it easy to build sophisticated, reactive user interfaces with minimal code.
For more examples and advanced usage patterns, see the test files in the repository.