DevToys Web Pro iconDevToys Web ProBlog
Ocijenite nas:
Isprobajte proširenje preglednika:
← Back to Blog

Math Expression Evaluator Guide: Why eval Is Dangerous and How Parsers Work

10 min read

When you type 2 + 2 * 3 into a calculator and expect 8, you are implicitly trusting that the tool understands operator precedence, not just left-to-right execution. Building that kind of evaluator correctly — especially in JavaScript — is more nuanced than it first appears. The obvious shortcut is eval(), and it works until it does not. Try it live with the Math Expression Evaluator to see what a safe parser produces.

This guide covers why eval() and new Function() are the wrong tools for evaluating user-supplied math, how a real expression parser works internally, operator precedence and associativity rules, floating-point precision limits, and the features you should expect from a production-quality math evaluator. The canonical safe library for JavaScript is mathjs, and you will see it throughout.

Why eval() Is Dangerous for User-Supplied Math

The fastest way to evaluate a math string in JavaScript is to hand it directly to the engine:

// Do NOT do this with user input
const result = eval(userInput);

The problem is that eval() does not evaluate math — it evaluates JavaScript. Any expression that is valid JavaScript will run. A user who knows this can do far more than calculate:

  • Arbitrary code execution — the input fetch('/api/admin') is valid JavaScript. So is calling localStorage.clear(), reading environment variables in a Node.js context, or spawning child processes. The evaluator has no concept of "this is just arithmetic," so none of those are blocked.
  • Prototype chain access — an attacker can walk up the prototype chain to reach global objects and constructors. Even sandbox attempts inside eval() have well-documented escape paths that researchers have catalogued for years.
  • Denial of service — an infinite loop or deeply recursive expression is syntactically valid JavaScript and will hang the event loop (in a browser tab) or freeze a Node.js server. A math-only parser can detect and reject these patterns by enforcing a maximum AST depth and an iteration budget.

The new Function() constructor has the same problems. It creates a new function from a string and executes it. While it does not share the local scope the way inline eval() does, it still runs arbitrary JavaScript and has access to the global object. Neither mechanism is remotely suitable for evaluating untrusted math input.

How a Real Expression Parser Works

A dedicated math expression parser is safe because it only understands math. It cannot execute arbitrary JavaScript because it never calls the JavaScript engine to interpret the input. Instead, it transforms the string through three well-defined stages:

  1. Tokenize (lex) — split the raw string into a flat list of typed tokens: numbers, operators, identifiers (function names and variable names), parentheses, and commas. Anything that does not fit a known token type is immediately rejected as a syntax error. The input fetch('/api') produces an unknown identifier token and is rejected before any evaluation occurs.
  2. Parse to an Abstract Syntax Tree (AST) — the token stream is fed into a parser that enforces the grammar of mathematical expressions. The parser handles precedence and associativity by construction (usually via a Pratt parser or recursive descent with precedence climbing). The output is a tree where each node is a typed math operation.
  3. Evaluate the AST — walk the tree recursively, computing numeric values bottom-up. At no point is a string passed to the JavaScript engine. The evaluator only knows how to add, subtract, multiply, call Math.sin, and so on.

Because the grammar only accepts numeric literals, operators, known function names, and declared variables, there is no path for attacker-controlled code to reach the JavaScript runtime. The parser is a closed sandbox by design.

Safe Evaluation with mathjs

mathjs is the most complete open-source math expression library for JavaScript. It implements the full parse-then-evaluate pipeline, supports a rich set of functions and constants, and its evaluate() method is the direct safe replacement for eval() on math strings.

import { evaluate, pi, e } from "mathjs";

// Basic arithmetic with correct precedence
evaluate("2 + 2 * 3");          // 8  (not 12)
evaluate("(2 + 2) * 3");        // 12

// Built-in functions and constants
evaluate("sin(pi / 2)");        // 1
evaluate("log(e)");             // 1
evaluate("sqrt(144)");          // 12

// Variables via a scope object
evaluate("a^2 + b^2", { a: 3, b: 4 });  // 25

// Unit-aware computation
evaluate("5 km to m");          // 5000 m

Contrast that with the eval() equivalent, which exposes the full JavaScript runtime to whatever string the user provides. The mathjs evaluator rejects non-math input at the tokenizer stage and raises a descriptive parse error instead of executing it.

Operator Precedence and Associativity

The most common source of unexpected results in a math evaluator is operator precedence. The standard PEMDAS / BODMAS order applies:

PrecedenceOperatorsAssociativityExample
HighestParentheses ()N/A(2+3)*4 → 20
2Exponentiation ^Right-to-left2^3^2 → 512
3Unary minus -Right-to-left-2^2 → -4
4Multiply, Divide, ModuloLeft-to-right6/2*3 → 9
LowestAdd, SubtractLeft-to-right5-3+1 → 3

Right-associativity of exponentiation is a frequent surprise. The expression 2^3^2 is parsed as 2^(3^2) = 2^9 = 512, not as (2^3)^2 = 64. Left-associative operators like multiplication chain left: 6/2*3 is (6/2)*3 = 9, not 6/(2*3) = 1.

Order-of-operations gotchas

A classic viral example is 6/2(1+2). Strict mathematical notation treats juxtaposition as implicit multiplication, but most calculators and parsers require an explicit operator. Without the *, a well-behaved parser raises a syntax error rather than guessing intent. Always write 6/2*(1+2) to be unambiguous.

Unary minus interacts with exponentiation in a similarly surprising way. In standard math notation, -x^2 means -(x^2), not (-x)^2. Most expression parsers follow that convention, so -2^2 evaluates to -4, not 4. Use parentheses to be explicit: (-2)^2 gives 4.

Features of a Good Math Evaluator

A production-quality math evaluator should handle far more than the four basic operations. Here is what to look for:

  • Trigonometric functionssin, cos, tan and their inverses (asin, acos, atan), plus hyperbolic variants. Arguments typically in radians; a good tool also accepts sin(90 deg).
  • Exponential and logarithmic functionsexp, log (natural), log10, log2, and sqrt.
  • Built-in constantspi (π), e (Euler's number), phi (golden ratio), Infinity.
  • Variables and assignments — allow users to define x = 5 then use x in subsequent expressions.
  • Unit-aware computation — convert between units automatically, e.g. 5 km + 500 m5.5 km. mathjs has one of the most complete unit systems available in a JavaScript library.
  • Statistical helpersmean, median, std, sum operating on arrays.

For percentage-based calculations, the Percentage Calculator offers dedicated inputs for tip, discount, and percent-change scenarios that a general expression evaluator handles less ergonomically.

Floating-Point Precision and Big Numbers

Even a perfectly safe parser has to live with IEEE 754 double-precision floating-point arithmetic, which is what JavaScript uses for all of its numbers. This means some results look surprising:

// Standard JavaScript floating-point behavior
0.1 + 0.2;           // 0.30000000000000004
0.1 + 0.2 === 0.3;   // false

// mathjs with default precision
evaluate("0.1 + 0.2");  // 0.30000000000000004 — same IEEE 754 result

This is not a bug — it is a fundamental property of binary floating-point representation. The fractions 0.1 and 0.2 cannot be represented exactly in base 2, so their sum accumulates a tiny rounding error.

When precision matters: BigNumber mode

mathjs ships with a BigNumber type backed by the decimal.js library. In BigNumber mode, arithmetic is performed in decimal with a configurable number of significant digits (default 64), eliminating the binary rounding issue for most financial and scientific use cases:

import { create, all } from "mathjs";

const math = create(all, { number: "BigNumber", precision: 64 });

math.evaluate("0.1 + 0.2").toString();  // "0.3"
math.evaluate("1 / 3").toString();
// "0.3333333333333333333333333333333333333333333333333333333333333333"

The trade-off is performance: BigNumber operations are significantly slower than native floats and should be reserved for cases where precision genuinely matters rather than used by default everywhere.

Developer Scenarios

A browser-based math evaluator earns its place in the developer toolkit in several concrete situations:

  • Quick in-browser calculation — verifying that 2^16 is 65536, converting bytes to megabytes, or confirming a percentage without opening a spreadsheet.
  • Deriving config values — computing timeout durations, buffer sizes, or rate-limit thresholds from first principles before hardcoding them: 24 * 60 * 60 * 1000 for milliseconds-per-day, 1024^3 for bytes-per-gigabyte.
  • Sanity-checking formulas — validating that a compound interest formula, an amortization schedule, or an algorithm's complexity expression produces the expected value for known inputs before embedding it in code.
  • Unit conversions — a unit-aware evaluator removes the need to look up conversion factors; you write 70 kg to lb and get the answer.
// Deriving config values inline
evaluate("24 * 60 * 60 * 1000");   // 86400000 (ms per day)
evaluate("1024^3");                 // 1073741824 (bytes per GB)
evaluate("log2(1024)");             // 10 (bits needed for 1024 values)

// Sanity-check compound interest: P(1 + r/n)^(nt)
// P=1000, r=0.05, n=12, t=10
evaluate("1000 * (1 + 0.05/12)^(12*10)");  // 1647.009...

Inside the Tokenizer

Understanding how tokenization works explains why unknown input is safe to reject rather than dangerous to execute. The tokenizer scans the input string left-to-right with a finite-state machine. Each character transitions the state:

  • A digit starts a Number token; more digits and an optional decimal point extend it.
  • A letter starts an Identifier token (function name or variable); more alphanumerics extend it.
  • An operator character (+, -, *, /, ^, %) emits an Operator token immediately.
  • Parentheses emit LParen or RParen tokens.
  • Anything else — a backtick, a semicolon, a brace — is an unknown character and raises a lexer error.

This whitelist approach means the set of tokens that can ever reach the parser is bounded and fully enumerated. There is no way to introduce a fetch, a string literal, a property access, or any other JavaScript construct that the tokenizer does not already know about.

If you work with numbers in different bases — hexadecimal, binary, octal — the Number Base Conversion article covers how positional notation works, why two's complement matters for binary arithmetic, and how to convert between bases accurately.


Evaluate math expressions safely in your browser with the Math Expression Evaluator — it uses a dedicated parser so your input is never passed to eval(), and everything runs locally so your expressions never leave your machine.