Safe math evaluator with variables, dependencies, and precision.

evalla

Safe math evaluator with variables, dependencies, and precision.

import { evalla } from 'evalla';

const result = await evalla([
  { name: 'a', expr: 'c + 5' },          // depends on c
  { name: 'b', expr: 'a * 2' },          // depends on a
  { name: 'c', expr: '1 + 1' }           // base constant
]);

console.log(result.values.a.toString()); // "7"
console.log(result.values.b.toString()); // "14"
console.log(result.values.c.toString()); // "2"
console.log(result.order);               // ['c', 'a', 'b']

Features

Installation

npm install evalla

Usage

Input Format

interface ExpressionInput {
  name: string;         // Variable name (cannot start with $)
  expr?: string;        // Math expression (optional if value is provided)
  value?: any;          // Direct value (optional if expr is provided)
}

You must provide either expr or value per item. The value property allows you to pass objects and arrays directly without stringifying them into expressions.

Important Type Restrictions:

Using expressions:

const result = await evalla([
  { name: 'width', expr: '100' },
  { name: 'height', expr: '50' }
]);

Using direct values:

const result = await evalla([
  { name: 'point', value: { x: 10, y: 20 } },
  { name: 'offset', value: { x: 5, y: 10 } }
]);

Mixing both:

const result = await evalla([
  { name: 'data', value: { width: 100, height: 50 } },
  { name: 'area', expr: 'data.width * data.height' }
]);

Output Format

interface EvaluationResult {
  values: Record<string, Decimal | boolean | null>;  // Computed values
  order: string[];                                   // Evaluation order (topologically sorted)
}

Results can be:

Examples

Basic Arithmetic with Precision

const result = await evalla([
  { name: 'x', expr: '0.1 + 0.2' }
]);

console.log(result.values.x.toString()); // "0.3" (exact!)

Variable Dependencies

const result = await evalla([
  { name: 'd', expr: 'c * 2' },
  { name: 'b', expr: 'a + 10' },
  { name: 'c', expr: 'b * 3' },
  { name: 'a', expr: '5' }
]);

// Automatically orders: a, b, c, d
console.log(result.order); // ['a', 'b', 'c', 'd']
console.log(result.values.d.toString()); // "90"

Arrays and Objects (via value property)

Important: Arrays and objects can ONLY be supplied via the value property. They cannot be created in expressions.

Arrays passed via value can be accessed with computed property syntax:

const result = await evalla([
  { name: 'data', value: [10, 20, 30, 40, 50] },
  { name: 'first', expr: 'data[0]' },
  { name: 'sum', expr: 'data[0] + data[1] + data[2]' }
]);

console.log(result.values.first.toString()); // "10"
console.log(result.values.sum.toString());   // "60"
// Note: result.values.data is undefined (arrays from value property are not in output)

Object Property Access (via value property)

Objects must be passed via the value property, then accessed with dot notation:

const result = await evalla([
  { name: 'point', value: {x: 10, y: 20} },
  { name: 'offset', value: {x: 5, y: 10} },
  { name: 'resultX', expr: 'point.x + offset.x' },
  { name: 'resultY', expr: 'point.y + offset.y' }
]);

console.log(result.values.resultX.toString()); // "15"
console.log(result.values.resultY.toString()); // "30"
// Note: result.values.point and result.values.offset are undefined (objects from value property are not in output)

Special Property Names: Use string literals in computed property access for property names with special characters:

const result = await evalla([
  { name: 'obj', value: { 'y-y': 20, 'prop name': 42 } },
  { name: 'hyphen', expr: 'obj["y-y"]' },      // String literal for hyphenated property
  { name: 'space', expr: 'obj["prop name"]' }  // String literal for property with space
]);

console.log(result.values.hyphen.toString()); // "20"
console.log(result.values.space.toString());  // "42"

Note: Objects and arrays cannot be created within expressions. Use the value property to pass them, then access properties/elements in expressions.

Nested Property Access

const result = await evalla([
  { name: 'data', value: {pos: {x: 5, y: 10}, scale: 2} },
  { name: 'scaledX', expr: 'data.pos.x * data.scale' }
]);

console.log(result.values.scaledX.toString()); // "10"

Reserved Values

Algebra, not code: evalla is designed for natural mathematical expressions, not programming. A few special values are reserved as first-class mathematical primitives and cannot be used as variable names:

These reserved values allow evalla to return boolean results from comparisons and handle edge cases naturally, just like in mathematical notation.

// ❌ Cannot use reserved values as variable names
await evalla([{ name: 'true', expr: '10' }]);     // ValidationError
await evalla([{ name: 'false', expr: '10' }]);    // ValidationError
await evalla([{ name: 'null', expr: '10' }]);     // ValidationError
await evalla([{ name: 'Infinity', expr: '10' }]); // ValidationError

// ✅ Can use them as values
const result = await evalla([
  { name: 'isValid', expr: 'true' },
  { name: 'isEmpty', expr: 'false' },
  { name: 'missing', expr: 'null' }
]);

Why reserve these? Unlike programming keywords (which evalla allows as variable names), these mathematical primitives need special handling to support boolean logic and comparisons as first-class results.

Boolean Output

Results can now be boolean values, not just numbers. This enables natural mathematical questions:

Standalone Comparisons

Comparisons return true or false:

const result = await evalla([
  { name: 'slope', expr: '0.75' },
  { name: 'isSteep', expr: 'slope > 0.5' }  // Returns boolean true
]);

console.log(result.values.isSteep); // true
console.log(typeof result.values.isSteep); // "boolean"

All comparison operators return boolean:

Boolean Branches in Ternary

Ternary expressions can have boolean or null branches:

const result = await evalla([
  { name: 'score', expr: '85' },
  { name: 'passed', expr: 'score >= 60 ? true : false' },
  { name: 'bonus', expr: 'score >= 90 ? 10 : null' }
]);

console.log(result.values.passed);  // true (boolean)
console.log(result.values.bonus);   // null

Boolean Literals

Use true and false directly:

const result = await evalla([
  { name: 'enabled', expr: 'true' },
  { name: 'disabled', expr: 'false' },
  { name: 'result', expr: 'enabled && !disabled' }
]);

console.log(result.values.result); // true

Equality Operators

evalla supports two equality operators that work identically:

Single Equals = (Algebraic)

In mathematics, = tests equality. evalla follows this convention:

const result = await evalla([
  { name: 'x', expr: '5' },
  { name: 'y', expr: '5' },
  { name: 'equal', expr: 'x = y' }  // Algebraic equality
]);

console.log(result.values.equal); // true

Double Equals == (Programmer-Friendly)

For programmers familiar with ==, it works exactly the same:

const result = await evalla([
  { name: 'a', expr: '10' },
  { name: 'b', expr: '20' },
  { name: 'test', expr: '(a + 10) == b' }
]);

console.log(result.values.test); // true

No loose vs. strict: Unlike JavaScript, there’s no distinction between = and == in evalla. Both = and == perform the same strict equality check. Use whichever feels more natural.

Formatting Results

By default, evalla returns full-precision Decimal values. For display purposes, you can format results to a specific number of decimal places using the formatResults() function:

import { evalla, formatResults } from 'evalla';

// Evaluate with full precision
const result = await evalla([
  { name: 'pi', expr: '3.14159265358979323846' },
  { name: 'oneThird', expr: '1/3' }
]);

// Format for display
const formatted = formatResults(result, { decimalPlaces: 7 });

console.log(formatted.values.pi.toString());       // "3.1415927"
console.log(formatted.values.oneThird.toString()); // "0.3333333"

Key points:

Common use cases:

// Financial precision (2 decimal places)
const financial = formatResults(result, { decimalPlaces: 2 });

// Engineering precision (6-7 decimal places)
const engineering = formatResults(result, { decimalPlaces: 6 });

// Scientific precision (custom)
const scientific = formatResults(result, { decimalPlaces: 10 });

Namespaces

Variables may not begin with $, this is reserved for namespaces for built-in functions and constants.

$math Namespace

Mathematical constants and functions:

const result = await evalla([
  { name: 'circumference', expr: '2 * $math.PI * 10' },
  { name: 'absVal', expr: '$math.abs(-42)' },
  { name: 'sqrtVal', expr: '$math.sqrt(16)' },
  { name: 'maxVal', expr: '$math.max(10, 5, 20, 3)' }
]);

Available:

$unit Namespace

Unit conversion functions:

const result = await evalla([
  { name: 'inches', expr: '$unit.mmToInch(25.4)' },
  { name: 'mm', expr: '$unit.inchToMm(1)' }
]);

Available:

$angle Namespace

Angle conversion functions:

const result = await evalla([
  { name: 'radians', expr: '$angle.toRad(180)' },
  { name: 'degrees', expr: '$angle.toDeg($math.PI)' }
]);

Available:

Circular Dependency Detection

try {
  await evalla([
    { name: 'a', expr: 'b + 1' },
    { name: 'b', expr: 'a + 1' }
  ]);
} catch (error) {
  console.log(error.message); // "Circular dependency detected: b -> a -> b"
}

Variable naming

Variables may not begin with a number, double underscore(__), or $ (see namespaces above).

Variables cannot use reserved values: true, false, null, Infinity (see Reserved Values).

Keywords as Variable Names

Unlike JavaScript, algebra-like variable names can include JavaScript keywords:

const result = await evalla([
  { name: 'return', expr: '10' },
  { name: 'if', expr: '20' },
  { name: 'for', expr: 'return + if' }
]);

console.log(result.values.for.toString()); // "30"

Security

Safe by design:

Blocked property access examples:

// These will throw SecurityError
await evalla([{ name: 'bad', expr: 'obj.prototype' }]);
await evalla([{ name: 'bad', expr: 'obj.__proto__' }]);
await evalla([{ name: 'bad', expr: 'obj.constructor' }]);
await evalla([{ name: 'bad', expr: 'obj.__defineGetter__' }]);

Blocked function aliasing examples:

// These will throw SecurityError - functions must be called with ()
await evalla([{ name: 'myabs', expr: '$math.abs' }]);
await evalla([{ name: 'mysqrt', expr: '$math.sqrt' }]);
// Correct usage - call the function:
await evalla([{ name: 'result', expr: '$math.abs(-5)' }]); // ✅ Works

Blocked namespace head usage:

// Namespace heads cannot be used as standalone values
await evalla([{ name: 'a', expr: '$math' }]); // ❌ EvaluationError
await evalla([{ name: 'a', value: 5 }, { name: 'b', expr: 'a < $angle' }]); // ❌ EvaluationError
// Correct usage - access properties or call methods:
await evalla([{ name: 'pi', expr: '$math.PI' }]); // ✅ Works
await evalla([{ name: 'rad', expr: '$angle.toRad(180)' }]); // ✅ Works

Properties starting with __ are blocked because they typically provide access to JavaScript internals that could be exploited for prototype pollution or other security vulnerabilities.

API

evalla(inputs: ExpressionInput[]): Promise<EvaluationResult>

Evaluates an array of math expressions with dependencies.

Parameters:

Returns:

Throws:

All errors include structured details for programmatic access - no need to parse error messages!

Error Handling:

import { evalla, ParseError, SecurityError, CircularDependencyError, ValidationError, EvaluationError } from 'evalla';

try {
  const result = await evalla(inputs);
} catch (error) {
  // Catch ParseError first since it extends EvaluationError
  if (error instanceof ParseError) {
    console.error(`Syntax error in "${error.variableName}" at ${error.line}:${error.column}`);
    console.error(`Expression: ${error.expression}`);
  } else if (error instanceof EvaluationError) {
    console.error(`Runtime error in "${error.variableName}"`);
  } else if (error instanceof SecurityError) {
    console.error(`Security violation: attempted to access "${error.property}"`);
  } else if (error instanceof CircularDependencyError) {
    console.error(`Circular dependency: ${error.cycle.join(' -> ')}`);
  } else if (error instanceof ValidationError) {
    console.error(`Invalid variable: "${error.variableName}"`);
  }
}

checkSyntax(expr: string): SyntaxCheckResult

Checks the syntax of an expression without evaluating it. Useful for text editors to validate expressions before sending them for evaluation.

Parameters:

Returns:

Example:

import { checkSyntax, formatErrorMessage } from 'evalla';

// Valid expression
const result1 = checkSyntax('a + b * 2');
console.log(result1.valid); // true

// Invalid expression - missing closing parenthesis
const result2 = checkSyntax('(a + b');
console.log(result2.valid); // false
console.log(result2.error); // "PARSE_ERROR_AT_LOCATION"
console.log(result2.line); // 1
console.log(result2.column); // 7

// Get human-readable error message
const formatted = formatErrorMessage(result2.error, 'en', result2);
console.log(formatted);
// "Parse error at line 1, column 7: Expected ")", "+", "-", "*", "/", "%", "**", ".", "[", "?", "??", "&&", "||", "=", "==", "!=", "<", ">", "<=", ">=", or end of input but end of input found."

Formatting Error Messages:

The error field contains an enum key. To get a human-readable message with placeholders filled in, use formatErrorMessage():

import { checkSyntax, formatErrorMessage, ErrorMessage } from 'evalla';

const result = checkSyntax('a b');

if (!result.valid) {
  // Option 1: Get formatted error message
  const message = formatErrorMessage(result.error as ErrorMessage, 'en', result);
  console.log(message);
  // "Parse error at line 1, column 3: Expected "!=", "&&", "(", "**", ".", "<=", "=", "==", ">=", "?", "??", "[", "||", [%*/], [+\-], [<>], or end of input but "b" found."
  
  // Option 2: Use individual fields for custom formatting
  console.log(`Error at ${result.line}:${result.column}: ${result.message}`);
  // "Error at 1:3: Expected "!=", "&&", ... but "b" found."
}

The result object contains all the data needed for proper formatting:

Pass the entire result object to formatErrorMessage() as the third parameter to replace all placeholders ({line}, {column}, {message}) with actual values.

Usage Patterns:

When to use checkSyntax() vs just calling evalla():

import { checkSyntax, evalla, EvaluationError } from 'evalla';

// Pattern 1: Pre-validate for immediate user feedback (recommended for text editors/UI)
const inputs = [
  { name: 'a', expr: 'c + 5' },
  { name: 'b', expr: 'a * 2' }
];

// Check syntax of each expression before calling evalla
for (const input of inputs) {
  if (input.expr) {
    const check = checkSyntax(input.expr);
    if (!check.valid) {
      console.error(`Invalid syntax in "${input.name}": ${check.error}`);
      return; // Don't call evalla with invalid syntax
    }
  }
}

// All syntax valid, now evaluate
const result = await evalla(inputs);

// Pattern 2: Let evalla handle all validation (simpler for batch processing)
try {
  const result = await evalla(inputs);
  // Success - use result
} catch (error) {
  // Catch ParseError specifically to handle syntax errors
  if (error instanceof ParseError) {
    console.error(`Syntax error in "${error.variableName}" at ${error.line}:${error.column}`);
  } else if (error instanceof EvaluationError) {
    console.error('Runtime evaluation error:', error.message);
  }
  // Handle other error types (ValidationError, CircularDependencyError, etc.)
}

Note:

checkVariableName(name: string): VariableNameCheckResult

Checks if a variable name is valid according to evalla’s naming rules. Useful for text editors to validate variable names before evaluation and provide helpful error messages.

Parameters:

Returns:

Example:

import { checkVariableName } from 'evalla';

// Valid variable name
const result1 = checkVariableName('myVar');
console.log(result1.valid); // true

// Invalid - starts with $
const result2 = checkVariableName('$myVar');
console.log(result2.valid); // false
console.log(result2.error); // "Variable names cannot start with $ (reserved for system namespaces)"

// Invalid - reserved value name
const result3 = checkVariableName('true');
console.log(result3.valid); // false
console.log(result3.error); // "Variable name cannot be a reserved value: true"

isValidName(name: string): boolean

Simple boolean check for variable name validity. This is a simpler alternative to checkVariableName() when you don’t need detailed error messages.

Parameters:

Returns:

Example:

import { isValidName } from 'evalla';

isValidName('myVar');      // true
isValidName('$myVar');     // false (starts with $)
isValidName('__private');  // false (starts with __)
isValidName('true');       // false (reserved value)

VALID_NAME_PATTERN: RegExp

Regular expression pattern for valid variable names. Useful when you need to validate names in your own code or UI.

Pattern: /^(?![_$]{2})[a-zA-Z_][a-zA-Z0-9_$]*$/

Matches names that:

Note: This pattern does not check for reserved value names (true, false, null, Infinity). Use isValidName() or checkVariableName() for complete validation.

Example:

import { VALID_NAME_PATTERN } from 'evalla';

VALID_NAME_PATTERN.test('myVar');     // true
VALID_NAME_PATTERN.test('var123');    // true
VALID_NAME_PATTERN.test('_private');  // true
VALID_NAME_PATTERN.test('my$var');    // true
VALID_NAME_PATTERN.test('$invalid');  // false (starts with $)
VALID_NAME_PATTERN.test('__proto__'); // false (starts with __)
VALID_NAME_PATTERN.test('123abc');    // false (starts with number)
VALID_NAME_PATTERN.test('a.b');       // false (contains dot)
VALID_NAME_PATTERN.test('true');      // true (pattern matches, but it's reserved - use isValidName())

RESERVED_VALUES: readonly ['true', 'false', 'null', 'Infinity']

Array of reserved value names that cannot be used as variable names. Frozen at runtime to prevent mutation.

Example:

import { RESERVED_VALUES } from 'evalla';

RESERVED_VALUES.includes('true');  // true
RESERVED_VALUES.includes('myVar'); // false

// Array is frozen - cannot be mutated
RESERVED_VALUES.push('newValue'); // throws TypeError

formatResults(result: EvaluationResult, decimalPlaces: number): EvaluationResult

Format numeric results to a specific number of decimal places. This is a presentation utility - evaluation always uses full precision internally.

Parameters:

Returns:

Example:

import { evalla, formatResults } from 'evalla';

const result = await evalla([
  { name: 'pi', expr: '$math.PI' },
  { name: 'area', expr: 'pi * 10 * 10' }
]);

const formatted = formatResults(result, 2);
console.log(formatted.values.pi.toString());   // "3.14"
console.log(formatted.values.area.toString()); // "314.16"

Error Handling

evalla throws typed errors with programmatic enum keys. Error messages are enum keys (e.g., UNDEFINED_VARIABLE), with context available via error properties.

Error Types:

Error Keys: See ErrorMessage enum for all 48 error keys.

Example:

try {
  await evalla([{ name: 'y', expr: 'x + 1' }]);
} catch (error) {
  console.log(error.message);      // "UNDEFINED_VARIABLE"
  console.log(error.variableName); // "x"
}

Optional i18n: Use formatErrorMessage(key, lang, params) to get human-readable messages.

Philosophy

Development

# Install dependencies
npm install

# Build
npm run build

# Test
npm test

For detailed API documentation and examples, see the sections above.

License

MIT