This guide will help you migrate from the original RuleEngine to the new V2 implementation that uses the Strategy pattern for operators.
Updated for v2.1: This guide now includes information about TypeScript method overloads, improved type inference, and singleton pattern changes.
- Overview of Changes
- Breaking Changes
- New Features
- TypeScript Improvements (v2.1)
- Migration Steps
- API Compatibility
- Custom Operators
- Performance Improvements
RuleEngine V2 introduces significant architectural improvements:
- Strategy Pattern for Operators: All operators now use the Strategy pattern, making them more modular and extensible
- Full Type Safety: Enhanced TypeScript support with better type inference
- Operator Registry: Centralized management of operators with metadata
- Enhanced Introspection: More detailed rule analysis with operator metadata
- Custom Operators: Easy creation and registration of custom operators
- Performance Optimizations: Optional caching and lazy evaluation
If you're importing specific operators:
// Old
import { greaterThanOperator, equalsOperator } from "@usex/rule-engine";
// New - operators are now classes
import { GreaterThanOperator, EqualsOperator } from "@usex/rule-engine";The way to add custom operators has changed:
// Old - directly modify operator map
// New - use operator registry
import { registerCustomOperator, BaseOperatorStrategy } from "@usex/rule-engine";
operatorsProcessorMap["custom-operator"] = (a, b) => customLogic(a, b);
class CustomOperator extends BaseOperatorStrategy {
// Implementation
}
registerCustomOperator(CustomOperator);Enhanced introspection now includes optional metadata:
// Old
const result = RuleEngine.introspect(rule);
// Returns: { results: [...], default?: {...} }
// New
const result = RuleEngine.introspect(rule, {
includeMetadata: true,
includeComplexity: true
});
// Returns: Enhanced result with operator metadata and complexity metricsimport { RuleEngine } from "@usex/rule-engine";
// Configure the engine
const engine = RuleEngine.getInstance({
trustMode: true, // Skip validation for trusted rules
enableCaching: true, // Cache evaluation results
maxCacheSize: 1000, // Maximum cache entries
enableOptimizations: true, // Enable performance optimizations
});// Get metadata for all operators
const registry = engine.getOperatorRegistry();
const allOperators = registry.getAllMetadata();
// Validate operators in a rule
const validation = engine.validateOperators(rule);
if (!validation.isValid) {
console.error("Invalid operators:", validation.errors);
}
// Get operators used in a rule
const usedOperators = engine.getUsedOperators(rule);import { registerCustomOperator, OperatorCategory, BaseOperatorStrategy } from "@usex/rule-engine";
class EmailDomainOperator extends BaseOperatorStrategy<string, string> {
readonly metadata = {
name: "email-domain",
displayName: "Email Domain",
category: OperatorCategory.STRING,
description: "Checks if email has specific domain",
acceptedFieldTypes: ["string"],
expectedValueType: "string",
requiresValue: true,
example: '{ field: "email", operator: "email-domain", value: "gmail.com" }'
};
evaluate(context) {
const { fieldValue, constraintValue } = context;
if (!fieldValue || !constraintValue) return false;
const domain = fieldValue.split('@')[1];
return domain === constraintValue;
}
isValidFieldType(value) {
return typeof value === "string" && value.includes("@");
}
}
// Register the operator
registerCustomOperator(EmailDomainOperator);All methods that accept Criteria now have overloads for automatic type inference:
// Old - Union return type
const result = await engine.evaluate(rule, criteria);
// result: EvaluationResult<T> | Array<EvaluationResult<T>>
// TypeScript doesn't know which type without checking
// New - Overloaded methods with specific return types
const singleResult = await engine.evaluate(rule, { age: 25 });
// singleResult: EvaluationResult<T> - TypeScript knows it's a single result
const arrayResults = await engine.evaluate(rule, [{ age: 25 }, { age: 30 }]);
// arrayResults: Array<EvaluationResult<T>> - TypeScript knows it's an arrayThe RuleEngine constructor is now private:
// Old - Direct instantiation
const engine = new RuleEngine();
// New - Singleton pattern
const engine = RuleEngine.getInstance();The clearMutations() method now properly removes all mutations:
// Old - Only cleared cache
engine.clearMutations(); // Only cleared mutation cache
// New - Removes all mutations
engine.clearMutations(); // Removes all registered mutations
engine.clearMutationCache(); // Clears only the cacheConstraint-specific error messages now have proper priority:
// Old behavior - Default rule message had priority
// When a constraint failed, the rule's default message was returned
// New behavior - Constraint message has priority
// When a constraint fails, its specific message is returned
const rule = {
conditions: {
and: [
{
field: "length",
operator: "length-between",
value: [4, 20],
message: "Length must be between 4 and 20 characters"
}
]
},
default: { message: "Rule failed" }
};
// Now returns: "Length must be between 4 and 20 characters"
// Instead of: "Rule failed"// Update your imports to use V2
import { RuleEngine } from "@usex/rule-engine";
// Or use both during migration
import { RuleEngine, } from "@usex/rule-engine";The API is mostly compatible, but you can now use the enhanced version:
// Old
const result = await RuleEngine.evaluate(rule, data);
// New - same API, but using V2
const result = await RuleEngine.evaluate(rule, data);
// With configuration
const engine = RuleEngine.getInstance({ enableCaching: true });
const result = await engine.evaluate(rule, data);If you have custom operators, migrate them to the new pattern:
// Old custom operator
const customOperator = (fieldValue, constraintValue) => {
return fieldValue > constraintValue * 2;
};
// New custom operator
class CustomMultiplierOperator extends BaseOperatorStrategy<number, number> {
readonly metadata = {
name: "greater-than-double",
displayName: "Greater Than Double",
category: OperatorCategory.NUMERIC,
description: "Checks if field is greater than double the value",
acceptedFieldTypes: ["number"],
expectedValueType: "number",
requiresValue: true,
};
evaluate(context) {
const { fieldValue, constraintValue } = context;
return fieldValue > constraintValue * 2;
}
isValidFieldType(value) {
return typeof value === "number";
}
}
registerCustomOperator(CustomMultiplierOperator);// Old
const introspection = RuleEngine.introspect(rule);
// New with enhanced features
const introspection = RuleEngine.introspect(rule, {
includeMetadata: true, // Include operator metadata
includeComplexity: true, // Include complexity metrics
validateOperators: true // Validate all operators exist
});
// Access new metadata
console.log("Used operators:", introspection.operatorMetadata?.usedOperators);
console.log("Complexity:", introspection.complexity);These methods work exactly the same in V2:
evaluate(rule, criteria, trustRule?)checkIsPassed(rule, criteria, trustRule?)getEvaluateResult(rule, criteria, trustRule?)validate(rule)builder()
These methods have additional optional features in V2:
introspect(rule, options?)- Now accepts options for metadata and complexityconstructor/getInstance(config?)- Now accepts configuration options
validateOperators(rule)- Validate all operators in a rulegetUsedOperators(rule)- Get set of operators usedgetOperatorRegistry()- Access the operator registryconfigure(config)- Update configurationclearCache()- Clear evaluation cache
All operators must implement the OperatorStrategy interface:
interface OperatorStrategy<TField = any, TValue = any> {
readonly metadata: OperatorMetadata;
validate(context: OperatorContext): ValidationResult;
evaluate(context: OperatorContext): boolean;
isValidFieldType?(value: unknown): value is TField;
isValidConstraintType?(value: unknown): value is TValue;
formatMessage?(template: string, context: OperatorContext): string;
}Operators receive a context object with:
interface OperatorContext {
fieldValue: any; // The resolved field value
constraintValue?: any; // The constraint value
criteria?: Record<string, any>; // Full criteria object
fieldPath?: string; // The field path
}- Extend BaseOperatorStrategy: Provides default implementations
- Implement Type Guards: Use
isValidFieldTypeandisValidConstraintType - Provide Clear Metadata: Help users understand your operator
- Handle Edge Cases: Null, undefined, type mismatches
- Add Validation: Implement custom validation logic
const engine = RuleEngine.getInstance({
enableCaching: true,
maxCacheSize: 1000
});
// Clear cache when needed
engine.clearCache();Skip validation for known-good rules:
// Global trust mode
const engine = RuleEngine.getInstance({ trustMode: true });
// Per-evaluation trust
await engine.evaluate(rule, data, true); // Skip validationThe registry enables:
- Fast operator lookup (O(1) instead of O(n))
- Lazy loading of operators
- Better memory management
-
"Unknown operator" errors
- Ensure custom operators are registered before use
- Check operator names match exactly (case-sensitive)
-
Type errors with custom operators
- Implement proper type guards
- Use generics for type safety
-
Performance degradation
- Enable caching for repeated evaluations
- Use trust mode for validated rules
- Consider batch evaluation for multiple items
Enable debug logging:
import { Logger } from "@usex/rule-engine";
Logger.setLevel("debug");// Before
import { RuleEngine } from "@usex/rule-engine";
// After
import { RuleEngine, initializeOperators } from "@usex/rule-engine";
const rule = {
conditions: {
and: [
{ field: "age", operator: "greater-than", value: 18 },
{ field: "status", operator: "equals", value: "active" }
]
}
};
const result = await RuleEngine.evaluate(rule, { age: 25, status: "active" });
// Initialize once at app startup
initializeOperators();
// Configure engine
const engine = RuleEngine.getInstance({
enableCaching: true,
trustMode: false
});
// Same rule works without changes
const result = await engine.evaluate(rule, { age: 25, status: "active" });
// Use new features
const introspection = await engine.introspect(rule, {
includeMetadata: true,
includeComplexity: true
});
console.log("Rule complexity:", introspection.complexity);
console.log("Operators used:", introspection.operatorMetadata?.usedOperators);RuleEngine V2 maintains backward compatibility while adding powerful new features. The migration can be done incrementally:
- Start by updating imports to use RuleEngine
- Gradually migrate custom operators to the new pattern
- Take advantage of new features like caching and enhanced introspection
- Consider using TypeScript for better type safety
The original RuleEngine remains available for backward compatibility, allowing you to migrate at your own pace.