npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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, and failed states for UI feedback

Architecture Overview

eval-tree transforms formula AST nodes into reactive MobX computations:

EvalNode (AST) → evaluateNode() → EvaluationResult → EvalState

Data Flow

  1. Input: EvalNode - Immutable AST from formula parser
  2. Processing: evaluateNode() - Recursive evaluation with MobX computeds
  3. Output: EvaluationResult - Contains MobX computed + disposal functions
  4. 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); // 42

Async 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 errored

State 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:

  1. All results wrapped in computed() - Automatic memoization
  2. Lazy evaluation - Only computed when .get() is called
  3. Automatic updates - MobX tracks dependencies and recomputes when they change
  4. 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 automatically

Function 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 flags

FunctionRegistry 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:

  1. Creation: Subscription created when Observable is wrapped
  2. Re-evaluation: Old subscription unsubscribed when function re-evaluates
  3. 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 unsubscribed

Type 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());  // Cleanup

FunctionRegistry

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
): ReactiveFunctionDefinition

Automatically called by FunctionRegistry.setFunction().

wrapImperativeAsReactive()

Wraps an imperative function for the reactive pipeline.

function wrapImperativeAsReactive(
  imperativeDef: FunctionDefinition
): ReactiveFunctionDefinition

Handles 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 (evaluating flag)
  • 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:

  1. Function execution computed (from reactive function)
  2. 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:

  1. RxJS subscriptions - From Observable-returning functions
  2. Child disposers - Propagated from nested evaluations
  3. 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:

  1. Subscribe: When toObservable() wraps Observable
  2. Track: Subscription stored in ObservableValue.subscription
  3. Cleanup on re-eval: Old subscription unsubscribed when function re-executes
  4. 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 unsubscribed

Common 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-tree

Run tests with coverage:

npx nx test eval-tree --coverage

Build library:

npx nx build eval-tree

License

This library is part of the Kaskad monorepo.