@kaskad/eval-tree
v0.0.10
Published
A reactive formula evaluation engine that transforms AST nodes into MobX-tracked computations with first-class async support.
Readme
@kaskad/eval-tree
A reactive formula evaluation engine that transforms AST nodes into MobX-tracked computations with first-class async support.
Features
- Reactive Evaluation: MobX-based pull reactive system with automatic dependency tracking
- First-Class Async: Native support for Promises and RxJS Observables
- Lazy Evaluation: Control flow functions only evaluate needed branches
- Resource Management: Explicit disposal pattern for subscription cleanup
- Dual Function Model: Simple imperative functions and advanced reactive functions
- State Tracking: Explicit
evaluating,settled, andfailedstates for UI feedback
Architecture Overview
eval-tree transforms formula AST nodes into reactive MobX computations:
EvalNode (AST) → evaluateNode() → EvaluationResult → EvalStateData Flow
- Input:
EvalNode- Immutable AST from formula parser - Processing:
evaluateNode()- Recursive evaluation with MobX computeds - Output:
EvaluationResult- Contains MobX computed + disposal functions - State:
EvalState- Tracks value, loading, and error states
Core Types
// Input: AST nodes
type EvalNode =
| ValueEvalNode // { type: 'value', value: unknown }
| ArrayEvalNode // { type: 'array', items: EvalNode[] }
| ObjectEvalNode // { type: 'object', properties: [...] }
| FunctionEvalNode // { type: 'function', name: string, args: EvalNode[] }
// Output: Evaluation state
interface EvalState<T = unknown> {
value: T; // The computed result
evaluating: boolean; // Async operation in progress
settled: boolean; // Evaluation completed (success or failure)
failed: boolean; // Error occurred during evaluation
}
// Result: Reactive container + cleanup
interface EvaluationResult<S extends EvalState = EvalState> {
readonly computed: IComputedValue<S>; // MobX reactive value
readonly disposers: ReadonlyArray<IReactionDisposer>; // Cleanup functions
}Quick Start
Basic Evaluation
import { evaluateNode, EvalState } from '@kaskad/eval-tree';
// Simple value node
const valueNode = { type: 'value', value: 42 };
const result = evaluateNode(valueNode, {
componentId: 'root',
path: []
});
// Read the evaluated state
const state: EvalState = result.computed.get();
console.log(state.value); // 42
console.log(state.settled); // true
console.log(state.evaluating); // false
// Clean up resources when done
result.disposers.forEach(dispose => dispose());Function Registration
import { FunctionRegistry, FunctionDefinition } from '@kaskad/eval-tree';
// Register an imperative function
const plus: FunctionDefinition = {
kind: 'imperative',
execute: (ctx, num1: number, num2: number) => num1 + num2,
args: [
{ valueType: { type: 'number' } },
{ valueType: { type: 'number' } }
],
returns: { type: 'number' }
};
FunctionRegistry.getInstance().setFunction('plus', plus);
// Evaluate a function call
const functionNode = {
type: 'function',
name: 'plus',
args: [
{ type: 'value', value: 10 },
{ type: 'value', value: 32 }
]
};
const position = { componentId: 'root', path: [] };
const result = evaluateNode(functionNode, position);
console.log(result.computed.get().value); // 42Async Functions
// Async function returns Promise
const fetchData: FunctionDefinition = {
kind: 'imperative',
execute: async (ctx, url: string) => {
const response = await fetch(url);
return response.json();
},
args: [{ valueType: { type: 'string' } }],
returns: { type: 'unknown' }
};
FunctionRegistry.getInstance().setFunction('fetch', fetchData);
// Evaluate async function
const asyncNode = {
type: 'function',
name: 'fetch',
args: [{ type: 'value', value: '/api/data' }]
};
const position = { componentId: 'root', path: [] };
const result = evaluateNode(asyncNode, position);
// Initially evaluating
console.log(result.computed.get().evaluating); // true
console.log(result.computed.get().value); // null
// After Promise resolves (MobX will auto-update)
// evaluating: false, value: { ... response data ... }Core Concepts
EvalNode Types
Value Node - Wraps a literal value:
{ type: 'value', value: 42 }
{ type: 'value', value: 'hello' }
{ type: 'value', value: null }Array Node - Evaluates to an array:
{
type: 'array',
items: [
{ type: 'value', value: 1 },
{ type: 'value', value: 2 },
{ type: 'function', name: 'multiply', args: [...] }
]
}Object Node - Evaluates to an object:
{
type: 'object',
properties: [
{
key: { type: 'value', value: 'name' },
value: { type: 'value', value: 'Alice' }
},
{
key: { type: 'function', name: 'getDynamicKey', args: [] },
value: { type: 'value', value: 'dynamic value' }
}
]
}Function Node - Calls a registered function:
{
type: 'function',
name: 'plus',
args: [
{ type: 'value', value: 10 },
{ type: 'value', value: 32 }
]
}EvalState Fields
The evaluation state tracks three aspects:
Value - The computed result:
state.value // The actual computed value (any type)Loading - Async operation status:
state.evaluating // true if Promise pending or Observable emitting
state.settled // true when evaluation completed (success or failure)Error - Failure tracking:
state.failed // true if evaluation failed or async operation erroredState Transitions:
Initial: { value: null, evaluating: false, settled: false, failed: false }
Sync eval: { value: 42, evaluating: false, settled: true, failed: false }
Async start:{ value: null, evaluating: true, settled: false, failed: false }
Async done: { value: data, evaluating: false, settled: true, failed: false }
Error: { value: null, evaluating: false, settled: true, failed: true }Reactive Evaluation
eval-tree uses MobX's pull-based reactive system:
- All results wrapped in
computed()- Automatic memoization - Lazy evaluation - Only computed when
.get()is called - Automatic updates - MobX tracks dependencies and recomputes when they change
- Glitch-free - Updates happen in transactions, no intermediate states
const position = { componentId: 'root', path: [] };
const result = evaluateNode(node, position);
// Subscribe to changes
autorun(() => {
const state = result.computed.get();
console.log('Value changed:', state.value);
});
// When dependencies change, autorun re-executes automaticallyFunction System
eval-tree supports two function models: imperative (simple) and reactive (advanced).
Imperative Functions
Imperative functions are the simplest way to add custom functionality. All arguments are evaluated before execution.
const multiply: FunctionDefinition = {
kind: 'imperative',
execute: (ctx, a: number, b: number) => a * b,
args: [
{ valueType: { type: 'number' } },
{ valueType: { type: 'number' } }
],
returns: { type: 'number' }
};
FunctionRegistry.getInstance().setFunction('multiply', multiply);Features:
- ✅ Simple API - just write a regular function
- ✅ Arguments auto-unwrapped from MobX computeds
- ✅ Can return Promise or Observable for async
- ❌ Cannot implement lazy evaluation (all args evaluated)
- ❌ Cannot implement short-circuit logic
When to use:
- Math operations:
plus,multiply,divide - String operations:
concat,substring,toUpperCase - Data transformations:
map,filter,reduce - Async operations:
fetch,delay,timeout
Reactive Functions
Reactive functions receive unevaluated MobX computeds, enabling lazy evaluation and short-circuit logic.
import { computed, IComputedValue } from 'mobx';
import {
ReactiveFunctionDefinition,
FunctionExecutionStateBuilder,
EvalState
} from '@kaskad/eval-tree';
const ifFn: ReactiveFunctionDefinition = {
kind: 'reactive',
execute: (ctx, args: IComputedValue<EvalState>[]) => {
return computed(() => {
const state = new FunctionExecutionStateBuilder();
// Evaluate condition
const conditionState = args[0].get();
const earlyReturn = state.checkReady(conditionState);
if (earlyReturn) return earlyReturn;
// Lazy: only evaluate the selected branch
const branchState = conditionState.value
? args[1].get() // true branch
: args[2].get(); // false branch
const branchEarlyReturn = state.checkReady(branchState);
if (branchEarlyReturn) return branchEarlyReturn;
return state.success(branchState.value);
});
},
args: { type: 'fixed', count: 3, valueType: { type: 'unknown' } },
returns: { type: 'unknown' }
};Features:
- ✅ Full control over argument evaluation
- ✅ Can implement lazy evaluation (only evaluate needed args)
- ✅ Can implement short-circuit logic (
and,or) - ✅ Direct access to argument states (evaluating, failed)
- ❌ More complex API (must handle states manually)
When to use:
- Control flow:
if,switch,case - Short-circuit logic:
and,or,coalesce - Conditional evaluation:
try,catch,default - Advanced state handling:
retry,debounce,throttle
FunctionExecutionStateBuilder
Helper class for building execution states in reactive functions:
const state = new FunctionExecutionStateBuilder();
// Track argument states
state.trackArg(argState); // Accumulates evaluating/failed flags
// Check if argument is ready
const earlyReturn = state.checkReady(argState);
if (earlyReturn) return earlyReturn; // Returns notReady() if not settled
// Return success
return state.success(computedValue);
// Or return not ready
return state.notReady(); // Returns partial state with flagsFunctionRegistry API
const registry = FunctionRegistry.getInstance();
// Register single function
registry.setFunction('myFn', functionDefinition);
// Register multiple functions
registry.setFunctions({
plus: plusDefinition,
minus: minusDefinition,
multiply: multiplyDefinition
});
// Get function (throws if not found)
const fn = registry.getFunction('plus');
// Check if function exists
if (registry.hasFunction('myFn')) {
// ...
}
// Get all function names
const names = registry.getFunctionNames(); // ['plus', 'minus', 'multiply']
// Reset registry (useful for testing)
FunctionRegistry.reset();Async Handling
eval-tree has first-class support for async values (Promises and RxJS Observables).
Promise Support
When a function returns a Promise, it's automatically wrapped in a PromiseValue object tracked by MobX:
const asyncFn: FunctionDefinition = {
kind: 'imperative',
execute: async (ctx, url: string) => {
const response = await fetch(url);
return response.json();
},
args: [{ valueType: { type: 'string' } }],
returns: { type: 'unknown' }
};
// State transitions:
// 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
// 2. Resolved: { value: data, evaluating: false, settled: true, failed: false }
// 3. Rejected: { value: null, evaluating: false, settled: true, failed: true }PromiseValue Structure:
interface PromiseValue {
kind: 'promise';
current: unknown; // Resolved value (null if pending/rejected)
error: unknown; // Rejection error (null if pending/resolved)
pending: boolean; // true until Promise settles
}Observable Support
RxJS Observables are wrapped in ObservableValue with automatic subscription management:
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
const streamFn: FunctionDefinition = {
kind: 'imperative',
execute: (ctx) => {
return interval(1000).pipe(
map(n => n * 2)
);
},
args: [],
returns: { type: 'number' }
};
// State transitions:
// 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
// 2. First emit: { value: 0, evaluating: false, settled: true, failed: false }
// 3. Next emit: { value: 2, evaluating: false, settled: true, failed: false }
// 4. Error: { value: 2, evaluating: false, settled: true, failed: true }
// (note: current value preserved on error)ObservableValue Structure:
interface ObservableValue {
kind: 'observable';
current: unknown; // Latest emitted value
error: unknown; // Error if stream errored
pending: boolean; // false after first emission or completion
subscription: Subscription; // RxJS subscription (for cleanup)
}Subscription Cleanup
eval-tree automatically manages RxJS subscriptions:
- Creation: Subscription created when Observable is wrapped
- Re-evaluation: Old subscription unsubscribed when function re-evaluates
- Disposal: Subscription unsubscribed when disposer called
const position = { componentId: 'root', path: [] };
const result = evaluateNode(observableNode, position);
// Subscription active, receiving values
// When done, clean up
result.disposers.forEach(dispose => dispose());
// Subscription automatically unsubscribedType Guards
Check async value types:
import {
isAsyncValue,
isPromiseValue,
isObservableValue
} from '@kaskad/eval-tree';
if (isPromiseValue(value)) {
console.log('Promise:', value.current, value.pending);
}
if (isObservableValue(value)) {
console.log('Observable:', value.current);
value.subscription.unsubscribe(); // Manual cleanup if needed
}API Reference
evaluateNode()
Main entry point for evaluating AST nodes.
function evaluateNode<S extends EvalState = EvalState>(
evalNode: EvalNode,
position: NodePosition
): EvaluationResult<S>Parameters:
evalNode- AST node to evaluate (value, array, object, or function)position- Node position context (component ID and path)
Returns:
EvaluationResult- Contains MobX computed and disposal functions
Example:
const result = evaluateNode(
{ type: 'value', value: 42 },
{ componentId: 'root', path: ['field'] }
);
const state = result.computed.get();
console.log(state.value); // 42
result.disposers.forEach(d => d()); // CleanupFunctionRegistry
Singleton for managing formula functions.
class FunctionRegistry {
static getInstance(): FunctionRegistry
static reset(): FunctionRegistry
setFunction(name: string, definition: FunctionDefinition | ReactiveFunctionDefinition): void
setFunctions(definitions: Record<string, FunctionDefinition | ReactiveFunctionDefinition>): void
getFunction(name: string): ReactiveFunctionDefinition
hasFunction(name: string): boolean
getFunctionNames(): string[]
}ensureReactive()
Converts imperative functions to reactive (or returns reactive unchanged).
function ensureReactive(
definition: FunctionDefinition | ReactiveFunctionDefinition
): ReactiveFunctionDefinitionAutomatically called by FunctionRegistry.setFunction().
wrapImperativeAsReactive()
Wraps an imperative function for the reactive pipeline.
function wrapImperativeAsReactive(
imperativeDef: FunctionDefinition
): ReactiveFunctionDefinitionHandles argument evaluation, error catching, and async value wrapping.
FunctionExecutionStateBuilder
Helper for building execution states in reactive functions.
class FunctionExecutionStateBuilder {
trackArg(argState: EvalState): void
checkReady(argState: EvalState): FunctionExecutionState | null
notReady(): FunctionExecutionState
success(value: unknown): FunctionExecutionState
}Usage:
const state = new FunctionExecutionStateBuilder();
for (const argComputed of args) {
const argState = argComputed.get();
const earlyReturn = state.checkReady(argState);
if (earlyReturn) return earlyReturn;
// Use argState.value here
}
return state.success(result);Design Decisions
Pull-based Reactivity (MobX) vs Push-based (RxJS)
Choice: MobX computed() for pull-based reactivity
Rationale:
- Lazy evaluation: Computations only run when values are read
- Natural backpressure: Don't read → don't compute
- Glitch-free updates: MobX transactions prevent intermediate states
- Better debugging: Call stack intact (not async callbacks)
- Lower learning curve: Simpler mental model than reactive streams
Trade-off: Push-based would enable reactive composition patterns, but pull-based is better suited for formula evaluation where values are queried on-demand.
Manual Disposal vs Automatic GC
Choice: Explicit disposal pattern with returned disposers array
Rationale:
- Deterministic cleanup: Know exactly when subscriptions unsubscribe
- Resource control: Critical for RxJS subscriptions (can't rely on GC)
- No GC pressure: Cleanup happens immediately, not at GC time
- Explicit lifecycle: Caller controls when to dispose (flexible)
Trade-off: Requires caller to remember to call disposers (risk of leaks), but provides control needed for production systems. Future: Could use TC39 using declarations for automatic disposal.
Dual Function Model (Imperative + Reactive)
Choice: Two function types with automatic adapter
Rationale:
- Progressive complexity: Simple functions use imperative, advanced use reactive
- Optimal for common case: 80% of functions don't need lazy evaluation
- Single internal model: All functions become reactive internally
- Type safety: Discriminated union prevents mixing
Trade-off: Two APIs to learn, but the imperative API is much simpler and auto-upgraded.
Explicit State vs Monadic Error Handling
Choice: Explicit { value, evaluating, failed } state object
Rationale:
- UI-friendly: Can show loading spinners (
evaluatingflag) - Partial state: Can inspect value even if evaluation failed
- Multi-state tracking: Need to distinguish pending/loading/error/success
- No type ceremony: Simpler than Result/Either monads for UI code
Trade-off: No compile-time guarantee you check failed before using value, but more practical for UI-driven evaluation.
Performance Optimization Guide
MobX Memoization
Automatic Caching:
All evaluation results are wrapped in computed(), providing automatic memoization:
const position = { componentId: 'root', path: [] };
const result = evaluateNode(node, position);
// First call: evaluates and caches
const state1 = result.computed.get();
// Second call: returns cached value (if deps unchanged)
const state2 = result.computed.get();Best Practice: Share IComputedValue instances across multiple readers to benefit from memoization.
Avoiding Unnecessary Dependency Tracking
Problem: Reading all children creates dependencies even if not needed:
// BAD: Reads all children even if first is evaluating
const evaluating = items.some(item => item.get().evaluating);Solution: Early exit to avoid tracking unused dependencies:
// GOOD: Only reads children until finding evaluating
let evaluating = false;
for (const item of items) {
const state = item.get();
if (state.evaluating) {
evaluating = true;
break; // Don't track remaining children
}
}Impact: Reduces recomputation frequency by avoiding dependencies on irrelevant children.
Function Execution Overhead
Cost: Each function call has two levels of computed() wrapping:
- Function execution computed (from reactive function)
- Subscription tracking computed (for cleanup)
Optimization: For non-Observable functions, the double wrapping is overhead. Imperative functions are more efficient if you don't need lazy evaluation.
Best Practice:
- Use imperative for: math, string ops, data transformations
- Use reactive only when you need: lazy eval, short-circuit, custom state handling
State Aggregation Patterns
Efficient aggregation:
// Aggregate states with early exit
let evaluating = false;
let failed = false;
for (const computed of children) {
const state = computed.get();
if (state.evaluating) evaluating = true;
if (state.failed) failed = true;
// Could early-exit here if both flags set
if (evaluating && failed) break;
}Debug names: Set meaningful names for computeds to help MobX devtools:
computed(() => { /* ... */ }, {
name: 'myComponent.field.formula'
})Memory Management
Dispose when done:
const { computed, disposers } = evaluateNode(node, position);
// Use the computed
// CRITICAL: Always dispose to prevent memory leaks
disposers.forEach(dispose => dispose());Best Practice: Store disposers in a container and batch-dispose:
class EvaluationContext {
private disposers: IReactionDisposer[] = [];
evaluate(node: EvalNode, position: NodePosition): IComputedValue<EvalState> {
const { computed, disposers } = evaluateNode(node, position);
this.disposers.push(...disposers);
return computed;
}
dispose(): void {
this.disposers.forEach(d => d());
this.disposers = [];
}
}Resource Management
Disposal Pattern
Every evaluation returns disposers that MUST be called to prevent resource leaks:
const { computed, disposers } = evaluateNode(node, position);
// When done with the evaluation:
disposers.forEach(dispose => dispose());What disposers clean up:
- RxJS subscriptions - From Observable-returning functions
- Child disposers - Propagated from nested evaluations
- MobX reactions - If reactive functions create reactions (rare)
Disposal Tree
Disposers follow the evaluation tree structure:
evaluateObject()
├─ evaluateNode(key1) → [disposer1, disposer2]
├─ evaluateNode(value1) → [disposer3]
├─ evaluateNode(key2) → []
└─ evaluateNode(value2) → [disposer4, disposer5]
Result disposers: [disposer1, disposer2, disposer3, disposer4, disposer5]Pattern: Parent collects all child disposers and returns them as a flat array.
Subscription Lifecycle
For Observable-returning functions:
- Subscribe: When
toObservable()wraps Observable - Track: Subscription stored in
ObservableValue.subscription - Cleanup on re-eval: Old subscription unsubscribed when function re-executes
- Cleanup on dispose: Subscription unsubscribed when disposer called
const position = { componentId: 'root', path: [] };
const result = evaluateNode(observableNode, position);
// Subscription active
const state = result.computed.get();
// Function re-evaluates (dependency changed)
// → Old subscription auto-unsubscribed
// → New subscription created
// When completely done:
result.disposers.forEach(d => d());
// → Current subscription unsubscribedCommon Patterns
Single evaluation:
const { computed, disposers } = evaluateNode(node, position);
try {
const state = computed.get();
// Use state
} finally {
disposers.forEach(d => d());
}Long-lived evaluation:
const { computed, disposers } = evaluateNode(node, position);
// Subscribe to changes
const reactionDisposer = autorun(() => {
const state = computed.get();
console.log('State changed:', state);
});
// Later, clean up everything
reactionDisposer();
disposers.forEach(d => d());Batched disposal:
const allDisposers: IReactionDisposer[] = [];
const result1 = evaluateNode(node1, position);
allDisposers.push(...result1.disposers);
const result2 = evaluateNode(node2, position);
allDisposers.push(...result2.disposers);
// Dispose all at once
allDisposers.forEach(d => d());Testing
Run tests:
npx nx test eval-treeRun tests with coverage:
npx nx test eval-tree --coverageBuild library:
npx nx build eval-treeLicense
This library is part of the Kaskad monorepo.
